@orchestrator-claude/definitions 3.37.0 → 3.37.2

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.
Files changed (73) hide show
  1. package/agents/adversarial-reviewer.md +6 -1
  2. package/agents/api-extractor.md +6 -0
  3. package/agents/azure-devops-engineer.md +1 -2
  4. package/agents/business-rule-miner.md +6 -0
  5. package/agents/cicd-platform-engineer.md +1 -2
  6. package/agents/ciclo-engineer.md +50 -21
  7. package/agents/code-archaeologist.md +6 -0
  8. package/agents/debug-sidecar.md +6 -0
  9. package/agents/doc-sidecar.md +6 -0
  10. package/agents/docs-guardian.md +9 -3
  11. package/agents/frontend-engineer.md +1 -2
  12. package/agents/{ai-architect.md → harness-architect.md} +8 -3
  13. package/agents/implementer.md +6 -1
  14. package/agents/legacy-discoverer.md +6 -0
  15. package/agents/legacy-synthesizer.md +6 -0
  16. package/agents/methodology-researcher.md +6 -1
  17. package/agents/operational-artifact-builder.md +6 -1
  18. package/agents/orchestrator.md +27 -87
  19. package/agents/planner.md +6 -1
  20. package/agents/presentation-engineer.md +29 -15
  21. package/agents/python-data-engineer.md +0 -1
  22. package/agents/researcher.md +6 -0
  23. package/agents/reviewer.md +6 -1
  24. package/agents/schema-extractor.md +6 -0
  25. package/agents/software-architect.md +10 -5
  26. package/agents/specifier.md +6 -1
  27. package/agents/task-generator.md +6 -1
  28. package/agents/test-sidecar.md +6 -0
  29. package/dist/scripts/generate-hooks-matcher.d.ts +106 -0
  30. package/dist/scripts/generate-hooks-matcher.d.ts.map +1 -0
  31. package/dist/scripts/generate-hooks-matcher.js +153 -0
  32. package/dist/scripts/generate-hooks-matcher.js.map +1 -0
  33. package/dist/scripts/generate-manifest.d.ts +22 -0
  34. package/dist/scripts/generate-manifest.d.ts.map +1 -1
  35. package/dist/scripts/generate-manifest.js +67 -2
  36. package/dist/scripts/generate-manifest.js.map +1 -1
  37. package/hooks/agent-git-guard.mjs +63 -11
  38. package/hooks/dangling-guard.mjs +42 -12
  39. package/hooks/gate-guardian.mjs +38 -8
  40. package/hooks/hooks.json +3 -9
  41. package/hooks/lib/api-client.ts +128 -9
  42. package/hooks/lib/capability-scanner.ts +462 -55
  43. package/hooks/lib/dispatch-marker.ts +214 -0
  44. package/hooks/lib/git-checkpoint.ts +34 -37
  45. package/hooks/lib/observations-helper.ts +14 -1
  46. package/hooks/lib/security-gate-core.ts +271 -0
  47. package/hooks/mailbox-listener.mjs +69 -17
  48. package/hooks/playwright-output-guard.mjs +40 -10
  49. package/hooks/post-implement.mjs +38 -8
  50. package/hooks/pre-agent-dispatch.mjs +137 -16
  51. package/hooks/pre-agent-dispatch.ts +82 -15
  52. package/hooks/security-gate-honest.mjs +302 -0
  53. package/hooks/security-gate-honest.ts +148 -0
  54. package/hooks/session-start.mjs +304 -106
  55. package/hooks/session-start.ts +317 -71
  56. package/hooks/subagent-start.mjs +7673 -22
  57. package/hooks/subagent-start.ts +367 -16
  58. package/hooks/subagent-stop.mjs +102 -62
  59. package/hooks/subagent-stop.ts +124 -28
  60. package/hooks/user-prompt.mjs +43 -13
  61. package/hooks/verification-before-completion.mjs +63 -11
  62. package/hooks/workflow-guard.mjs +45 -11
  63. package/hooks-settings-template.json +1 -7
  64. package/manifest.json +77 -61
  65. package/package.json +4 -2
  66. package/skills/checkpoint-protocol/SKILL.md +19 -4
  67. package/skills/ciclo-lane-dispatch/SKILL.md +117 -8
  68. package/skills/fase-1b-projecao/SKILL.md +150 -0
  69. package/skills/lane-1-entrevista/SKILL.md +173 -0
  70. package/skills/orchestrator/SKILL.md +11 -3
  71. package/skills/workflow-coordination/SKILL.md +3 -3
  72. package/hooks/post-compact.mjs +0 -7931
  73. package/hooks/post-compact.ts +0 -170
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
10
10
  import { parse as parseYaml } from 'yaml';
