Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ modified: 2026-09-13

## [Unreleased]

### Fixed
- **`summary --write`가 detached HEAD에서 "브랜치 조회 실패"로 거부되던 결함** (task `summary-detached-head`).
`branch --show-current`의 빈 출력(detached)을 git 오류와 같은 `error`로 분류해, 워커 워크트리가 `origin/main`에
detach해서 머지 후 종결할 때마다 같은 커밋의 임시 브랜치를 만들어 우회해야 했다(#114·#115 종결). 이제 detached는
별도 상태이고 비-기본 브랜치와 같은 판정(`isSyncedWithDefault`)을 받는다 — `origin/HEAD`가 가리키는 브랜치와 정확히
같은 커밋이면 통과, 다른 커밋·`origin/HEAD` 없음은 종전처럼 거부한다. git 실패는 계속 fail-closed이고 `done` 가드는
브랜치를 조회하지 않아 영향이 없다.

## [0.44.4] - 2026-09-27

### Fixed
Expand Down
44 changes: 44 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-artifact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# summary-detached-head — Artifact

*최종 결과물과 학습 내용을 기록한다.*

## 결과
`branchState`에 `detached`를 분리하고 `summary --write` 가드가 이를 비-기본 브랜치처럼 `isSyncedWithDefault`로 판정하게 했다. 재현 테스트 3건(synced 허용·다른 커밋 거부·origin/HEAD 없음 거부). `done` 가드는 브랜치 조회가 없어 수정 불필요.


## Reviews
*Codex 등 리뷰 실행 시 결과(요약·발견·조치)를 날짜와 함께 남긴다. 남기지 않은 리뷰는 "안 한 것"으로 간주.*
*기계 판독용 마커를 함께 남긴다: `<!-- harness:review kind=codex scope=worktree tip=<sha|none> at=<ISO8601> -->`*

### 2026-09-27T16:00:33.227Z — codex (harness-team review)

- engine: codex · scope: worktree · tip: a91b804fcd5f6a05981ffe0db708de66cf702579 · exit 0 · 596 B

```text
전하, **작업 트리 변경분에서 P1·P2·P3 지적 사항은 없습니다.** 변경된 파일은 [summary-detached-head-handoff.md](/Users/hsonpro/.ao/data/worktrees/harness-aijient-team-plugin/harness-aijient-team-plugin-11/docs/hslee/summary-detached-head/summary-detached-head-handoff.md:5) 하나이며, 추가된 커밋 기록과 변경 통계는 `HEAD`와 일치합니다. `git diff --check`도 통과했습니다.

**최종 판정:** 현재 작업 트리 변경분은 승인 가능합니다. 이미 커밋된 코드 변경은 이번 리뷰 범위에 포함하지 않았습니다.
```

<!-- harness:review kind=codex scope=worktree tip=a91b804fcd5f6a05981ffe0db708de66cf702579 at=2026-09-27T16:00:33.227Z -->

**판별: 무효(범위 오류).** post-commit 훅이 재생성한 handoff 때문에 작업 트리가 더러워 `--base`보다
worktree 범위가 우선됐고, 코드 변경은 보지 않았다. 훅 재생성분을 되돌린 깨끗한 트리에서 base 범위로 재실행한다.

### 2026-09-27T16:02:09.846Z — codex (harness-team review)

- engine: codex · scope: diff · tip: da927a6f1ab738ae15b42d01d07bffff10b53d1d · exit 0 · 927 B

```text
전하, **P1·P2 지적 사항은 없습니다.** `summary --write`는 detached HEAD에서 `origin/HEAD`가 가리키는 원격 기본 브랜치와 HEAD 커밋이 정확히 같을 때만 통과합니다. Git 조회 실패, 다른 커밋, `origin/HEAD` 부재는 계속 거부됩니다.

- **P3** [src/commands/summary.mjs:227](/Users/hsonpro/.ao/data/worktrees/harness-aijient-team-plugin/harness-aijient-team-plugin-11/src/commands/summary.mjs:227) — 주석의 “Three outcomes”는 `detached` 추가 후 실제 네 상태와 맞지 않습니다.

**최종 판정: 승인 가능.** 새 테스트는 동기화된 detached HEAD의 허용과 다른 커밋·`origin/HEAD` 부재 시 거부를 확인하도록 작성되어 있습니다. `git diff --check`와 두 변경 파일의 `node --check`는 통과했습니다. 읽기 전용 범위 때문에 파일을 생성하는 테스트 스위트는 실행하지 않았습니다.
```

<!-- harness:review kind=codex scope=diff tip=da927a6f1ab738ae15b42d01d07bffff10b53d1d at=2026-09-27T16:02:09.846Z -->

**판별.** P1·P2 없음. P3(`summary.mjs:227` 주석 "Three outcomes")는 진짜 — `detached` 추가로 네 상태가 됐다. 주석을 "Four outcomes"로 고쳤다.

## Learnings
27 changes: 27 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# summary-detached-head — Context Card
<!-- working set only; UTF-8 <= 6 KiB, nonblank lines <= 100 -->

## Now
- Goal:
- Current atomic step:
- Stop / human-decision condition:

## Constraints and settled decisions
-

## JIT retrieval map
- Identifiers / symbols:
- Narrow globs:
- Read next:
- Verification command:

## Failure capsules (max 3 unresolved)
### F-001
- Signal:
- Tried:
- Compact finding / current hypothesis:
- Next discriminator:
- Source (safe path or command):

## Resume checklist
-
15 changes: 15 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# summary-detached-head — Handoff

(세션 종료 시 post-commit hook이 자동 갱신합니다)

## 2026-09-27T15:59:29.743Z — a91b804 fix(summary): origin/main과 같은 커밋의 detached HEAD에서 --write를 허용한다
CHANGELOG.md | 8 ++++
.../summary-detached-head-artifact.md | 13 ++++++
.../summary-detached-head-context.md | 27 +++++++++++
.../summary-detached-head-handoff.md | 3 ++
.../summary-detached-head-meta.json | 11 +++++
.../summary-detached-head-plan.md | 19 ++++++++
.../summary-detached-head-spec.md | 40 ++++++++++++++++
src/commands/summary.mjs | 11 +++--
tests/summary.test.mjs | 54 ++++++++++++++++++++++
9 files changed, 182 insertions(+), 4 deletions(-)
30 changes: 30 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"user": "hslee",
"task": "summary-detached-head",
"created": "2026-09-27",
"firstActivatedAt": "2026-09-27T15:57:53.984Z",
"status": "open",
"closedAt": null,
"forcedAt": null,
"forcedIssues": null,
"reviews": [
{
"kind": "codex",
"engine": "codex",
"scope": "worktree",
"tip": "a91b804fcd5f6a05981ffe0db708de66cf702579",
"at": "2026-09-27T16:00:33.227Z",
"exitCode": 0,
"outputBytes": 596
},
{
"kind": "codex",
"engine": "codex",
"scope": "diff",
"tip": "da927a6f1ab738ae15b42d01d07bffff10b53d1d",
"at": "2026-09-27T16:02:09.846Z",
"exitCode": 0,
"outputBytes": 927
}
]
}
19 changes: 19 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# summary-detached-head — Plan

