diff --git a/CHANGELOG.md b/CHANGELOG.md index 7376205f..62a1758f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/hslee/summary-detached-head/summary-detached-head-artifact.md b/docs/hslee/summary-detached-head/summary-detached-head-artifact.md new file mode 100644 index 00000000..8bb5fa83 --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-artifact.md @@ -0,0 +1,44 @@ +# summary-detached-head — Artifact + +*최종 결과물과 학습 내용을 기록한다.* + +## 결과 +`branchState`에 `detached`를 분리하고 `summary --write` 가드가 이를 비-기본 브랜치처럼 `isSyncedWithDefault`로 판정하게 했다. 재현 테스트 3건(synced 허용·다른 커밋 거부·origin/HEAD 없음 거부). `done` 가드는 브랜치 조회가 없어 수정 불필요. + + +## Reviews +*Codex 등 리뷰 실행 시 결과(요약·발견·조치)를 날짜와 함께 남긴다. 남기지 않은 리뷰는 "안 한 것"으로 간주.* +*기계 판독용 마커를 함께 남긴다: ``* + +### 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`도 통과했습니다. + +**최종 판정:** 현재 작업 트리 변경분은 승인 가능합니다. 이미 커밋된 코드 변경은 이번 리뷰 범위에 포함하지 않았습니다. +``` + + + +**판별: 무효(범위 오류).** 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`는 통과했습니다. 읽기 전용 범위 때문에 파일을 생성하는 테스트 스위트는 실행하지 않았습니다. +``` + + + +**판별.** P1·P2 없음. P3(`summary.mjs:227` 주석 "Three outcomes")는 진짜 — `detached` 추가로 네 상태가 됐다. 주석을 "Four outcomes"로 고쳤다. + +## Learnings diff --git a/docs/hslee/summary-detached-head/summary-detached-head-context.md b/docs/hslee/summary-detached-head/summary-detached-head-context.md new file mode 100644 index 00000000..1663e3ef --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-context.md @@ -0,0 +1,27 @@ +# summary-detached-head — Context Card + + +## 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 +- diff --git a/docs/hslee/summary-detached-head/summary-detached-head-handoff.md b/docs/hslee/summary-detached-head/summary-detached-head-handoff.md new file mode 100644 index 00000000..108ccc55 --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-handoff.md @@ -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(-) diff --git a/docs/hslee/summary-detached-head/summary-detached-head-meta.json b/docs/hslee/summary-detached-head/summary-detached-head-meta.json new file mode 100644 index 00000000..ec7cf098 --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-meta.json @@ -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 + } + ] +} diff --git a/docs/hslee/summary-detached-head/summary-detached-head-plan.md b/docs/hslee/summary-detached-head/summary-detached-head-plan.md new file mode 100644 index 00000000..9df62b0a --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-plan.md @@ -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 가드는 브랜치 조회 없음 — 종결 실측으로 확인 diff --git a/docs/hslee/summary-detached-head/summary-detached-head-spec.md b/docs/hslee/summary-detached-head/summary-detached-head-spec.md new file mode 100644 index 00000000..60a3b4bd --- /dev/null +++ b/docs/hslee/summary-detached-head/summary-detached-head-spec.md @@ -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/`. 브랜치 이름·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/` diff --git a/src/commands/summary.mjs b/src/commands/summary.mjs index 312dd8b8..1e94624a 100644 --- a/src/commands/summary.mjs +++ b/src/commands/summary.mjs @@ -224,7 +224,7 @@ 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. @@ -232,7 +232,8 @@ export function renderAll(tasks) { // `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". @@ -240,7 +241,7 @@ 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' }; } @@ -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냐 diff --git a/tests/summary.test.mjs b/tests/summary.test.mjs index db98e382..c270247f 100644 --- a/tests/summary.test.mjs +++ b/tests/summary.test.mjs @@ -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;