11
11
  import {
12
12
  log,
13
+ readStdin,
13
14
  getActiveWorkflow,
14
15
  getNextAction,
15
16
  outputContext,
@@ -24,14 +25,16 @@ import {
24
25
  import {
25
26
  needsRescan,
26
27
  buildCapabilityIndex,
27
- writeCapabilitiesToVault,
28
- readCapabilitiesFromVault,
28
+ resolveCapabilityCachePath,
29
+ writeCapabilityCache,
30
+ readCapabilityCache,
29
31
  scanAgentDefinitions,
30
32
  formatCapabilityMatrix,
31
33
  readMemoryIndex,
32
34
  filterRelevantMemories,
33
35
  formatMemoryContext,
34
36
  } from './lib/capability-scanner.js';
37
+ import type { MemoryIndexDiagnostic } from './lib/capability-scanner.js';
35
38
  import { readSessionSelection } from './lib/session-selection.js';
36
39
 
37
40
  const HOOK = 'SESSION-START';
@@ -63,29 +66,115 @@ const PROJECT_SELECTION_ENABLED = process.env.ORCHESTRATOR_ENABLE_PROJECT_SELECT
63
66
  // that is exactly the "no binding" state phase-1 prompts for).
64
67
  const SESSION_SELECTION_STATE_DIR = join(HOME, '.orchestrator', 'session-selection');
65
68
  const AGENTS_DIR = PROJECT_ROOT ? join(PROJECT_ROOT, '.claude', 'agents') : null;
69
+ // READ-ONLY from here on (TD-235 half B / S445 VETOR 1): the vault config is a
70
+ // git-tracked governance document. The scan cache moved out of it and into the
71
+ // gitignored state dir — see capability-scanner.ts "Cache Persistence".
66
72
  const VAULT_CONFIG_PATH = PROJECT_ROOT
67
73
  ? join(PROJECT_ROOT, 'project-guidelines', '.vault-config.yaml')
68
74
  : null;
75
+ // Same null-when-unregistered-cwd contract as its siblings (RFC-040 C5).
76
+ const CAPABILITY_CACHE_PATH = PROJECT_ROOT ? resolveCapabilityCachePath(PROJECT_ROOT) : null;
69
77
  const PROFILES_PATH = PROJECT_ROOT ? join(PROJECT_ROOT, '.claude', 'plugin-profiles.yaml') : null;
70
78
  const MEMORY_INDEX_PATH = PROJECT_ROOT
71
79
  ? join(PROJECT_ROOT, '.claude', 'agent-memory', 'orchestrator', 'memory-index.yaml')
72
80
  : null;
73
81
 
74
82
  /**
75
- * Read session_id from SessionStart stdin payload.
76
- * Claude Code sends JSON with session_id on SessionStart.
83
+ * How the harness entered this session. `compact` means context was
84
+ * compacted MID-TASK — a recovery, not a start (SPEC-S454 §12).
77
85
  */
78
- function readSessionId(): string | null {
86
+ interface SessionStartPayload {
87
+ sessionId: string | null;
88
+ source: string | null;
89
+ }
90
+
91
+ /**
92
+ * Read the SessionStart stdin payload — BOTH fields, in ONE read.
93
+ *
94
+ * SPEC-S455 RF-001/ADR-001: drains `process.stdin` to EOF via `readStdin()`
95
+ * (the same async primitive 13 of the other 14 hooks in this house already
96
+ * use successfully — `lib/api-client.ts#readStdin`), not
97
+ * `readFileSync('/dev/stdin')` (the old, synchronous mechanism — confirmed by
98
+ * runtime measurement, S455, to never deliver a real payload under the
99
+ * Claude Code harness's spawn: every `Claude session:` log line in this
100
+ * repo's history before this fix was a test fixture, zero were real UUIDs).
101
+ *
102
+ * ★ `process.stdin` is a stream, drained by the first `for await` inside
103
+ * `readStdin()`. A second read resolves to `{}` (readStdin's own
104
+ * empty/malformed contract), so a second call here would yield
105
+ * `source: null` on every real invocation, permanently pinning the hook to
106
+ * the boot branch while every mock-based test still passed — the same class
107
+ * of trap as the old mechanism, now manifested asynchronously instead of
108
+ * synchronously. That failure is silent by construction, which is why this
109
+ * function returns the whole payload and is called exactly once (see its
110
+ * single call site in `main()`). Do not add a second reader.
111
+ * Regression-pinned by tests/hooks/session-start-source-branch.test.ts, whose
112
+ * stdin mock models real drain semantics and counts the reads, and by
113
+ * tests/integration/api/helpers/spawn-session-start.test.ts, which drives a
114
+ * real OS pipe against the compiled bundle.
115
+ *
116
+ * ADR-005 (SPEC-S455 planner finding): the outer try/catch is deliberately
117
+ * KEPT around `readStdin()`, not removed — `readStdin()`'s own try/catch only
118
+ * protects `JSON.parse`, never a `for await` that itself throws/rejects (e.g.
119
+ * `process.stdin` unavailable/non-iterable under an unusual host such as a
120
+ * Vitest worker thread). Fail-open (RNF-002) must hold against ANY read
121
+ * failure, not just a malformed payload.
122
+ */
123
+ async function readSessionStartPayload(): Promise<SessionStartPayload> {
79
124
  try {
80
- const raw = readFileSync('/dev/stdin', 'utf-8').trim();
81
- if (!raw) return null;
82
- const payload = JSON.parse(raw) as Record<string, unknown>;
83
- return (payload.session_id as string) ?? null;
84
- } catch {
85
- return null;
125
+ const payload = await readStdin();
126
+ // SPEC-S455 RF-003: unconditional, every invocation — this is the line
127
+ // that would have made the old defect diagnosable from day one (model
128
+ // already validated by dangling-guard.ts:312). Limitation, documented
129
+ // here and in the spec: readStdin() collapses "stdin empty" and "JSON
130
+ // malformed" into the same {} — this instrumentation cannot and does not
131
+ // distinguish the two (out of scope, would require changing the shared
132
+ // helper, which 13 other hooks depend on).
133
+ log(HOOK, `stdin keys: [${Object.keys(payload).join(', ')}]`);
134
+ if (!payload.session_id && !payload.source) {
135
+ log(
136
+ HOOK,
137
+ `[WARN] stdin payload has neither session_id nor source — read likely empty or unparseable (readStdin() collapses both cases to {}); raw keys=[${Object.keys(payload).join(', ')}]`
138
+ );
139
+ }
140
+ return {
141
+ sessionId: (payload.session_id as string) ?? null,
142
+ source: (payload.source as string) ?? null,
143
+ };
144
+ } catch (err) {
145
+ // Anomaly readStdin() itself doesn't swallow (its own try/catch only
146
+ // covers JSON.parse) — e.g. a rejecting/throwing `for await`. Fail-open
147
+ // per RNF-002/ADR-005, but leave a trace instead of the old silence.
148
+ log(HOOK, `[WARN] readStdin() itself threw (not just empty/malformed stdin) — ${String(err)}`);
149
+ return { sessionId: null, source: null };
86
150
  }
87
151
  }
88
152
 
153
+ /**
154
+ * Recovery is ONLY `compact`. Every other source — including absent, unknown
155
+ * and unparseable — is a boot, so any degradation lands on the pre-existing
156
+ * behaviour rather than on a new, less-tested path (RNF-002 fail-open).
157
+ */
158
+ function isRecoverySource(source: string | null): boolean {
159
+ return source === 'compact';
160
+ }
161
+
162
+ /**
163
+ * Injected INSTEAD of the greeting instructions when recovering.
164
+ *
165
+ * The hard guarantee against a mid-task greeting is the missing
166
+ * `[ORCHESTRATOR-DATA]` token, not this text: the Session Start Protocol keys
167
+ * its AskUserQuestion greeting off that literal token, so withholding it
168
+ * disarms the trigger without editing the identity file (SPEC-S454 D-1,
169
+ * decided by the user). This directive is the belt to that suspenders — it
170
+ * states the situation the model cannot otherwise infer, since from the
171
+ * inside a compaction is indistinguishable from a fresh session.
172
+ */
173
+ const RECOVERY_DIRECTIVE: readonly string[] = [
174
+ '[ORCHESTRATOR] Context was compacted mid-session. This is NOT a session start.',
175
+ 'Do NOT greet. Do NOT call AskUserQuestion. Resume the in-flight task.',
176
+ ];
177
+
89
178
  // ─── RFC-033 Phase 1: project-state.yaml reader ─────────────────────────────
90
179
 
91
180
  const PROJECT_STATE_PATH = PROJECT_ROOT
@@ -403,59 +492,172 @@ export async function buildProjectSelectionPrompt(): Promise<string> {
403
492
  }
404
493
 
405
494
  /**
406
- * Build [CAPABILITY-MATRIX] + [MEMORY-CONTEXT] lines for injection.
407
- * Fail-open: returns empty string on any error.
495
+ * Build the [CAPABILITY-MATRIX] line (rescan-or-cache + format).
496
+ * Split out of buildCapabilityMatrixContext (TD-249) so a failure here
497
+ * cannot discard a memory line already built by buildMemoryLine, or vice
498
+ * versa — the two used to share one try block and were all-or-nothing.
408
499
  */
409
- function buildCapabilityMatrixContext(): string {
410
- try {
411
- // RFC-040 C5: unregistered cwd — no vault/agents/memory-index to read, skip.
412
- if (
413
- VAULT_CONFIG_PATH === null ||
414
- AGENTS_DIR === null ||
415
- PROFILES_PATH === null ||
416
- MEMORY_INDEX_PATH === null
417
- ) {
418
- log(
419
- HOOK,
420
- 'buildCapabilityMatrixContext: PROJECT_ROOT unresolved (unregistered cwd) — skipping'
421
- );
422
- return '';
423
- }
424
- let index = readCapabilitiesFromVault(VAULT_CONFIG_PATH);
425
- const cachedMtime = index?.installedPluginsMtime ?? '';
426
-
427
- if (!index || needsRescan(INSTALLED_PLUGINS_PATH, cachedMtime)) {
428
- log(HOOK, 'Capability rescan triggered');
429
- index = buildCapabilityIndex({
430
- installedPluginsPath: INSTALLED_PLUGINS_PATH,
431
- agentsDir: AGENTS_DIR,
432
- vaultConfigPath: VAULT_CONFIG_PATH,
433
- profilesPath: PROFILES_PATH,
434
- });
435
- writeCapabilitiesToVault(VAULT_CONFIG_PATH, index);
436
- } else {
437
- // Agents are always scanned fresh (fast, <10ms)
438
- index.agents = scanAgentDefinitions(AGENTS_DIR);
439
- log(HOOK, 'Using cached capability index');
440
- }
500
+ function buildMatrixLine(
501
+ vaultConfigPath: string,
502
+ agentsDir: string,
503
+ profilesPath: string,
504
+ cachePath: string
505
+ ): string {
506
+ // A null cache (absent / corrupt) means RESCAN, never an empty matrix.
507
+ let index = readCapabilityCache(cachePath, vaultConfigPath);
508
+ const cachedMtime = index?.installedPluginsMtime ?? '';
509
+
510
+ if (!index || needsRescan(INSTALLED_PLUGINS_PATH, cachedMtime)) {
511
+ log(HOOK, 'Capability rescan triggered');
512
+ index = buildCapabilityIndex({
513
+ installedPluginsPath: INSTALLED_PLUGINS_PATH,
514
+ agentsDir,
515
+ vaultConfigPath,
516
+ profilesPath,
517
+ });
518
+ writeCapabilityCache(cachePath, index);
519
+ } else {
520
+ // Agents are always scanned fresh (fast, <10ms)
521
+ index.agents = scanAgentDefinitions(agentsDir);
522
+ log(HOOK, 'Using cached capability index');
523
+ }
524
+
525
+ return formatCapabilityMatrix(index);
526
+ }
441
527
 
442
- const matrixLine = formatCapabilityMatrix(index);
528
+ /**
529
+ * Build the [MEMORY-CONTEXT] line. Split out of buildCapabilityMatrixContext
530
+ * (TD-249) — see buildMatrixLine doc comment for why.
531
+ */
532
+ function buildMemoryLine(memoryIndexPath: string): string {
533
+ const diag: MemoryIndexDiagnostic = {};
534
+ const memoryEntries = readMemoryIndex(memoryIndexPath, diag);
535
+ if (diag.reason) log(HOOK, `Memory index: ${diag.reason}`);
536
+
537
+ // Índice AUSENTE segue silencioso — não ter memória ainda é legítimo, e é o
538
+ // estado da maior parte dos slugs. Índice que EXISTE e não carrega, NÃO: era
539
+ // indistinguível de "não há memória" (S480). E o log sozinho não bastaria —
540
+ // ele vai para um arquivo de debug que ninguém abre. A linha entra NA SESSÃO,
541
+ // que é onde a ausência precisa ser vista: mesma regra do monitor da Root,
542
+ // campo ausente nunca some da tela.
543
+ //
544
+ // A guarda é `diag.reason`, NÃO `length === 0`: lista legitimamente vazia
545
+ // segue pelo caminho original (filter -> format -> ''), porque não é o
546
+ // defeito e mudá-la quebrou um teste que já existia — ele mocka
547
+ // readMemoryIndex devolvendo [] e assere que o fluxo continua.
548
+ if (diag.reason && (!memoryEntries || memoryEntries.length === 0)) {
549
+ return `[MEMORY-CONTEXT] INDISPONÍVEL — nenhuma memória injetada. ${diag.reason}`;
550
+ }
551
+ if (!memoryEntries) return '';
552
+ const filtered = filterRelevantMemories(memoryEntries);
553
+ return formatMemoryContext(filtered, memoryEntries);
554
+ }
443
555
 
444
- // Memory context
445
- const memoryEntries = readMemoryIndex(MEMORY_INDEX_PATH);
446
- let memoryLine = '';
447
- if (memoryEntries) {
448
- const filtered = filterRelevantMemories(memoryEntries);
449
- memoryLine = formatMemoryContext(filtered);
450
- }
556
+ /**
557
+ * RFC-040 C5: unregistered cwd — no vault/agents/memory-index to read, skip.
558
+ * (Single guard is correct here, NOT a repeat of the TD-249 bug: all five
559
+ * paths derive from the same PROJECT_ROOT, so they are null/non-null
560
+ * together — this is one precondition, not two independently-failing
561
+ * steps. CAPABILITY_CACHE_PATH joined them in S445; it is PROJECT_ROOT-
562
+ * derived too, unless ORCHESTRATOR_STATE_DIR overrides the state dir, in
563
+ * which case it is non-null whenever PROJECT_ROOT is.)
564
+ */
565
+ interface VaultPaths {
566
+ vaultConfigPath: string;
567
+ agentsDir: string;
568
+ profilesPath: string;
569
+ memoryIndexPath: string;
570
+ capabilityCachePath: string;
571
+ }
572
+
573
+ function resolveVaultPaths(caller: string): VaultPaths | null {
574
+ if (
575
+ VAULT_CONFIG_PATH === null ||
576
+ AGENTS_DIR === null ||
577
+ PROFILES_PATH === null ||
578
+ MEMORY_INDEX_PATH === null ||
579
+ CAPABILITY_CACHE_PATH === null
580
+ ) {
581
+ log(HOOK, `${caller}: PROJECT_ROOT unresolved (unregistered cwd) — skipping`);
582
+ return null;
583
+ }
584
+ return {
585
+ vaultConfigPath: VAULT_CONFIG_PATH,
586
+ agentsDir: AGENTS_DIR,
587
+ profilesPath: PROFILES_PATH,
588
+ memoryIndexPath: MEMORY_INDEX_PATH,
589
+ capabilityCachePath: CAPABILITY_CACHE_PATH,
590
+ };
591
+ }
451
592
 
452
- return [matrixLine, memoryLine].filter(Boolean).join('\n');
593
+ /**
594
+ * Build the [CAPABILITY-MATRIX] line for injection. Fail-open (TD-249):
595
+ * a throw here returns '' and never touches the memory line, which is
596
+ * built by buildMemoryContext() on its own try block.
597
+ * Exported for unit-testability (TD-315).
598
+ */
599
+ export function buildMatrixContext(): string {
600
+ const paths = resolveVaultPaths('buildMatrixContext');
601
+ if (!paths) return '';
602
+ try {
603
+ return buildMatrixLine(
604
+ paths.vaultConfigPath,
605
+ paths.agentsDir,
606
+ paths.profilesPath,
607
+ paths.capabilityCachePath
608
+ );
453
609
  } catch (err) {
454
610
  log(HOOK, `Capability matrix failed: ${err}`);
455
611
  return '';
456
612
  }
457
613
  }
458
614
 
615
+ /**
616
+ * Build the [MEMORY-CONTEXT] line for injection. Fail-open (TD-249):
617
+ * a throw here returns '' and never touches the matrix line.
618
+ *
619
+ * TD-315: emitted SEPARATELY from the matrix — and BEFORE [LAST-SESSION] —
620
+ * so the memory is not the first block to die when the harness truncates
621
+ * additionalContext. Measured S513/S514: total 17190/17013 chars; with the
622
+ * old order ([ORCHESTRATOR-DATA] · [LAST-SESSION] · [PENDENTE] · greeting ·
623
+ * [CAPABILITY-MATRIX] · [MEMORY-CONTEXT]) the memory started at offset
624
+ * 10974/10551 and a 10000-char cut took ALL 20 memories while the
625
+ * narrative survived whole. Placed right after the state line (offset
626
+ * 4101 in S514, ending 10563), the same cut keeps 91% of it, and any cut
627
+ * >= 10563 keeps it whole — with nothing lost when nothing is cut. Capping
628
+ * [LAST-SESSION] instead would NOT save the memory (it would still end past
629
+ * 13000) and would mutilate the PM's narrative every session, cut or not.
630
+ * Exported for unit-testability (TD-315).
631
+ */
632
+ export function buildMemoryContext(): string {
633
+ const paths = resolveVaultPaths('buildMemoryContext');
634
+ if (!paths) return '';
635
+ try {
636
+ return buildMemoryLine(paths.memoryIndexPath);
637
+ } catch (err) {
638
+ log(HOOK, `Memory context failed: ${err}`);
639
+ return '';
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Build [CAPABILITY-MATRIX] + [MEMORY-CONTEXT] lines, matrix first.
645
+ * Fail-open: each half degrades INDEPENDENTLY (TD-249) — a throw building
646
+ * the matrix must not discard a working memory line, and a throw building
647
+ * the memory line must not discard a working matrix line. Previously both
648
+ * lived in one try block, so either failure returned '' and threw away
649
+ * whatever the other half had already produced.
650
+ *
651
+ * TD-315: no longer what main() injects — main() now places the two halves
652
+ * at different offsets (memory before the narrative, matrix after the
653
+ * greeting). Kept as the composed form for the TD-249 partial-failure
654
+ * contract and any external caller.
655
+ * Exported for unit-testability (TD-249).
656
+ */
657
+ export function buildCapabilityMatrixContext(): string {
658
+ return [buildMatrixContext(), buildMemoryContext()].filter(Boolean).join('\n');
659
+ }
660
+
459
661
  /**
460
662
  * Check sprint mailbox inbox for pending tasks (RFC-027 Sprint 4).
461
663
  * Only runs when SPRINT_ID and PANE_ROLE are set (sprint pane sessions).
@@ -519,14 +721,22 @@ async function main(): Promise<void> {
519
721
  );
520
722
  }
521
723
 
522
- const sessionId = readSessionId();
724
+ // ONE read of stdin (readStdin() drains process.stdin to EOF), both fields
725
+ // derived from it — see readSessionStartPayload's doc comment for why a
726
+ // second read is fatal.
727
+ const { sessionId, source } = await readSessionStartPayload();
728
+ const isRecovery = isRecoverySource(source);
523
729
  const sessionSegment = sessionId ? ` | session=${sessionId}` : '';
524
730
  if (sessionId) log(HOOK, `Claude session: ${sessionId}`);
731
+ log(HOOK, `source=${source ?? 'unknown'} mode=${isRecovery ? 'recovery' : 'boot'}`);
525
732
 
526
733
  const backlog = getBacklogSummary();
527
734
  const tierInfo = await checkTier();
528
735
  const tierSegment = formatTierSegment(tierInfo);
529
- const capabilityContext = buildCapabilityMatrixContext();
736
+ // TD-315: two separate blocks at two different offsets — see
737
+ // buildMemoryContext's doc comment for the measured reason.
738
+ const memoryContext = buildMemoryContext();
739
+ const matrixContext = buildMatrixContext();
530
740
 
531
741
  // Sprint inbox check (RFC-027 S4 — only for sprint panes)
532
742
  const sprintInboxContext = checkSprintInbox();
@@ -582,8 +792,15 @@ async function main(): Promise<void> {
582
792
  // folded as extra lines into whichever outputContext() call below fires
583
793
  // — never a SECOND outputContext call, so exactly one [ORCHESTRATOR-DATA]
584
794
  // block is ever emitted (double-load regression guard).
795
+ //
796
+ // SPEC-S454 RF-002: skipped entirely when recovering. A compaction does not
797
+ // change the project binding — if none resolved, the prompt already fired at
798
+ // boot — and re-prompting mid-task is both an AskUserQuestion (which the
799
+ // recovery directive just forbade) and the interruption this fix exists to
800
+ // avoid. Skipping also spares the API round-trip inside the recovery path
801
+ // (RNF-001: no new I/O).
585
802
  const projectSelectionPrompt =
586
- PROJECT_SELECTION_ENABLED && projectBinding.source === 'none'
803
+ PROJECT_SELECTION_ENABLED && !isRecovery && projectBinding.source === 'none'
587
804
  ? await buildProjectSelectionPrompt()
588
805
  : '';
589
806
 
@@ -604,17 +821,35 @@ async function main(): Promise<void> {
604
821
  const workflow = await getActiveWorkflow();
605
822
 
606
823
  if (!workflow) {
607
- log(HOOK, 'No active workflow, injecting greeting data');
824
+ log(HOOK, `No active workflow, injecting ${isRecovery ? 'recovery' : 'greeting'} data`);
825
+ // SPEC-S454 RF-002: identical STATE, different FRAME. Only the leading
826
+ // token and the instruction block differ between boot and recovery — the
827
+ // capability matrix, memory context and sprint inbox are exactly what a
828
+ // compaction destroys, so they ride both paths unchanged.
829
+ // [LAST-SESSION]/[PENDENTE] stay boot-only: they orient someone arriving,
830
+ // and mid-task they are noise that reads like a session opening.
831
+ //
832
+ // TD-315 ORDER CONTRACT: the state line stays at offset 0 (the greeting
833
+ // keys off the leading token); [MEMORY-CONTEXT] comes IMMEDIATELY after
834
+ // it, before [LAST-SESSION] and everything else, so that a truncation of
835
+ // additionalContext takes narrative and routing hints before it takes
836
+ // memory. Pinned by tests/hooks/session-start-memory-before-narrative.test.ts.
837
+ const stateLine = `version=${backlog.version}${sessionSegment} | workflow=none${tierSegment}${sessionNumberSegment}${projectBindingSegment} | next=${backlog.next}`;
608
838
  outputContext(
609
839
  'SessionStart',
610
840
  [
611
- `[ORCHESTRATOR-DATA] version=${backlog.version}${sessionSegment} | workflow=none${tierSegment}${sessionNumberSegment}${projectBindingSegment} | next=${backlog.next}`,
612
- backlog.lastSession ? `[LAST-SESSION] ${backlog.lastSession}` : '',
613
- backlog.status ? `[PENDENTE] ${backlog.status}` : '',
614
- `[ORCHESTRATOR] Greet the user via AskUserQuestion using the data above (CLAUDE.md Rule #1).`,
615
- 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
616
- 'Invoke /orchestrator skill only when the user chooses to start a workflow.',
617
- capabilityContext,
841
+ `${isRecovery ? '[ORCHESTRATOR-RECOVERY]' : '[ORCHESTRATOR-DATA]'} ${stateLine}`,
842
+ memoryContext,
843
+ ...(isRecovery
844
+ ? RECOVERY_DIRECTIVE
845
+ : [
846
+ backlog.lastSession ? `[LAST-SESSION] ${backlog.lastSession}` : '',
847
+ backlog.status ? `[PENDENTE] ${backlog.status}` : '',
848
+ `[ORCHESTRATOR] Greet the user via AskUserQuestion using the data above (orchestrator identity, MUST rule #1).`,
849
+ 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
850
+ 'Invoke /orchestrator skill only when the user chooses to start a workflow.',
851
+ ]),
852
+ matrixContext,
618
853
  sprintInboxContext,
619
854
  projectSelectionPrompt,
620
855
  ]
@@ -631,15 +866,26 @@ async function main(): Promise<void> {
631
866
  ? ` | pending=${action.pendingAction.agent} (${action.pendingAction.status})`
632
867
  : '';
633
868
 
634
- log(HOOK, `Injecting workflow context (wf=${workflow.id} phase=${workflow.currentPhase})`);
869
+ log(
870
+ HOOK,
871
+ `Injecting workflow ${isRecovery ? 'recovery' : 'context'} (wf=${workflow.id} phase=${workflow.currentPhase})`
872
+ );
873
+ const stateLine = `version=${backlog.version}${sessionSegment} | workflow=${workflow.id} | type=${workflow.type} | phase=${workflow.currentPhase} | status=${workflow.status}${actionHint}${tierSegment}${sessionNumberSegment}${projectBindingSegment}`;
874
+ // TD-315: same order contract as the no-workflow path — memory right after
875
+ // the state line, matrix after the instruction block.
635
876
  outputContext(
636
877
  'SessionStart',
637
878
  [
638
- `[ORCHESTRATOR-DATA] version=${backlog.version}${sessionSegment} | workflow=${workflow.id} | type=${workflow.type} | phase=${workflow.currentPhase} | status=${workflow.status}${actionHint}${tierSegment}${sessionNumberSegment}${projectBindingSegment}`,
639
- `[ORCHESTRATOR] Active workflow detected. Greet the user via AskUserQuestion showing workflow state (CLAUDE.md Rule #1).`,
640
- 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
641
- 'Invoke /orchestrator skill when the user chooses to continue the workflow.',
642
- capabilityContext,
879
+ `${isRecovery ? '[ORCHESTRATOR-RECOVERY]' : '[ORCHESTRATOR-DATA]'} ${stateLine}`,
880
+ memoryContext,
881
+ ...(isRecovery
882
+ ? RECOVERY_DIRECTIVE
883
+ : [
884
+ `[ORCHESTRATOR] Active workflow detected. Greet the user via AskUserQuestion showing workflow state (orchestrator identity, MUST rule #1).`,
885
+ 'Do NOT invoke /orchestrator skill for the greeting — use AskUserQuestion directly.',
886
+ 'Invoke /orchestrator skill when the user chooses to continue the workflow.',
887
+ ]),
888
+ matrixContext,
643
889
  projectSelectionPrompt,
644
890
  ]
645
891
  .filter(Boolean)