feat(ambient)!: replace keyword/plan detection with orchestrator charter + plan handoff#251
Conversation
…ter + plan handoff Removes both old ambient detection paths (first-word keyword dispatch and 3-marker plan detection) and replaces them with a two-hook orchestrator system: - scripts/hooks/orchestrator-charter.md: static ~200-token charter injected at every SessionStart as additionalContext, establishing the main model as a pure orchestrator and grading sub-agents by complexity (haiku/sonnet/opus) - scripts/hooks/session-start-orchestrator: new SessionStart hook that reads the charter and emits it via json_session_output; silent outside git repos, guards against DEVFLOW_BG_UPDATER re-entrancy - scripts/hooks/preamble: rewritten to three behaviors — plan-handoff fast-path (prompts beginning "Implement the following plan:" invoke devflow:implement), slash skip (no output), and 2-line orchestrator reminder for all other prompts - scripts/hooks/git-marker: sourced helper providing df_has_git_marker, a pure bash bounded upward walk (64 levels, no subprocess) for git repo detection ambient.ts gains ORCHESTRATOR_HOOK_MARKER, partial-state detection and repair, and updated enable/disable/status messaging. Tests: 1881 passing. Upgrade: run `devflow init` to register the session-start-orchestrator hook. Existing installs with only the preamble hook are in partial state — run `devflow ambient --enable` to repair.
…ring preamble: remove `set -e` — aligns with the session-start-orchestrator pattern and the stated design invariant (fail-open via guard clauses, no set -e). Guard clauses already ensure exit 0 on every path; omitting set -e makes the hook strictly more fail-safe. session-start-orchestrator: move json_session_output before the "=== HOOK COMPLETE ===" debug log so the trace accurately marks completion after all work is done, consistent with preamble.
- Sync devflow-ambient description across plugins.ts + marketplace.json with plugin.json (removed stale 'Keyword + plan auto-detection' / 'classifies intent' wording that described the pre-ADR-004 behavior) - Realign marketplace.json ambient keywords with plugin.json - Fix stale integration-test title/comment claiming a normal prompt produces no hook output; now injects the orchestrator reminder and asserts it does not trigger a spurious skill invocation
The background memory worker runs claude -p in the project git root with DEVFLOW_BG_UPDATER=1 and fires UserPromptSubmit. The redesigned preamble emits the orchestrator reminder on every non-slash/non-empty prompt, so every background memory refresh would otherwise be injected with a delegate-to-agents directive a haiku one-shot cannot act on. Add the canonical re-entrancy guard (mirrors capture-prompt and session-start-orchestrator) + F13 regression test.
Relocates scripts/hooks/orchestrator-charter.md to scripts/hooks/assets/orchestrator-charter.md, establishing an assets/ subdirectory for static prose assets shipped with hooks. Updates: - session-start-orchestrator: CHARTER_FILE path → assets/ subdir - tests/shell-hooks.test.ts: CHARTER_FILE const + oversize-charter fixture (creates assets/ dir before writing the test file) - tests/skill-references.test.ts: Format 5 scan extended to also cover scripts/hooks/assets/*.md (flat readdirSync was hook-only) - CLAUDE.md + docs/reference/file-organization.md: structure docs - .devflow/features/ambient-orchestrator/KNOWLEDGE.md: path refs Content of the charter itself is byte-identical (only location moved). Co-Authored-By: Claude <noreply@anthropic.com>
Review Summary: PR #251 Code CommentsReview findings consolidated and deduplicated across 10 reports. Posting inline comments for ≥80% confidence blocking/should-fix issues. Comment Strategy
Key Blocking Issues (≥80% confidence)
Should-Fix Issues (≥80% confidence)
Lower-Confidence Suggestions (60-79%)
Recommendation: APPROVED_WITH_CONDITIONS Generated by Claude Code devflow:comment-pr operation |
| p.log.info(`Ambient mode: ${color.green('enabled')}`); | ||
| } | ||
| } else { | ||
| p.log.info(`Ambient mode: ${color.dim('disabled')}`); |
There was a problem hiding this comment.
Status observability gap: Orchestrator-only state reports as disabled (MEDIUM, 80%)
When hasAmbientHook returns false (no preamble) but the orchestrator SessionStart hook exists, this line prints plain disabled with no hint about the stray hook.
The orchestrator hook fires every session and injects the charter, so the reported state contradicts observable behavior. The mirror partial-state case (preamble without orchestrator) correctly gets a partial hint on line 231.
Fix: Check for the orchestrator-only case in the else branch:
} else {
if (hasOrchestratorHook(settingsContent)) {
p.log.info(`Ambient mode: ${color.dim('disabled')} ${color.dim('(partial — stray orchestrator hook; run devflow ambient --disable to clean up)')}`);
} else {
p.log.info(`Ambient mode: ${color.dim('disabled')}`);
}
}| ], | ||
| }); | ||
| changed = true; | ||
| } |
There was a problem hiding this comment.
Duplicated hook-registration block: SessionStart mirrors UserPromptSubmit (MEDIUM, 80%)
The SessionStart orchestrator add-block (lines 112-130) is structurally identical to the UserPromptSubmit preamble add-block (lines 92-110). Both perform the same some(... includes(MARKER)) check, then idempotent add.
This duplication creates a future maintenance burden. The feature KB warns: "Adding a third presence-gate separately… must be added to both functions [and] the partial-state detection logic." This duplication is exactly what makes that fragile.
Fix: Extract a helper to avoid keeping two copies in sync:
function ensureHook(
settings: Settings, event: 'UserPromptSubmit' | 'SessionStart',
marker: string, hookName: string, timeout: number, devflowDir: string,
): boolean {
const present = settings.hooks?.[event]?.some((m) =>
m.hooks.some((h) => h.command.includes(marker)));
if (present) return false;
settings.hooks ??= {};
settings.hooks[event] ??= [];
settings.hooks[event]!.push({
hooks: [{ type: 'command', command: path.join(devflowDir, 'scripts', 'hooks', 'run-hook') + ` ${hookName}`, timeout }],
});
return true;
}
// Then:
if (ensureHook(settings, 'UserPromptSubmit', PREAMBLE_HOOK_MARKER, 'preamble', 5, devflowDir)) changed = true;
if (ensureHook(settings, 'SessionStart', ORCHESTRATOR_HOOK_MARKER, 'session-start-orchestrator', 10, devflowDir)) changed = true;| @@ -187,7 +225,16 @@ export const ambientCommand = new Command('ambient') | |||
|
|
|||
| if (options.status) { | |||
| const enabled = hasAmbientHook(settingsContent); | |||
There was a problem hiding this comment.
Unguarded JSON.parse crashes on corrupt settings.json (MEDIUM, 80%)
This path calls hasAmbientHook(settingsContent) → JSON.parse(input) with no surrounding try/catch. If ~/.claude/settings.json is corrupt (trailing comma, etc.), this throws an uncaught exception and the CLI aborts with a raw stack trace.
The same function guards another parse 15 lines away (line 247-260) with a try/catch and graceful degradation. The primary paths (status/enable/disable) should do the same.
Fix: Parse once upfront and degrade gracefully:
let settingsContent: string;
try {
settingsContent = await fs.readFile(settingsPath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// ... handle ENOENT
}
let settings: Settings;
try {
settings = JSON.parse(settingsContent);
} catch (err) {
p.log.error(`~/.claude/settings.json is not valid JSON: ${(err as Error).message}`);
if (options.status) return;
// ... handle for enable/disable
}
const enabled = hasAmbientHook(settings);
const hasOrchestrator = hasOrchestratorHook(settings);Both predicates already accept Settings objects, removing the redundant double-parse at lines 227/229 (also addresses the performance optimization concern).
| |---|---| | ||
| | First word begins `Implement the following plan:` | Fixed devflow:implement handoff directive | | ||
| | First word is `/` | Silent exit 0 (slash commands pass through) | | ||
| | Anything else (including empty-after-strip) | 2-line orchestrator delegation reminder | |
There was a problem hiding this comment.
Dispatch table contradicts code for empty prompts (MEDIUM, 88%)
The KB dispatch table row reads: Anything else (including empty-after-strip) | 2-line orchestrator delegation reminder
But the actual preamble code handles empty-after-strip as its own first branch that emits nothing (silent exit 0):
if [ -z "$HEAD" ]; then
dbg "EXIT: empty prompt" # no json_prompt_output — silent
elif [[ "$HEAD" == "Implement the following plan:"* ]]; then ...
elif [[ "$HEAD" == "/"* ]]; then ...
else
json_prompt_output "..." # reminder only here
fiSince this KB is the feature knowledge base that future agents/developers will trust as the model of this hook, the inaccuracy will mislead work.
Fix: Split the table so empty-after-strip is its own row:
| First word begins Implement the following plan: | Fixed devflow:implement handoff directive |
| First char is / | Silent exit 0 (slash commands pass through) |
| Empty after whitespace strip | Silent exit 0 (no output) |
| Anything else | 2-line orchestrator delegation reminder |
|
|
||
| // --- 256-window bound --- | ||
|
|
||
| it('F12: 300 leading spaces before prefix → no plan handoff (prefix beyond 256-byte window) [AC-F12]', () => { |
There was a problem hiding this comment.
AC-F12 test is mislabeled and under-asserts the 256-byte window bound (MEDIUM, 90%)
The test name and comment claim it verifies "prefix beyond 256-byte window", but the input ' '.repeat(300) + 'Implement the following plan: …' doesn't exercise that path.
300 leading spaces make HEAD="${PROMPT:0:256}" all-whitespace, then the leading-whitespace strip collapses HEAD to empty. The hook exits via [ -z "$HEAD" ] (empty-prompt branch) — output is 0 bytes. So the key assertion expect(out).not.toContain('plan handoff') passes only because out === ''; it never tests the actual 256-byte content window.
The genuinely window-bounded case (256+ non-whitespace bytes before the prefix) is unexercised. I verified: 'a'.repeat(260) + 'Implement the following plan: …' fires the reminder (prefix pushed past window), which is the behavior this test claims to verify but doesn't.
Fix: Split into two precise tests:
- Whitespace-only head → assert
expect(out).toBe('')(this is what AC-F12 actually tests) - New test with non-whitespace padding past byte 256 → assert the reminder template fires, not
HANDOFF_TEMPLATE(this is the real window-bound verification)
Additional Inline Comments PostedFive high-priority inline comments have been posted on this PR covering the most critical blocking and should-fix findings:
Remaining Findings (≥80% confidence, not yet posted as inline)
Summary AssessmentStatus: APPROVED_WITH_CONDITIONS Blockers to address before merge:
Should-fix items (safe to defer post-merge):
All findings detailed in the corresponding inline comments and initial summary comment. Review completed by Claude Code devflow:comment-pr |
Three stale passages updated to reflect the PR #251 ambient redesign (applies ADR-004, applies ADR-003): - README.md line 19: replace "detects when you paste a structured plan" with charter-injection description; drop EDD reference, retain TDD. - README.md line 44: rewrite "Ambient intelligence" bullet — remove first-word keyword dispatch prose; describe charter injection, delegation reminder, plan-handoff fast-path, and slash pass-through. - .devflow/features/ambient-orchestrator/KNOWLEDGE.md: fix preamble dispatch table — add empty-after-strip row (silent exit 0, matching the `[ -z "$HEAD" ]` branch in scripts/hooks/preamble); reorder rows to match code dispatch order; remove stale parenthetical from the "Anything else" row. - docs/reference/file-organization.md: update plugin count 21 → 22.
Issue 1 (git-marker): replace `||`/`&&` chain at the loop-termination guard with an explicit `if` — semantics are unchanged (left-associative precedence was already correct) but the explicit form removes the readability footgun. Issue 2 (session-start-orchestrator): add hook-log-init sourcing and a `log "Injecting charter (N chars)"` call to match the observability pattern both sibling SessionStart hooks (session-start-context, session-start-memory) already follow. Applies ADR-004. Issue 3 (preamble): add a cross-reference comment beside the model-tier taxonomy in the orchestrator reminder string, pointing to orchestrator- charter.md as the authoritative full definition. The charter already carries the reciprocal cross-reference; this closes the asymmetry. Avoids PF-001. All 132 shell-hook tests pass.
…classification sweep Issue 1 — parse settings.json once with try/catch before any branch that needs it; pass the already-parsed Settings object to hasAmbientHook / hasOrchestratorHook in --status, eliminating double-parse and crash risk on a corrupt settings.json. Issue 2 — extend --status else-branch to check hasOrchestratorHook; shows "(partial — run devflow ambient --enable to repair)" when the orchestrator SessionStart hook is present but preamble is absent, matching the hint surfaced for the mirror partial state. Issue 3 — extract ensureHook() helper, collapsing the structurally-identical UserPromptSubmit and SessionStart add-blocks; eliminates the const hasOrchestratorHook local variable that shadowed the module-level function. Issue 4 — add symmetric filterHookEntries(…, isClassification) sweep to addAmbientHook, matching removeAmbientHook behaviour; enables --enable to repair installs left with a stale session-start-classification hook. Two regression tests cover the new classification sweep in addAmbientHook. Co-Authored-By: Claude <noreply@anthropic.com>
…-byte boundary
Batch-3 test fixes for shell-hooks.test.ts.
- Issue 1 (F12 mislabeled/vacuous): split into F12a (300 spaces → HEAD
collapses to empty → silent exit, asserting output is exactly '') and F12b
('a'.repeat(260) + prefix → prefix past byte 256 → reminder, not handoff).
The original test passed vacuously on empty output; F12b now pins the actual
256-byte window-bound behavior.
- Issue 2 (git-marker no direct test): add describe('git-marker helper')
with 7 behavioral tests (repo root, nested subdir, .git-as-file worktree
style, non-git dir, empty string, '/', >64-level bound). Also add P1b
source scan to catch a reintroduced git subprocess in git-marker itself
(previously only preamble source was scanned). Regex uses `\bgit\s+\w`
to detect git-as-command without false-matching the `.git` path literal.
- Issue 3 (charter boundary): add AC-F10 exact-4096-byte-accept test. The
hook guard is `-gt 4096`, so exactly 4096 bytes must be injected; only the
4097-reject case was previously tested.
- Issue 4 (template triplication): extract HANDOFF_TEMPLATE and
REMINDER_TEMPLATE to tests/fixtures/ambient-templates.ts and import in
both shell-hooks.test.ts and tests/integration/ambient-activation.test.ts.
Eliminates drift risk between shell-hook unit assertions and integration
systemPrompt simulation.
- Issue 5 (noGitDir guard): add beforeAll that probes os.tmpdir() via
df_has_git_marker; throws with a clear message if tmpdir has a .git
ancestor, making the assumption explicit and failing fast instead of
producing confusing assertion failures.
applies ADR-003 (end-state only: no tombstone residue in test rewrites)
Co-Authored-By: Claude <noreply@anthropic.com>
…lay + predicate style - Convert 4 arrow-function predicates (isLegacy, isAmbient, isClassification, isOrchestrator) to named function declarations with explicit return types, following the project's function-keyword standard. - Replace `let changed = ... || changed` accumulation in addAmbientHook with named boolean variables (removedLegacy, removedClassification, addedPreamble, addedOrchestrator), matching the cleaner pattern already used in removeAmbientHook. - Collapse the status display from two nested branches (each calling hasOrchestratorHook independently) to a single evaluation: extract both enabled and hasOrchestrator before branching, derive the shared repairHint string once, and apply it in both the enabled and disabled log lines.
Refreshes KB with five targeted changes from the resolve session: state-machine table now shows exact --status repair hint text; ensureHook helper + symmetric classification sweep + up-front settings parsing captured; session-start-orchestrator hook-log-init sourcing noted; preamble routing-table cross-reference comment documented; tests/fixtures/ambient-templates.ts added to Key Files.
Drop the disable-mouse-clicks entry from the flag registry, its test block, and the CLAUDE.md flags doc (21 -> 20 flags). No migration: existing installs clean up manually.
Summary
implement,explore,research,debug,plan) and 3-marker plan detection (## Goal/## Steps/## Files). These added overhead and duplicated slash commands.session-start-orchestrator— a SessionStart hook that injects a static ~200-token orchestrator charter asadditionalContextat every session start, establishing the main model as a pure delegator and grading sub-agents by complexity (haiku → sonnet → opus → full workflows).preambleto three clean behaviors: plan-handoff fast-path (Implement the following plan:prefix →devflow:implement), slash skip (no output), and a 2-line orchestrator reminder for all other prompts. Both hooks are git-repo-gated via a new sourcedgit-markerhelper (pure bash, 64-level bounded walk, no subprocess).devflow ambient --enable/--disabletoggle. Partial state (preamble present, orchestrator missing) is detected and reported bydevflow ambient --statuswith a repair note.Changes
scripts/hooks/orchestrator-charter.md— static charter asset (≤4096 chars, enforced)scripts/hooks/session-start-orchestrator— new SessionStart hook; DEVFLOW_BG_UPDATER re-entrancy guard; fail-openscripts/hooks/preamble— rewritten; old keyword case statement and 3-marker detection removedscripts/hooks/git-marker— sourced helper:df_has_git_marker; bash 3.2 compatiblesrc/cli/commands/ambient.ts— ORCHESTRATOR_HOOK_MARKER, partial-state detection/repair, updated messagingsrc/cli/commands/init.ts/uninstall.ts— updated descriptionsdescribe('session-start-orchestrator', ...)block + fullpreamble — orchestrator charter moderewrite;tests/integration/ambient-activation.test.tsupdatedBreaking Changes
Both old detection paths are removed. Sessions that relied on keyword-prefixed prompts auto-routing workflows will no longer get that behavior — the orchestrator charter approach delegates routing to the model's judgment based on context, not string matching.
Reviewer Focus Areas
scripts/hooks/session-start-orchestrator: verify it mirrorssession-start-contextstructure (noset -e, fail-open, re-entrancy guard)scripts/hooks/preamble: confirm clean removal ofshopt -s nocasematch, keywordcase, and 3-markergrep— no residuescripts/hooks/git-marker: check the 64-level walk handles/, empty string, and.git-as-file correctlysrc/cli/commands/ambient.ts:hasAmbientHook(preamble-authoritative),hasOrchestratorHook, partial-state detection, idempotentaddAmbientHookorchestrator-charter.md: content correctness — model tier routing, plan-handoff fallback bullet, ≤4096 char budgetUpgrade Note
After upgrading, run
devflow initto register the newsession-start-orchestratorhook. Existing installs with only thepreamblehook are in partial state —devflow ambient --enablerepairs it.T-5 empirical outcome: pending live verification post-merge deploy.
Prefix re-validation (2026-07-04, pre-merge): The handoff prefix was re-confirmed live — this feature's own implementation session was a native "execute plan in new session" handoff at v2.1.198, and its first user prompt begins with the literal
Implement the following plan:. Local transcript survey shows the prefix stable across v2.1.167→v2.1.198 with no alternative phrasing in any project; changelog/docs through v2.1.201 show no handoff-format change. Schema note: transcripts now carry the plan body in a top-levelplanContentfield withorigin: {"kind":"auto-continuation"}—message.contentholds only the prefix. This is harmless to the fast-path (the match is prefix-anchored and the model still receives the full plan in context), but theorigintag is a plausible discriminator Claude Code could use to skip UserPromptSubmit for injected prompts — T-5 remains the definitive post-deploy test.