## 목표
동기화된 detached HEAD에서 `summary --write`가 통과하게 한다 — 가드 의미는 불변.

## 단계
- [x] 재현 실패 테스트 (synced detached 허용 · behind detached 거부 · origin 없는 detached 거부)
- [x] `branchState`에 `detached` 분리 + 가드를 `isSyncedWithDefault`로 라우팅
- [x] `npm test`·`npm run docs:check` green
- [x] CHANGELOG [Unreleased]
- [x] codex 리뷰 기록
- [ ] PR·CI green
- [ ] 머지 후 종결 — detached HEAD에서 `summary --write` 실측

## Ontology 변경 로그
- `branchState` 결과에 `detached` 추가 (종전엔 `error`에 흡수)

## 참고
- done 가드는 브랜치 조회 없음 — 종결 실측으로 확인
40 changes: 40 additions & 0 deletions docs/hslee/summary-detached-head/summary-detached-head-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# summary-detached-head — Spec

## 목적 / 요구사항
- **문제**: `harness-team summary --write`가 detached HEAD에서 "현재 브랜치를 확인할 수 없어 원장을 쓰지 않음"(브랜치 조회 실패)으로 거부된다.
`branch --show-current`가 detached에서 빈 문자열을 내고, `branchState`가 이를 git 오류와 같은 `error`로 분류했기 때문이다.
- **영향**: AO 워커 워크트리의 머지 후 종결. 기본 브랜치는 원본 체크아웃이 점유해 워커는 `origin/main`에 detach해서 종결하는데,
#114·#115 종결 모두 `origin/main`과 같은 커밋의 임시 로컬 브랜치를 만들어 우회했다.
- **기대 결과**: HEAD가 `origin/HEAD`가 가리키는 브랜치와 **정확히 같은 커밋**인 detached HEAD에서는 `--write`가 통과한다.
- **제약**: 가드 의도(#99·summary-branch-guard — 기본 브랜치와 동기화된 상태에서만 원장 갱신)를 약화하지 않는다.
동기화되지 않은 detached·`origin/HEAD` 없는 저장소의 detached는 계속 거부한다. `isSyncedWithDefault`는 바꾸지 않는다.

## 설계 / 접근
- `branchState`에 `detached` 결과를 추가한다(git 성공 + 빈 이름). git 실패는 종전대로 `error`(fail-closed).
- 가드: `detached`는 이름이 없으니 비-기본 브랜치처럼 취급 → `isSyncedWithDefault`가 참일 때만 통과.
근거는 synced 브랜치와 동일하다 — HEAD가 기본 tip과 같은 커밋이면 로컬 커밋이 0개라 원장 커밋이 기본 tip 바로 위에 얹힌다.
브랜치 이름은 이 근거에 기여하지 않는다.
- 거부 메시지는 기존 비-기본 브랜치 패킷을 재사용하고 `(현재: detached HEAD)`로 표기한다.
- **done 가드**: 브랜치 조회가 없다(`status --porcelain`·`log --since`만) — detached와 무관. 종결 실측으로 확인한다.

## Ontology
- **synced**: HEAD 커밋 == `refs/remotes/origin/<origin/HEAD가 가리키는 이름>`. 브랜치 이름·detached 여부와 무관한 커밋 동일성.
- **detached**: `branch --show-current`가 성공하고 빈 출력을 낸 상태. git 오류(`error`)와 구분된다.
- 게이트 근거: 목표·제약·성공 기준이 재현 테스트 3개로 결정론적으로 표현된다.

## Ambiguity 자가진단
- [x] **Goal 명확도** (40%) — synced detached HEAD에서 `--write` 통과.
- [x] **Constraint 명확도** (30%) — `isSyncedWithDefault` 불변, 비동기 detached·origin 없는 detached 거부 유지.
- [x] **Success 기준** (30%) — `tests/summary.test.mjs` detached 3건 + 실제 종결에서 detached `--write` 통과.
- [x] **Context 명확도** (brownfield 한정) — `src/commands/summary.mjs` `branchState`·`runSummary` 가드.
- [x] **Ambiguity ≤ 0.2** — 위 항목 가중합 1.0

## Done evidence
```json
{ "version": 1, "review": "required", "tests": "required" }
```

## 참고
- `src/commands/summary.mjs` `branchState` · `isSyncedWithDefault` 블록 주석
- `tests/summary.test.mjs` `cloneWithOrigin` 기반 detached 3건
- 선례: `docs/hslee/summary-branch-guard/`
13 changes: 8 additions & 5 deletions src/commands/summary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -224,23 +224,24 @@ export function renderAll(tasks) {
return files;
}

// Three outcomes, not two. "not a git repository" is safe to write in — there are no
// Four outcomes: branch, detached, error, none. "not a git repository" is safe to write in — there are no
// branches to collide across. Any other git failure (git missing, repo unreadable) must
// NOT be read as "no branches": that silently turns the guard off exactly when we cannot
// tell where we are.
//
// `branch --show-current` rather than `rev-parse --abbrev-ref HEAD`: the latter fails on
// an unborn HEAD (a fresh repo with no commits), which is a perfectly ordinary place to
// render the ledger. `--show-current` names the branch there, and prints nothing when
// HEAD is detached — which stays refused.
// HEAD is detached. Detached is its own outcome, not an error: it has no name to match,
// so it is treated like a non-default branch — refused unless `isSyncedWithDefault`.
//
// Repo-ness is decided on the filesystem, not by a second git call, so that a missing or
// broken git binary cannot masquerade as "no repository here".
async function branchState(targetDir) {
try {
const { stdout } = await pexec('git', ['-C', targetDir, 'branch', '--show-current']);
const name = stdout.trim();
return name ? { kind: 'branch', name } : { kind: 'error' };
return name ? { kind: 'branch', name } : { kind: 'detached' };
} catch {
return (await findGitDir(targetDir)) ? { kind: 'error' } : { kind: 'none' };
}
Expand Down Expand Up @@ -388,10 +389,12 @@ export async function runSummary(ctx) {
});
}
const bases = await defaultBranchCandidates(ctx.targetDir);
const offDefault = state.kind === 'branch' && !bases.includes(state.name) && !force;
const offDefault = !force
&& (state.kind === 'detached' || (state.kind === 'branch' && !bases.includes(state.name)));
if (offDefault && !(await isSyncedWithDefault(ctx.targetDir))) {
process.exitCode = 1;
return fail(json, 'summary', `기본 브랜치가 아니라 원장을 쓰지 않음 (현재: ${state.name})`, {
const current = state.kind === 'detached' ? 'detached HEAD' : state.name;
return fail(json, 'summary', `기본 브랜치가 아니라 원장을 쓰지 않음 (현재: ${current})`, {
cause: `공유 원장을 feature 브랜치에서 갱신하면 병렬 브랜치끼리 다시 충돌함 (기본 브랜치: ${bases.join(' 또는 ')})`,
retry: `\`${bases[0]}\` 로 전환한 뒤 \`harness-team summary --write\` 실행`,
// 우회 경로는 이 명령의 escape hatch(--force)까지만 안내한다 — 어떻게 반영할지(직접 push냐
Expand Down
54 changes: 54 additions & 0 deletions tests/summary.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,60 @@ test('--write: origin/main보다 뒤진(behind) 브랜치도 거부한다', asyn
}
});

// 워커 워크트리의 머지 후 종결은 `git checkout --detach origin/main` 위에서 한다 — 브랜치 이름이
// 없을 뿐 동기화된 브랜치와 같은 커밋이라, 같은 근거(로컬 커밋 0개)로 허용한다.
test('--write: origin/main과 같은 커밋의 detached HEAD에서도 원장을 쓴다', async () => {
const dir = await mkdtemp(join(tmpdir(), 'harness-summary-detached-'));
const exitCode = process.exitCode;
try {
const repo = await cloneWithOrigin(dir);
await git(repo, 'checkout', '-q', '--detach', 'refs/remotes/origin/main');
await runTask({ targetDir: repo, flags: { member: 'chad' }, taskArgs: ['demo'] });

await runSummary({ targetDir: repo, flags: { write: true } });
assert.notEqual(process.exitCode, 1, 'origin/main 과 같은 커밋이면 detached 라도 거부되면 안 된다');
const summary = await readFile(join(repo, 'docs', 'task_summary.md'), 'utf8');
assert.match(summary, /\| chad \| demo \| 🔄 open \|/);
} finally {
process.exitCode = exitCode;
await rm(dir, { recursive: true, force: true });
}
});

test('--write: origin/main과 다른 커밋의 detached HEAD는 거부한다', async () => {
const dir = await mkdtemp(join(tmpdir(), 'harness-summary-detached-behind-'));
const exitCode = process.exitCode;
try {
const repo = await cloneWithOrigin(dir);
await git(repo, 'checkout', '-q', '--detach', 'HEAD~1');
await runTask({ targetDir: repo, flags: { member: 'chad' }, taskArgs: ['demo'] });

await runSummary({ targetDir: repo, flags: { write: true } });
assert.equal(process.exitCode, 1, '동기화되지 않은 detached HEAD 는 이름이 없을 뿐 feature 커밋일 수 있다');
await assert.rejects(() => readFile(join(repo, 'docs', 'task_summary.md'), 'utf8'));
} finally {
process.exitCode = exitCode;
await rm(dir, { recursive: true, force: true });
}
});

test('--write: origin/HEAD가 없는 저장소의 detached HEAD는 거부한다 (fail-closed)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'harness-summary-detached-local-'));
const exitCode = process.exitCode;
try {
await initRepo(dir);
await git(dir, 'checkout', '-q', '--detach');
await runTask({ targetDir: dir, flags: { member: 'chad' }, taskArgs: ['demo'] });

await runSummary({ targetDir: dir, flags: { write: true } });
assert.equal(process.exitCode, 1, '기본 브랜치를 특정할 수 없으면 detached 는 계속 거부한다');
await assert.rejects(() => readFile(join(dir, 'docs', 'task_summary.md'), 'utf8'));
} finally {
process.exitCode = exitCode;
await rm(dir, { recursive: true, force: true });
}
});

test('--write: 커밋이 하나도 없는 저장소의 비-기본 브랜치는 거부한다 (HEAD 조회 실패는 fail-closed)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'harness-summary-unborn-'));
const exitCode = process.exitCode;
Expand Down
Loading