@orchestrator-claude/definitions 3.34.0 → 3.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * pre-agent-dispatch.ts — PreToolUse hook for the Agent tool
3
+ *
4
+ * NON-BLOCKING (always exits 0; only injects advisory context). Two responsibilities:
5
+ *
6
+ * 1. (RFC-027 Sprint 4) Warn when a known agent is dispatched WITHOUT subagent_type,
7
+ * so frontmatter (model/tools) loads correctly.
8
+ *
9
+ * 2. (TD-171) Branch-before-dispatch ALERT. When the orchestrator dispatches a
10
+ * COMMITTING agent — one whose tool list holds `mcp__orchestrator-tools__createCheckpoint`,
11
+ * so it may make local checkpoint commits — while sitting on a PROTECTED branch
12
+ * (main/master), warn it to check out a feature branch FIRST. Root cause of the
13
+ * S289 breach `4f1f4f37`: an implementer was dispatched from `main`, so its local
14
+ * commit landed directly on the protected branch. agent-git-guard (TD-169) blocks
15
+ * push/merge but intentionally ALLOWS local commit, so a local commit on `main`
16
+ * slips through. The real fix is branch-before-dispatch (orchestrator owns the
17
+ * branch); this hook is the non-blocking backstop. Per the user directive it ALERTS
18
+ * (not blocks) — the hard stop on the unrecoverable vector stays in agent-git-guard.
19
+ *
20
+ * 3. (TD-205) Dispatch-intent stamp. This hook fires SYNCHRONOUSLY at
21
+ * PreToolUse[Agent] — before the sub-agent spawns / before the turn yields — so it
22
+ * is the guaranteed-present signal for the bg-dispatch race: when the orchestrator
23
+ * dispatches a sub-agent in the BACKGROUND and yields immediately, dangling-guard's
24
+ * Stop hook can fire before subagent-start.ts's ASYNC startInvocation()+
25
+ * writeInvocationState() has landed, producing a false-positive BLOCK on a
26
+ * genuinely active workflow. Stamping a dispatch-intent marker for the active
27
+ * workflow here gives dangling-guard a short (seconds), self-expiring grace-window
28
+ * signal to cover that race without opening a hole in the rule-9 zombie safety net.
29
+ * Best-effort / fail-open in every dimension (missing deps, no active workflow,
30
+ * resolution timeout, filesystem error) — never blocks the dispatch.
31
+ *
32
+ * Design note (TD-171): the committing-agent set mirrors the `createCheckpoint` tool
33
+ * holders in `.claude/agents/*.md`. Keep it in sync with that ground truth and with the
34
+ * orchestrator SKILL §2c.1 Commit-Policy scope.
35
+ */
36
+
37
+ import { execFileSync } from 'node:child_process';
38
+ import {
39
+ log,
40
+ readStdin,
41
+ getField,
42
+ outputContext,
43
+ getActiveWorkflow,
44
+ writeDispatchIntent,
45
+ } from './lib/api-client.js';
46
+
47
+ const HOOK = 'PRE-AGENT-DISPATCH';
48
+
49
+ // Known workflow agents (RFC-027 Sprint 4 — subagent_type presence check).
50
+ const KNOWN_AGENTS = [
51
+ 'implementer',
52
+ 'planner',
53
+ 'specifier',
54
+ 'reviewer',
55
+ 'task-generator',
56
+ 'researcher',
57
+ ];
58
+
59
+ const KNOWN_AGENTS_REGEX = new RegExp(`\\b(${KNOWN_AGENTS.join('|')})\\b`, 'i');
60
+
61
+ /**
62
+ * Agents whose tool list includes `mcp__orchestrator-tools__createCheckpoint` (ground
63
+ * truth: grep `createCheckpoint` over `.claude/agents/*.md`). These may make local
64
+ * checkpoint commits during their phase, so dispatching them on a protected branch is
65
+ * the branch-before-dispatch hazard. Keep in sync with orchestrator SKILL §2c.1.
66
+ */
67
+ export const COMMITTING_AGENTS: readonly string[] = [
68
+ 'implementer',
69
+ 'azure-devops-engineer',
70
+ 'presentation-engineer',
71
+ 'operational-artifact-builder',
72
+ 'cicd-platform-engineer',
73
+ 'python-data-engineer',
74
+ 'frontend-engineer',
75
+ ];
76
+
77
+ const PROTECTED_BRANCHES: readonly string[] = ['main', 'master'];
78
+
79
+ // ─────────────────────────────────────────────────────────────
80
+ // Pure helpers (exported for testing)
81
+ // ─────────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Strip the plugin namespace from a dispatch name.
85
+ *
86
+ * Under plugin scope the harness namespaces every agent (`orchestrator:implementer`
87
+ * — measured in the S398 sandbox, RFC-040 §18.14); in project scope the same agent
88
+ * arrives bare (`implementer`). A guard that compares against the bare name only
89
+ * answers `false` for the namespaced form — and since hooks fail open, it disables
90
+ * itself in silence. Both forms MUST resolve to the same decision.
91
+ */
92
+ export function normalizeAgentName(subagentType: string): string {
93
+ const i = subagentType.lastIndexOf(':');
94
+ return i === -1 ? subagentType : subagentType.slice(i + 1);
95
+ }
96
+
97
+ export function isCommittingAgent(subagentType: string): boolean {
98
+ if (!subagentType) return false;
99
+ const t = normalizeAgentName(subagentType).toLowerCase();
100
+ if (!t) return false;
101
+ return COMMITTING_AGENTS.some((a) => a.toLowerCase() === t);
102
+ }
103
+
104
+ export function isProtectedBranch(branch: string): boolean {
105
+ return PROTECTED_BRANCHES.includes(branch);
106
+ }
107
+
108
+ /** Parse `.git/HEAD` contents → branch name, or '' for detached HEAD / junk. */
109
+ export function parseBranchFromHead(headContents: string): string {
110
+ const m = /^ref:\s*refs\/heads\/(.+)$/.exec(headContents.trim());
111
+ return m ? (m[1] ?? '').trim() : '';
112
+ }
113
+
114
+ export function detectKnownAgentInPrompt(prompt: string): string | null {
115
+ const match = KNOWN_AGENTS_REGEX.exec(prompt);
116
+ return match ? (match[1] ?? '').toLowerCase() : null;
117
+ }
118
+
119
+ export function branchBeforeDispatchMessage(subagentType: string, branch: string): string {
120
+ return (
121
+ `[BRANCH-BEFORE-DISPATCH][TD-171] About to dispatch committing agent '${subagentType}' while on ` +
122
+ `protected branch '${branch}'. This agent can make LOCAL checkpoint commits, and agent-git-guard ` +
123
+ `(TD-169) blocks push/merge but ALLOWS commit — so a local commit would land directly on '${branch}' ` +
124
+ `(root cause of the S289 '4f1f4f37' breach). Check out a feature branch BEFORE dispatching; the ` +
125
+ `orchestrator owns push after its independent assert.`
126
+ );
127
+ }
128
+
129
+ export interface DispatchContext {
130
+ subagentType: string;
131
+ prompt: string;
132
+ branch: string;
133
+ }
134
+
135
+ /**
136
+ * Pure decision. Returns the list of advisory warning lines (possibly empty).
137
+ * - missing subagent_type + a known agent named in the prompt → RFC-027 warn
138
+ * - committing agent dispatched on a protected branch → TD-171 branch-before-dispatch warn
139
+ */
140
+ export function evaluateDispatch(ctx: DispatchContext): string[] {
141
+ const warnings: string[] = [];
142
+ const { subagentType, prompt, branch } = ctx;
143
+
144
+ if (!subagentType) {
145
+ const detected = detectKnownAgentInPrompt(prompt);
146
+ if (detected) {
147
+ warnings.push(
148
+ `[WARN] Agent dispatched without subagent_type. Known agent detected: "${detected}". ` +
149
+ `Add subagent_type for proper frontmatter loading.`
150
+ );
151
+ }
152
+ }
153
+
154
+ if (isCommittingAgent(subagentType) && isProtectedBranch(branch)) {
155
+ warnings.push(branchBeforeDispatchMessage(subagentType, branch));
156
+ }
157
+
158
+ return warnings;
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────
162
+ // Dependency injection interface (enables testing without IO)
163
+ // ─────────────────────────────────────────────────────────────
164
+
165
+ export interface HookDeps {
166
+ readStdin: () => Promise<Record<string, unknown>>;
167
+ outputContext: (hookEvent: string, context: string) => void;
168
+ log: (prefix: string, msg: string) => void;
169
+ readBranch: () => string;
170
+ /**
171
+ * TD-205: resolves the active workflow so the dispatch-intent marker can be
172
+ * workflow-scoped. Optional so pre-TD-205 test doubles keep working —
173
+ * stampDispatchIntent() no-ops (fail-open) when this is absent. Production
174
+ * defaultDeps below always provides it.
175
+ */
176
+ getActiveWorkflow?: () => Promise<{ id: string } | null>;
177
+ /** TD-205: stamps the dispatch-intent marker for workflowId. See getActiveWorkflow doc. */
178
+ writeDispatchIntent?: (workflowId: string) => boolean;
179
+ }
180
+
181
+ function readBranchFromGit(): string {
182
+ try {
183
+ // Fixed args, no shell (execFile, not exec) — no interpolation, no injection surface.
184
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
185
+ encoding: 'utf-8',
186
+ stdio: ['ignore', 'pipe', 'ignore'],
187
+ }).trim();
188
+ } catch {
189
+ return '';
190
+ }
191
+ }
192
+
193
+ const defaultDeps: HookDeps = {
194
+ readStdin,
195
+ outputContext,
196
+ log,
197
+ readBranch: readBranchFromGit,
198
+ getActiveWorkflow,
199
+ writeDispatchIntent,
200
+ };
201
+
202
+ /**
203
+ * TD-205: internal resolve timeout for stampDispatchIntent's getActiveWorkflow()
204
+ * call — seconds, bounded well within this hook's own 5000ms PreToolUse
205
+ * timeout (settings.json) so a slow/unavailable API degrades gracefully
206
+ * (marker simply isn't stamped for that dispatch) rather than ever blocking
207
+ * the dispatch itself.
208
+ */
209
+ const DISPATCH_INTENT_RESOLVE_TIMEOUT_MS = 3000;
210
+
211
+ /**
212
+ * TD-205: stamps a dispatch-intent marker for the currently active workflow,
213
+ * if one is resolvable within DISPATCH_INTENT_RESOLVE_TIMEOUT_MS. Fail-open
214
+ * in every dimension — missing deps (pre-TD-205 test doubles), no active
215
+ * workflow, a rejected/never-resolving getActiveWorkflow(), or a filesystem
216
+ * failure inside writeDispatchIntent() all silently skip the stamp. This is
217
+ * a best-effort advisory signal, never a blocking step (matches this hook's
218
+ * existing non-blocking posture).
219
+ */
220
+ export async function stampDispatchIntent(
221
+ deps: Pick<HookDeps, 'getActiveWorkflow' | 'writeDispatchIntent' | 'log'>
222
+ ): Promise<void> {
223
+ if (!deps.getActiveWorkflow || !deps.writeDispatchIntent) return;
224
+
225
+ try {
226
+ const workflow = await Promise.race([
227
+ deps.getActiveWorkflow(),
228
+ new Promise<null>((resolve) => {
229
+ setTimeout(() => {
230
+ resolve(null);
231
+ }, DISPATCH_INTENT_RESOLVE_TIMEOUT_MS);
232
+ }),
233
+ ]);
234
+ if (workflow?.id) {
235
+ const stamped = deps.writeDispatchIntent(workflow.id);
236
+ deps.log(HOOK, `[TD-205] dispatch-intent stamped=${String(stamped)} workflow=${workflow.id}`);
237
+ }
238
+ } catch (err) {
239
+ deps.log(HOOK, `[TD-205] stampDispatchIntent failed (fail-open): ${String(err)}`);
240
+ }
241
+ }
242
+
243
+ // ─────────────────────────────────────────────────────────────
244
+ // Core logic (exported for testing)
245
+ // ─────────────────────────────────────────────────────────────
246
+
247
+ export async function mainWithDeps(deps: HookDeps): Promise<void> {
248
+ const stdin = await deps.readStdin();
249
+
250
+ const toolInput = getField(stdin, 'tool_input') || '';
251
+ const subagentType =
252
+ getField(stdin, 'tool_input.subagent_type') || getField(stdin, 'tool_input.subagentType') || '';
253
+ const prompt =
254
+ getField(stdin, 'tool_input.prompt') ||
255
+ getField(stdin, 'tool_input.description') ||
256
+ (typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput));
257
+
258
+ const branch = deps.readBranch();
259
+
260
+ const warnings = evaluateDispatch({ subagentType, prompt, branch });
261
+
262
+ // TD-205: stamp the dispatch-intent marker regardless of whether a warning
263
+ // fires — a dispatch is happening either way, and the marker exists purely
264
+ // to cover the bg-dispatch race for dangling-guard's Stop hook.
265
+ await stampDispatchIntent(deps);
266
+
267
+ if (warnings.length > 0) {
268
+ deps.outputContext(HOOK, warnings.join('\n'));
269
+ deps.log(
270
+ HOOK,
271
+ `${warnings.length} warning(s) (agent="${subagentType || '?'}" branch="${branch}")`
272
+ );
273
+ return;
274
+ }
275
+
276
+ deps.log(HOOK, `OK (agent="${subagentType || '?'}" branch="${branch}")`);
277
+ }
278
+
279
+ // ─────────────────────────────────────────────────────────────
280
+ // Entry point (production use)
281
+ // ─────────────────────────────────────────────────────────────
282
+
283
+ async function main(): Promise<void> {
284
+ await mainWithDeps(defaultDeps);
285
+ }
286
+
287
+ main().catch(() => process.exit(0));
@@ -7881,7 +7881,6 @@ function readSessionSelection(stateDir, sessionId) {
7881
7881
  var HOOK = "SESSION-START";
7882
7882
  var __filename2 = fileURLToPath2(import.meta.url);
7883
7883
  var __dirname2 = dirname3(__filename2);
7884
- var GRAPHRAG_TIMEOUT_MS = 2e3;
7885
7884
  var PROJECT_ROOT2 = resolveHookProjectRoot(process.cwd());
7886
7885
  var PROJECT_ID2 = process.env.ORCHESTRATOR_PROJECT_ID ?? "";
7887
7886
  var HOME = process.env.HOME ?? process.env.USERPROFILE ?? "/home/user";
@@ -7960,7 +7959,7 @@ function readBacklogFallback() {
7960
7959
  const latestSession = sessionMatches.sort((a, b) => b.num - a.num || a.idx - b.idx)[0];
7961
7960
  const lastSession = latestSession?.line.replace(/\*\*/g, "").slice(0, 120) || "";
7962
7961
  const nextLine = allLines.find((l) => /^\*\*proximo passo/i.test(l) || /^\*\*next/i.test(l));
7963
- const next = nextLine?.replace(/.*\*\*(proximo passo|next):?\*\*:?\s*/i, "").replace(/\s*$/, "").slice(0, 120) || "see BACKLOG.md";
7962
+ const next = nextLine?.replace(/.*\*\*(proximo passo|next):?\*\*:?\s*/i, "").replace(/\s*$/, "").slice(0, 120) || "BACKLOG.md has no next-step line (legacy parser; see project-guidelines/project-state.yaml)";
7964
7963
  log(
7965
7964
  HOOK,
7966
7965
  `BACKLOG.md fallback parsed: version=${version} next=${next.slice(0, 50)} lastSession=${lastSession.slice(0, 50)}`
@@ -7968,7 +7967,12 @@ function readBacklogFallback() {
7968
7967
  return { version, next, status, lastSession };
7969
7968
  } catch (err) {
7970
7969
  log(HOOK, `BACKLOG read failed: ${err}`);
7971
- return { version: "v?.?.?", next: "see BACKLOG.md", status: "", lastSession: "" };
7970
+ return {
7971
+ version: "v?.?.?",
7972
+ next: "no session state (project-guidelines/project-state.yaml missing or invalid, BACKLOG.md unreadable)",
7973
+ status: "",
7974
+ lastSession: ""
7975
+ };
7972
7976
  }
7973
7977
  }
7974
7978
  function getBacklogSummary() {
@@ -8128,34 +8132,6 @@ function checkSprintInbox() {
8128
8132
  return "";
8129
8133
  }
8130
8134
  }
8131
- async function getGraphContext(token, fetcher) {
8132
- if (token === null || token === "") return "";
8133
- const defaultFetcher = async (path, tok, timeoutMs = GRAPHRAG_TIMEOUT_MS) => {
8134
- const resp = await fetch(`http://localhost:3000${path}`, {
8135
- headers: { Authorization: `Bearer ${tok}` },
8136
- signal: AbortSignal.timeout(timeoutMs)
8137
- });
8138
- if (!resp.ok) return null;
8139
- return resp.json();
8140
- };
8141
- const fetch_ = fetcher ?? defaultFetcher;
8142
- try {
8143
- const [statsRaw, atRiskRaw] = await Promise.all([
8144
- fetch_("/api/v1/graphrag/stats", token, GRAPHRAG_TIMEOUT_MS),
8145
- fetch_("/api/v1/graphrag/at-risk", token, GRAPHRAG_TIMEOUT_MS)
8146
- ]);
8147
- const stats = statsRaw;
8148
- if (stats?.nodeCount === void 0) return "";
8149
- const atRiskData = atRiskRaw;
8150
- const components = String(stats.nodeCount);
8151
- const edges = String(stats.edgeCount ?? 0);
8152
- const hotPaths = (stats.hotPaths ?? []).join(",");
8153
- const atRisk = (atRiskData?.atRisk ?? []).join(",");
8154
- return `[GRAPH-CONTEXT] components=${components} edges=${edges} hot_paths=[${hotPaths}] at_risk=[${atRisk}]`;
8155
- } catch {
8156
- return "";
8157
- }
8158
- }
8159
8135
  async function main() {
8160
8136
  log(HOOK, "SessionStart hook triggered");
8161
8137
  if (PROJECT_ROOT2 === null) {
@@ -8172,8 +8148,7 @@ async function main() {
8172
8148
  const tierSegment = formatTierSegment(tierInfo);
8173
8149
  const capabilityContext = buildCapabilityMatrixContext();
8174
8150
  const sprintInboxContext = checkSprintInbox();
8175
- const token = await getToken();
8176
- const graphContext = await getGraphContext(token);
8151
+ await getToken();
8177
8152
  const selection = PROJECT_SELECTION_ENABLED && sessionId ? readSessionSelection(SESSION_SELECTION_STATE_DIR, sessionId) : void 0;
8178
8153
  const projectBinding = resolveSessionProjectBinding({
8179
8154
  envOverride: PROJECT_ID2,
@@ -8198,7 +8173,6 @@ async function main() {
8198
8173
  "Do NOT invoke /orchestrator skill for the greeting \u2014 use AskUserQuestion directly.",
8199
8174
  "Invoke /orchestrator skill only when the user chooses to start a workflow.",
8200
8175
  capabilityContext,
8201
- graphContext,
8202
8176
  sprintInboxContext,
8203
8177
  projectSelectionPrompt
8204
8178
  ].filter(Boolean).join("\n")
@@ -8216,7 +8190,6 @@ async function main() {
8216
8190
  "Do NOT invoke /orchestrator skill for the greeting \u2014 use AskUserQuestion directly.",
8217
8191
  "Invoke /orchestrator skill when the user chooses to continue the workflow.",
8218
8192
  capabilityContext,
8219
- graphContext,
8220
8193
  projectSelectionPrompt
8221
8194
  ].filter(Boolean).join("\n")
8222
8195
  );
@@ -8227,7 +8200,6 @@ export {
8227
8200
  formatProjectBindingSegment,
8228
8201
  formatSessionNumberSegment,
8229
8202
  formatTierSegment,
8230
- getGraphContext,
8231
8203
  readProjectState,
8232
8204
  resolveSessionProjectBinding,
8233
8205
  synthesizeStatus
@@ -37,7 +37,6 @@ import { readSessionSelection } from './lib/session-selection.js';
37
37
  const HOOK = 'SESSION-START';
38
38
  const __filename = fileURLToPath(import.meta.url);
39
39
  const __dirname = dirname(__filename);
40
- const GRAPHRAG_TIMEOUT_MS = 2000;
41
40
  // RFC-040 Operator Multi-Project — Slice 5 (C5), S358/S361: cwd-aware
42
41
  // resolution (walks up for the .orchestrator sentinel) replaces the
43
42
  // __dirname-hardcoded path. `null` means an unregistered cwd — deliberately
@@ -208,7 +207,8 @@ function readBacklogFallback(): BacklogSummary {
208
207
  nextLine
209
208
  ?.replace(/.*\*\*(proximo passo|next):?\*\*:?\s*/i, '')
210
209
  .replace(/\s*$/, '')
211
- .slice(0, 120) || 'see BACKLOG.md';
210
+ .slice(0, 120) ||
211
+ 'BACKLOG.md has no next-step line (legacy parser; see project-guidelines/project-state.yaml)';
212
212
 
213
213
  log(
214
214
  HOOK,
@@ -217,7 +217,12 @@ function readBacklogFallback(): BacklogSummary {
217
217
  return { version, next, status, lastSession };
218
218
  } catch (err) {
219
219
  log(HOOK, `BACKLOG read failed: ${err}`);
220
- return { version: 'v?.?.?', next: 'see BACKLOG.md', status: '', lastSession: '' };
220
+ return {
221
+ version: 'v?.?.?',
222
+ next: 'no session state (project-guidelines/project-state.yaml missing or invalid, BACKLOG.md unreadable)',
223
+ status: '',
224
+ lastSession: '',
225
+ };
221
226
  }
222
227
  }
223
228
 
@@ -371,9 +376,9 @@ export function formatProjectBindingSegment(binding: SessionProjectBinding): str
371
376
  * registered project this session belongs to, listing every registered
372
377
  * project by name/id/rootPath.
373
378
  *
374
- * Fail-open (Class C benign degrade, mirrors buildCapabilityMatrixContext
375
- * / getGraphContext): returns "" on any error — a failed project-list
376
- * fetch must never block or crash the hook.
379
+ * Fail-open (Class C benign degrade, mirrors buildCapabilityMatrixContext):
380
+ * returns "" on any error — a failed project-list fetch must never block
381
+ * or crash the hook.
377
382
  */
378
383
  export async function buildProjectSelectionPrompt(): Promise<string> {
379
384
  try {
@@ -504,66 +509,6 @@ function checkSprintInbox(): string {
504
509
  }
505
510
  }
506
511
 
507
- // ─── Graph Context (RFC-021 T-G4-06) ──────────────────────────────────────────
508
-
509
- type GraphFetcher = (path: string, token: string, timeoutMs?: number) => Promise<unknown>;
510
-
511
- interface GraphStatsResponse {
512
- nodeCount?: number;
513
- edgeCount?: number;
514
- hotPaths?: string[];
515
- }
516
-
517
- interface AtRiskResponse {
518
- atRisk?: string[];
519
- }
520
-
521
- /**
522
- * Fetch graph stats and at-risk components from the GraphRAG API.
523
- * Uses Promise.all for parallel calls with a 2s AbortController timeout.
524
- * Fail-open: returns "" on any error so session-start never blocks.
525
- *
526
- * @param token - JWT token (null → skip API calls, return "")
527
- * @param fetcher - injectable fetch function (default: real HTTP via api-client pattern)
528
- */
529
- export async function getGraphContext(
530
- token: string | null,
531
- fetcher?: GraphFetcher
532
- ): Promise<string> {
533
- if (token === null || token === '') return '';
534
-
535
- const defaultFetcher: GraphFetcher = async (path, tok, timeoutMs = GRAPHRAG_TIMEOUT_MS) => {
536
- const resp = await fetch(`http://localhost:3000${path}`, {
537
- headers: { Authorization: `Bearer ${tok}` },
538
- signal: AbortSignal.timeout(timeoutMs),
539
- });
540
- if (!resp.ok) return null;
541
- return resp.json() as Promise<unknown>;
542
- };
543
-
544
- const fetch_ = fetcher ?? defaultFetcher;
545
-
546
- try {
547
- const [statsRaw, atRiskRaw] = await Promise.all([
548
- fetch_('/api/v1/graphrag/stats', token, GRAPHRAG_TIMEOUT_MS),
549
- fetch_('/api/v1/graphrag/at-risk', token, GRAPHRAG_TIMEOUT_MS),
550
- ]);
551
-
552
- const stats = statsRaw as GraphStatsResponse | null;
553
- if (stats?.nodeCount === undefined) return '';
554
-
555
- const atRiskData = atRiskRaw as AtRiskResponse | null;
556
- const components = String(stats.nodeCount);
557
- const edges = String(stats.edgeCount ?? 0);
558
- const hotPaths = (stats.hotPaths ?? []).join(',');
559
- const atRisk = (atRiskData?.atRisk ?? []).join(',');
560
-
561
- return `[GRAPH-CONTEXT] components=${components} edges=${edges} hot_paths=[${hotPaths}] at_risk=[${atRisk}]`;
562
- } catch {
563
- return '';
564
- }
565
- }
566
-
567
512
  async function main(): Promise<void> {
568
513
  log(HOOK, 'SessionStart hook triggered');
569
514
 
@@ -586,15 +531,15 @@ async function main(): Promise<void> {
586
531
  // Sprint inbox check (RFC-027 S4 — only for sprint panes)
587
532
  const sprintInboxContext = checkSprintInbox();
588
533
 
589
- // Graph context (RFC-021 T-G4-06) — fail-open, 2s timeout. RFC-040 TD-206
590
- // (S372): this getToken() call MUST stay HERE, before
534
+ // RFC-040 TD-206 (S372): this getToken() call MUST stay HERE, before
591
535
  // resolveSessionProjectBinding() below — it is what bootstraps the
592
536
  // cwd-hint tier (getToken -> ensureProjectIdResolved ->
593
537
  // getEffectiveProjectId() reads the resolved value just below). Moving
594
538
  // getActiveWorkflow()/getNextAction() later (see below) must not remove
595
539
  // this bootstrap, or the cwd-hint tier would regress to reading '' .
596
- const token = await getToken();
597
- const graphContext = await getGraphContext(token);
540
+ // Return value no longer consumed directly (TD-211 F9 — getGraphContext,
541
+ // its sole prior consumer, was removed along with the graph-context surface).
542
+ await getToken();
598
543
 
599
544
  // RFC-040 TD-204 ⑤-C: read a previously-persisted selection for THIS
600
545
  // sessionId — feature-flagged (default OFF, kill-switch). With the flag
@@ -670,7 +615,6 @@ async function main(): Promise<void> {
670
615
  'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
671
616
  'Invoke /orchestrator skill only when the user chooses to start a workflow.',
672
617
  capabilityContext,
673
- graphContext,
674
618
  sprintInboxContext,
675
619
  projectSelectionPrompt,
676
620
  ]
@@ -696,7 +640,6 @@ async function main(): Promise<void> {
696
640
  'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
697
641
  'Invoke /orchestrator skill when the user chooses to continue the workflow.',
698
642
  capabilityContext,
699
- graphContext,
700
643
  projectSelectionPrompt,
701
644
  ]
702
645
  .filter(Boolean)
@@ -7595,16 +7595,6 @@ function getField(obj, path) {
7595
7595
  }
7596
7596
  return current != null ? String(current) : "";
7597
7597
  }
7598
- function outputContext(hookEvent, context) {
7599
- console.log(
7600
- JSON.stringify({
7601
- hookSpecificOutput: {
7602
- hookEventName: hookEvent,
7603
- additionalContext: context
7604
- }
7605
- })
7606
- );
7607
- }
7608
7598
 
7609
7599
  // hooks/lib/git-checkpoint.ts
7610
7600
  import { execSync } from "node:child_process";
@@ -7766,14 +7756,6 @@ async function main() {
7766
7756
  );
7767
7757
  }
7768
7758
  const commitHash = await createCheckpointIfNeeded(workflow.id, workflow.currentPhase);
7769
- const parts = [
7770
- `[ORCHESTRATOR] Agent '${agentType}' completed for workflow ${workflow.id}.`
7771
- ];
7772
- if (commitHash) {
7773
- parts.push(`Checkpoint: ${commitHash}.`);
7774
- }
7775
- parts.push("Proceed to gate check via AskUserQuestion before advancing phase.");
7776
- outputContext("SubagentStop", parts.join(" "));
7777
7759
  try {
7778
7760
  const sessionId = getField(stdin, "session_id") || getField(stdin, "claude_session_id") || "";
7779
7761
  const memoryBaseDir = resolveAgentMemoryBaseDir(process.cwd());
@@ -13,7 +13,6 @@ import {
13
13
  readStdin,
14
14
  getField,
15
15
  getActiveWorkflow,
16
- outputContext,
17
16
  readInvocationState,
18
17
  consumeInvocationState,
19
18
  completeAgentInvocation,
@@ -82,18 +81,35 @@ async function main(): Promise<void> {
82
81
  // 2. Git checkpoint if phase changed
83
82
  const commitHash = await createCheckpointIfNeeded(workflow.id, workflow.currentPhase);
84
83
 
85
- // 3. Guide main conversation — NO ping-pong, main IS the orchestrator
86
- const parts: string[] = [
87
- `[ORCHESTRATOR] Agent '${agentType}' completed for workflow ${workflow.id}.`,
88
- ];
89
-
90
- if (commitHash) {
91
- parts.push(`Checkpoint: ${commitHash}.`);
92
- }
93
-
94
- parts.push('Proceed to gate check via AskUserQuestion before advancing phase.');
95
-
96
- outputContext('SubagentStop', parts.join(' '));
84
+ // 3. NO context emission on SubagentStop — see below.
85
+ //
86
+ // This block used to call outputContext('SubagentStop', "[ORCHESTRATOR] Agent 'X'
87
+ // completed... Proceed to gate check via AskUserQuestion before advancing phase.").
88
+ // The intent was to guide the MAIN conversation (RFC-022: main IS the orchestrator).
89
+ // That intent is UNREACHABLE from this event, and the attempt was actively harmful:
90
+ //
91
+ // outputContext() emits hookSpecificOutput.additionalContext. On SubagentStop that
92
+ // context is injected into the SUB-AGENT's turn, not the orchestrator's — so the
93
+ // sub-agent was handed an orchestrator-addressed instruction naming a tool it does
94
+ // not have (AskUserQuestion). It cannot comply and cannot decline; it replies, stops
95
+ // again, the hook re-fires with the identical message, and the cycle repeats.
96
+ //
97
+ // MEASURED (S400, .orchestrator/.state/hook-debug.log): exactly 9 re-fires per dispatch,
98
+ // for `specifier`, `planner` AND — in logs predating this session — `implementer`. The
99
+ // sub-agent's final report is consumed by the loop (the orchestrator receives only the
100
+ // agent handle), while the real work survives because it lands via artifactStore.
101
+ // The planner of workflow 8f149ec6 self-reported the loop from inside it.
102
+ //
103
+ // Do NOT reintroduce an additionalContext emission here. Any payload on this event
104
+ // re-prompts the sub-agent — the defect is the emission itself, not its wording.
105
+ // The orchestrator already receives the sub-agent's result directly and owns the gate
106
+ // check unconditionally (orchestrator Skin, MUST #2). The RFC-034 post-implement signal
107
+ // is unaffected: it lives in post-implement.ts on PostToolUse[advancePhase], which fires
108
+ // in the MAIN conversation's context, where additionalContext is the correct mechanism.
109
+ //
110
+ // The side effects above (invocation-completion safety net, git checkpoint) and the
111
+ // observation below are unaffected — they are writes, not context injection.
112
+ // (commitHash is still consumed by the observation payload below.)
97
113
 
98
114
  // 4. Write Tier B observation (fail-open)
99
115
  try {