Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"payloadType": "application/vnd.ai-sdlc.attestation+json",
"payload": "eyJzY2hlbWFWZXJzaW9uIjoidjUiLCJzdWJqZWN0Ijp7ImRpZ2VzdCI6eyJzaGExIjoiZmI5NWI1ZTdhYWJjNjRjZDJhMzY5MjhhMzM4YzlmMGI3ZjQwMDU3MSJ9fSwiY29udGVudEhhc2hWMyI6Ijk3ZDg0NmM0ODFjMGE2YzJkMWVmODRlOGFjZGM5MmZiYzExMWJjODQ0ZWI2OTI3NjkxNWMzODVlMTMyODU2MjEiLCJjb250ZW50SGFzaFY0IjoiOGMzMzk1MzcwZGNlNTY2OTI5ZDIzY2NlZDQyZWIxYjFlZDY2NzEwNjZmYTUyOWZiOGZmNmFmMjE4NDZmOGZiMCIsInBvbGljeUhhc2giOiI0ZDJiYTA0OTAzMmYzYzgxYmY2MTIyYjcyMDFlM2I0NmIyOWM0MzY2ZThhYzAxNDFmNTEyMjY1MGVlNjlmM2ZhIiwicmV2aWV3ZXJzIjpbeyJhZ2VudElkIjoiY29kZS1yZXZpZXdlciIsImFnZW50RmlsZUhhc2giOiI5NjE1YzYyMjRiYjhjMmQ0MzhkNGMzMjRmMzIzM2NjMzIzOWE3OTI3ZWRhYWEzMzMzZmExNTMxZjBjMTI0NjYyIiwiaGFybmVzcyI6ImNsYXVkZS1jb2RlIiwiYXBwcm92ZWQiOnRydWUsImZpbmRpbmdzIjp7ImNyaXRpY2FsIjowLCJtYWpvciI6MCwibWlub3IiOjIsInN1Z2dlc3Rpb24iOjJ9fSx7ImFnZW50SWQiOiJ0ZXN0LXJldmlld2VyIiwiYWdlbnRGaWxlSGFzaCI6IjcyOGM3OTY2NmNmMjEzYWY3MDYxNzljYjRkOGJlNTZhNDVmNjIyNzgwYTEyNTkyODFiNDM5Y2RmN2RhOTZkY2EiLCJoYXJuZXNzIjoiY2xhdWRlLWNvZGUiLCJhcHByb3ZlZCI6dHJ1ZSwiZmluZGluZ3MiOnsiY3JpdGljYWwiOjAsIm1ham9yIjowLCJtaW5vciI6Mywic3VnZ2VzdGlvbiI6MH19LHsiYWdlbnRJZCI6InNlY3VyaXR5LXJldmlld2VyIiwiYWdlbnRGaWxlSGFzaCI6IjI4N2YwZDVhYjg5YTE4MTQzMDRiYjliNWJmNTMyZmIzYzczNDMwOWE3OTcwYTUxYTBkMWQ0ZjlhNzI0NGJlNDgiLCJoYXJuZXNzIjoiY2xhdWRlLWNvZGUiLCJhcHByb3ZlZCI6dHJ1ZSwiZmluZGluZ3MiOnsiY3JpdGljYWwiOjAsIm1ham9yIjowLCJtaW5vciI6MCwic3VnZ2VzdGlvbiI6MH19XSwicGx1Z2luVmVyc2lvbiI6IjAuOS4yIiwiaXRlcmF0aW9uQ291bnQiOjEsImhhcm5lc3NOb3RlIjoiIiwic2lnbmVkQXQiOiIyMDI2LTA1LTE4VDIwOjA5OjEzLjA2NFoiLCJzaWduZWRNZXJnZUJhc2UiOiJiZmU0MTJkNDdjM2QzZmJmNzhhMzM4N2VmZWE2MjU2MWQ3ODE4NjU5IiwiY29udGVudEhhc2hWNSI6Ijg5ZjBkNTFlYzdmZjNjNmI3NDc5YjQ2NmVlYTI5NjQyODk5NWUwM2ZjZDViNzk5NTEyMTliNTdjMzUzNTU3OGQiLCJwaXBlbGluZVZlcnNpb24iOiIwLjEwLjAifQ==",
"signatures": [
{
"keyid": "dominique@local:doms-macbook",
"sig": "9nv0VKUxvdt4orZdkA+TOfZFWTfw+e548JzWFehYthUWdCzznviu/jpx5GzFglQy8rlB2nOvru/n7/aiXbeiCg=="
}
]
}
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,14 @@ After resolving the candidate root, the resolver checks for Pattern C: if `<root
3. **No signal → refuse** with the Pattern C error message.

The typical Pattern C setup: `/ai-sdlc execute <task-id>` automatically writes `.worktrees/<task-id>/.active-task` (per AISDLC-81). For sessions where the env-var path is preferred (e.g. operator manually launching Claude Code into a multi-worktree project), set `AI_SDLC_ACTIVE_TASK_ID=AISDLC-NNN` before launch.

### Pattern C hard guards (AISDLC-358)

The parent working tree MUST be on `main` at all times. This is enforced by `scripts/check-orchestrator-state.sh` (called at Step 0 of every `/ai-sdlc execute` and `/ai-sdlc orchestrator-tick`) and by the inline `runParentBranchGuard()` check at the top of every `runOrchestratorTick()` call in `pipeline-cli/src/orchestrator/loop.ts`.

Guard logic (two outcomes):

- **Parent on non-main branch, clean working tree** → auto-recover: `git checkout main && git reset --hard origin/main`. Logs `[orchestrator-state] auto-recovered parent from '<branch>' to main`.
- **Parent on non-main branch, dirty working tree** → REFUSE. Prints the offending branch name, the dirty paths, and the manual recovery command. Exits non-zero (`check-orchestrator-state.sh`) or throws `ParentNotOnMainError` (TypeScript loop). The orchestrator tick is aborted; no frontier work proceeds.

Recovery (operator): stash or commit your changes in the parent, then run `git checkout main && git reset --hard origin/main`.
18 changes: 6 additions & 12 deletions pipeline-cli/src/orchestrator/chaos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,9 @@ describe('chaos — mid-dispatch SIGTERM (Q2 resume)', () => {
throw new Error('simulated SIGTERM mid-dispatch');
},
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-chaos-mid-dispatch',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

const result = await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -175,10 +174,9 @@ describe('chaos — mid-dispatch SIGTERM (Q2 resume)', () => {
frontier: fakeFrontier(['AISDLC-CHAOS']),
dispatch: dispatchFn,
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-chaos-resume',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

// Tick 1 — crashes.
Expand Down Expand Up @@ -216,10 +214,9 @@ describe('chaos — mid-finalize SIGTERM (events sink throws)', () => {
frontier: fakeFrontier(['AISDLC-FIN']),
dispatch: async (taskId) => approvedResult(taskId, `https://github.com/x/y/pull/9`),
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-chaos-mid-finalize',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
emitEvent: (ev) => {
sinkCalls += 1;
captured.push(ev);
Expand Down Expand Up @@ -354,10 +351,9 @@ describe('chaos — events.jsonl append-only integrity', () => {
return approvedResult(taskId, 'https://github.com/x/y/pull/1');
},
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-chaos-integ',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -416,10 +412,9 @@ describe('chaos — SIGTERM drain (Q2 resume contract)', () => {
frontier: fakeFrontier(['AISDLC-DRAIN-A']),
dispatch: async (taskId) => approvedResult(taskId, 'https://github.com/x/y/pull/A'),
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-A',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};
await runOrchestratorTick(config, adaptersA, 1);

Expand All @@ -431,10 +426,9 @@ describe('chaos — SIGTERM drain (Q2 resume contract)', () => {
frontier: fakeFrontier(['AISDLC-DRAIN-B']),
dispatch: async (taskId) => approvedResult(taskId, 'https://github.com/x/y/pull/B'),
escalate: async () => {},
parentBranchGuard: async () => {},
artifactsDir: workdir,
runId: 'run-B',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};
await runOrchestratorTick(config, adaptersB, 1);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe('runOrchestratorTick — blast-radius overlap integration (Major 3)', (
return approvedResult(taskId);
},
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: (ev) => emittedEvents.push(ev),
blastRadiusOverlapOpts: {
// AISDLC-100 has an open PR from a prior tick — visible to all candidates.
Expand All @@ -165,8 +166,6 @@ describe('runOrchestratorTick — blast-radius overlap integration (Major 3)', (
] as { number: number; headRefName: string }[],
computeBlastRadiusFiles: (taskId: string) => blastRadiusMap[taskId.toUpperCase()] ?? [],
},
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

const tick = await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -243,6 +242,7 @@ describe('runOrchestratorTick — OrchestratorBlockedByBlastRadiusOverlapEvent s
}),
dispatch: async (taskId) => approvedResult(taskId),
escalate: async () => {},
parentBranchGuard: async () => {},
now: () => now,
blastRadiusOverlapOpts: {
// AISDLC-150 has an open PR — branch name uses canonical numeric pattern.
Expand All @@ -261,8 +261,6 @@ describe('runOrchestratorTick — OrchestratorBlockedByBlastRadiusOverlapEvent s
return [];
},
},
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

const tick = await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -330,6 +328,7 @@ describe('runOrchestratorTick — OrchestratorBlockedByBlastRadiusOverlapEvent s
}),
dispatch: async (taskId) => approvedResult(taskId),
escalate: async () => {},
parentBranchGuard: async () => {},
blastRadiusOverlapOpts: {
listOpenPRs: () => [],
computeBlastRadiusFiles: (taskId: string) => {
Expand All @@ -338,8 +337,6 @@ describe('runOrchestratorTick — OrchestratorBlockedByBlastRadiusOverlapEvent s
return [];
},
},
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down
18 changes: 6 additions & 12 deletions pipeline-cli/src/orchestrator/loop.resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,13 @@ describe('runOrchestratorTick — aborted outcome (AISDLC-242)', () => {
frontier: fakeFrontier([taskId]),
dispatch: flipAndReturn(workDir, taskId, 'aborted', 'watchdog fired after 30min'),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runner,
runId: 'aisdlc-242-aborted-test',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
taskLabelsLoader: () => [],
calibrationLogPath: '/nonexistent-bypass.jsonl',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand All @@ -216,14 +215,13 @@ describe('runOrchestratorTick — aborted outcome (AISDLC-242)', () => {
frontier: fakeFrontier([taskId]),
dispatch: flipAndReturn(workDir, taskId, 'aborted', 'watchdog fired after 30min'),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runner,
runId: 'aisdlc-242-recoverable-event',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
taskLabelsLoader: () => [],
calibrationLogPath: '/nonexistent-bypass.jsonl',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -258,14 +256,13 @@ describe('runOrchestratorTick — aborted outcome (AISDLC-242)', () => {
frontier: fakeFrontier([taskId]),
dispatch: flipAndReturn(workDir, taskId, 'developer-failed', 'commitSha=null'),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runner,
runId: 'aisdlc-242-dev-failed',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
taskLabelsLoader: () => [],
calibrationLogPath: '/nonexistent-bypass.jsonl',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -406,13 +403,12 @@ describe('runOrchestratorTick — resume path (AISDLC-242 AC #7)', () => {
notes: 'killed again',
}),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runId: 'aisdlc-242-resume-tick2',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
taskLabelsLoader: () => [],
calibrationLogPath: '/nonexistent-bypass.jsonl',
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 2);
Expand Down Expand Up @@ -530,6 +526,7 @@ describe('runOrchestratorTick — in-flight bypass for recoverable worktrees (AI
notes: 'killed again',
}),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runId: 'aisdlc-242-inflight-bypass',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
Expand All @@ -538,8 +535,6 @@ describe('runOrchestratorTick — in-flight bypass for recoverable worktrees (AI
// Inject the pre-populated in-flight map to simulate a cold-start
// reconstruction that erroneously blocks a recoverable worktree.
inFlight: preloadedInFlight,
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down Expand Up @@ -591,14 +586,13 @@ describe('runOrchestratorTick — in-flight bypass for recoverable worktrees (AI
notes: 'should not reach dispatch — blocked by live in-flight',
}),
escalate: async () => {},
parentBranchGuard: async () => {},
emitEvent: sink,
runId: 'aisdlc-242-live-dispatch-block',
graphLoader: () => ({ nodes: new Map(), openIds: [], completedIds: [] }),
taskLabelsLoader: () => [],
calibrationLogPath: '/nonexistent-bypass.jsonl',
inFlight: preloadedInFlight,
// AISDLC-363 — skip the parent-branch guard in tests (no real git state).
parentBranchGuard: async () => {},
};

await runOrchestratorTick(config, adapters, 1);
Expand Down
65 changes: 50 additions & 15 deletions scripts/check-orchestrator-state.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
#!/usr/bin/env bash
# AISDLC-137: orchestrator-repo state hardening.
# AISDLC-137 + AISDLC-358: orchestrator-repo state hardening.
#
# Idempotent self-heal for the parent repo's bare-flag + main-branch staleness.
# Runs at the start of every /ai-sdlc execute dispatch (Step 0) so the
# orchestrator state is correct before any worktree is created.
# Idempotent self-heal for the parent repo's branch + bare-flag + main-branch
# staleness. Runs at the start of every /ai-sdlc execute dispatch (Step 0) AND
# at the entry of every autonomous orchestrator tick so the orchestrator state
# is correct before any worktree is created or frontier work begins.
#
# Pattern C contract (memory: project_orchestrator_repo_layout.md):
# - Parent dir = non-bare, has main checked out
# - Parent's working tree on main is READ-ONLY by contract
# - All edits happen in .worktrees/<task-id>/
#
# Hard guards (AISDLC-358):
# 1. Parent MUST be on `main`. If not:
# - Clean working tree → auto-checkout main + reset hard. Log recovery.
# - Dirty working tree → REFUSE (exit 1). Print branch + dirty paths + fix cmd.
# 2. core.bare MUST be false (AISDLC-137). Auto-correct if true.
# 3. Parent main ref MUST match origin/main. Reset --hard if clean + stale.
#
# Because parent is read-only, it's safe to git-reset --hard to origin/main
# whenever a sync is needed — but only when the working tree is verifiably
# clean. If the operator (or a tool) has uncommitted modifications, abort
Expand Down Expand Up @@ -44,16 +52,10 @@ PARENT_ROOT=$(dirname "$GIT_COMMON_DIR_ABS")
cd "$PARENT_ROOT"

# AISDLC-363: skip the orchestrator state check when running inside a GH
# merge-queue read-only probe branch or a shallow CI clone.
#
# GH merge-queue probe: ephemeral `gh-readonly-queue/main/pr-N-<sha>` branches
# are created by GitHub to test mergeability. They are read-only and have no
# local `main` ref, so `git checkout main` would fail. The parent-branch guard
# and state reset are meaningless in this context.
#
# Shallow clone: CI systems (GitHub Actions default checkout) may produce a
# shallow clone where `refs/heads/main` is absent. `git checkout main` returns
# "pathspec 'main' did not match any file(s) known to git" in that case.
# merge-queue read-only probe branch or a shallow CI clone. These run BEFORE
# the AISDLC-358 parent-on-main guard because the queue probe IS a non-main
# branch by design (sanctioned ephemeral state) and the guard would otherwise
# try (and fail) to recover.
CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
if [[ "$CURRENT_BRANCH" == gh-readonly-queue/* ]]; then
echo "[orchestrator-state] skipping: running inside GH merge-queue probe branch (${CURRENT_BRANCH})"
Expand All @@ -67,7 +69,40 @@ if ! git rev-parse refs/heads/main >/dev/null 2>&1; then
exit 0
fi

# 1. Auto-correct core.bare if it's true. Some local editor extensions / tools
# 1a. AISDLC-358: Pattern-C contract — parent MUST be on main.
# Read the symbolic HEAD ref. If it's detached or on a feature branch,
# auto-recover (clean tree) or refuse (dirty tree).
if [ -z "$CURRENT_BRANCH" ]; then
echo "[orchestrator-state] WARN: parent HEAD is detached; skipping branch check (manual recovery needed)"
elif [ "$CURRENT_BRANCH" != "main" ]; then
# Parent is on the wrong branch. Inspect working tree cleanliness.
DIRTY_TRACKED_BRANCH=$(git status --porcelain 2>/dev/null | grep -vE "^\?\?" | head -1 || true)
if [ -n "$DIRTY_TRACKED_BRANCH" ]; then
# Dirty — cannot auto-recover safely. Refuse with clear instructions.
echo "[orchestrator-state] ERROR: parent working tree is on branch '$CURRENT_BRANCH' (expected 'main') AND has uncommitted tracked changes."
echo "[orchestrator-state] Dirty paths:"
git status --porcelain | grep -vE "^\?\?" | head -10 | sed 's/^/[orchestrator-state] /'
echo "[orchestrator-state] Recovery: stash or commit your changes, then run:"
echo "[orchestrator-state] git -C \"${PARENT_ROOT}\" checkout main"
echo "[orchestrator-state] git -C \"${PARENT_ROOT}\" reset --hard origin/main"
exit 1
else
# Clean tree — auto-recover: checkout main + reset to origin/main.
echo "[orchestrator-state] auto-recovering parent from '${CURRENT_BRANCH}' to main"
if ! git checkout main; then
echo "[orchestrator-state] ERROR: git checkout main failed in ${PARENT_ROOT}" >&2
exit 1
fi
if ! git reset --hard origin/main; then
echo "[orchestrator-state] ERROR: git reset --hard origin/main failed in ${PARENT_ROOT}" >&2
exit 1
fi
echo "[orchestrator-state] auto-recovered parent from '${CURRENT_BRANCH}' to main at $(git rev-parse --short HEAD)"
exit 0
fi
fi

# 1b. (AISDLC-137) Auto-correct core.bare if it's true. Some local editor extensions / tools
# flip this back periodically; we re-correct it on every dispatch.
BARE=$(git config --get core.bare 2>/dev/null || echo "false")
if [ "$BARE" = "true" ]; then
Expand Down
Loading
Loading