@orchestrator-claude/definitions 3.32.2 → 3.34.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.
Files changed (117) hide show
  1. package/README.md +145 -0
  2. package/agents/adversarial-reviewer.md +2 -2
  3. package/agents/azure-devops-engineer.md +9 -1
  4. package/agents/cicd-platform-engineer.md +477 -0
  5. package/agents/debug-sidecar.md +6 -1
  6. package/agents/doc-sidecar.md +6 -1
  7. package/agents/frontend-engineer.md +173 -0
  8. package/agents/implementer.md +39 -23
  9. package/agents/methodology-researcher.md +2 -1
  10. package/agents/operational-artifact-builder.md +2 -1
  11. package/agents/orchestrator.md +47 -76
  12. package/agents/planner.md +2 -2
  13. package/agents/presentation-engineer.md +1 -1
  14. package/agents/python-data-engineer.md +214 -0
  15. package/agents/reviewer.md +16 -8
  16. package/agents/specifier.md +1 -1
  17. package/agents/task-generator.md +1 -1
  18. package/agents/test-sidecar.md +15 -4
  19. package/dist/cli.js +78 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/DiffCommand.js +1 -1
  22. package/dist/commands/DiffCommand.js.map +1 -1
  23. package/dist/commands/InstallCommand.d.ts +41 -0
  24. package/dist/commands/InstallCommand.d.ts.map +1 -0
  25. package/dist/commands/InstallCommand.js +253 -0
  26. package/dist/commands/InstallCommand.js.map +1 -0
  27. package/dist/http/ApiClient.d.ts +5 -0
  28. package/dist/http/ApiClient.d.ts.map +1 -1
  29. package/dist/http/ApiClient.js.map +1 -1
  30. package/dist/install/DevCheckoutDetector.d.ts +66 -0
  31. package/dist/install/DevCheckoutDetector.d.ts.map +1 -0
  32. package/dist/install/DevCheckoutDetector.js +150 -0
  33. package/dist/install/DevCheckoutDetector.js.map +1 -0
  34. package/dist/install/HooksWirer.d.ts +29 -0
  35. package/dist/install/HooksWirer.d.ts.map +1 -0
  36. package/dist/install/HooksWirer.js +80 -0
  37. package/dist/install/HooksWirer.js.map +1 -0
  38. package/dist/install/LocalDiffReporter.d.ts +25 -0
  39. package/dist/install/LocalDiffReporter.d.ts.map +1 -0
  40. package/dist/install/LocalDiffReporter.js +50 -0
  41. package/dist/install/LocalDiffReporter.js.map +1 -0
  42. package/dist/install/Materializer.d.ts +67 -0
  43. package/dist/install/Materializer.d.ts.map +1 -0
  44. package/dist/install/Materializer.js +196 -0
  45. package/dist/install/Materializer.js.map +1 -0
  46. package/dist/install/McpRegistrar.d.ts +63 -0
  47. package/dist/install/McpRegistrar.d.ts.map +1 -0
  48. package/dist/install/McpRegistrar.js +114 -0
  49. package/dist/install/McpRegistrar.js.map +1 -0
  50. package/dist/install/ProcessRunner.d.ts +25 -0
  51. package/dist/install/ProcessRunner.d.ts.map +1 -0
  52. package/dist/install/ProcessRunner.js +41 -0
  53. package/dist/install/ProcessRunner.js.map +1 -0
  54. package/dist/install/ProjectSettingsNeutralizer.d.ts +39 -0
  55. package/dist/install/ProjectSettingsNeutralizer.d.ts.map +1 -0
  56. package/dist/install/ProjectSettingsNeutralizer.js +100 -0
  57. package/dist/install/ProjectSettingsNeutralizer.js.map +1 -0
  58. package/dist/install/types.d.ts +52 -0
  59. package/dist/install/types.d.ts.map +1 -0
  60. package/dist/install/types.js +8 -0
  61. package/dist/install/types.js.map +1 -0
  62. package/dist/manifest/ManifestLoader.d.ts.map +1 -1
  63. package/dist/manifest/ManifestLoader.js +3 -2
  64. package/dist/manifest/ManifestLoader.js.map +1 -1
  65. package/dist/manifest/types.d.ts +3 -0
  66. package/dist/manifest/types.d.ts.map +1 -1
  67. package/dist/scripts/bundle-hooks.d.ts +39 -0
  68. package/dist/scripts/bundle-hooks.d.ts.map +1 -0
  69. package/dist/scripts/bundle-hooks.js +165 -0
  70. package/dist/scripts/bundle-hooks.js.map +1 -0
  71. package/dist/scripts/generate-manifest.d.ts +22 -3
  72. package/dist/scripts/generate-manifest.d.ts.map +1 -1
  73. package/dist/scripts/generate-manifest.js +78 -35
  74. package/dist/scripts/generate-manifest.js.map +1 -1
  75. package/hooks/dangling-guard.mjs +7426 -0
  76. package/hooks/dangling-guard.ts +223 -6
  77. package/hooks/gate-guardian.mjs +463 -0
  78. package/hooks/hooks.json +63 -0
  79. package/hooks/lib/additional-directories-sync.ts +295 -0
  80. package/hooks/lib/api-client.ts +388 -7
  81. package/hooks/lib/session-selection.ts +81 -0
  82. package/hooks/mailbox-listener.mjs +259 -0
  83. package/hooks/post-compact.mjs +7931 -0
  84. package/hooks/post-implement.mjs +238 -0
  85. package/hooks/session-start.mjs +8234 -0
  86. package/hooks/session-start.ts +264 -23
  87. package/hooks/sprint-registry.mjs +59 -0
  88. package/hooks/subagent-start.mjs +289 -0
  89. package/hooks/subagent-stop.mjs +7793 -0
  90. package/hooks/subagent-stop.ts +4 -2
  91. package/hooks/user-prompt.mjs +329 -0
  92. package/hooks/workflow-guard.mjs +7805 -0
  93. package/hooks/workflow-guard.ts +360 -119
  94. package/hooks-settings-template.json +63 -0
  95. package/manifest.json +124 -90
  96. package/mcp-servers.json +48 -0
  97. package/package.json +10 -4
  98. package/personas/auditor-compliance.md +104 -0
  99. package/personas/cfo-conservador.md +88 -0
  100. package/personas/designer-perfeccionista.md +104 -0
  101. package/personas/diretor-cetico-fmcg.md +80 -0
  102. package/personas/engenheiro-cetico-construcao.md +107 -0
  103. package/skills/artifact-generator/SKILL.md +1 -1
  104. package/skills/artifact-production/SKILL.md +28 -0
  105. package/skills/backlog-synthesis/SKILL.md +8 -3
  106. package/skills/checkpoint-protocol/SKILL.md +7 -0
  107. package/skills/infographic-engine/SKILL.md +116 -0
  108. package/skills/orchestrator/SKILL.md +93 -7
  109. package/skills/persona-library/SKILL.md +1 -1
  110. package/skills/project-conventions/SKILL.md +53 -30
  111. package/skills/project-management-enterprise/SKILL.md +1 -1
  112. package/skills/python-data-engineering/SKILL.md +792 -0
  113. package/skills/release/SKILL.md +29 -18
  114. package/skills/release/release.sh +65 -4
  115. package/skills/storybook-validated-loop/SKILL.md +110 -0
  116. package/skills/tdd-discipline/SKILL.md +16 -0
  117. package/skills/workflow-coordination/SKILL.md +117 -16
@@ -3,9 +3,10 @@
3
3
  * Replaces orch-helpers.sh: typed, async/await, zero `node -e` hacks.
4
4
  */
5
5
 
6
- import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, mkdirSync, statSync, unlinkSync, readdirSync } from 'node:fs';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { resolveProjectRoot } from '@orchestrator-claude/project-root-resolver';
9
10
 
10
11
  // Resolve paths relative to this file
11
12
  const __filename = fileURLToPath(import.meta.url);
@@ -20,13 +21,137 @@ mkdirSync(STATE_DIR, { recursive: true });
20
21
 
21
22
  // Config from env
22
23
  const API_URL = process.env.ORCHESTRATOR_API_URL || 'http://localhost:3001';
24
+ // RFC-040 ADR-002 (S358, TASK-RBAC-02): hooks authenticate as the seeded
25
+ // `system` tier account (level 4, ABOVE admin) rather than admin — the
26
+ // env-override chain is preserved verbatim so an explicit
27
+ // ORCHESTRATOR_ADMIN_EMAIL/ORCHESTRATOR_AUTH_EMAIL still wins.
23
28
  const AUTH_EMAIL =
24
29
  process.env.ORCHESTRATOR_ADMIN_EMAIL ||
25
30
  process.env.ORCHESTRATOR_AUTH_EMAIL ||
26
- 'admin@orchestrator.local';
31
+ 'system@orchestrator.local';
27
32
  const AUTH_PASSWORD =
28
- process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || 'admin123';
29
- const PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || '';
33
+ process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || 'system123';
34
+ // Mutable (not const): RFC-040 Operator Multi-Project, Slice 5 (C5) lazily
35
+ // resolves this once per process when the env var is absent — see
36
+ // ensureProjectIdResolved() below.
37
+ let PROJECT_ID = process.env.ORCHESTRATOR_PROJECT_ID || '';
38
+
39
+ // --- Project Root / Identity Resolution (RFC-040 Operator Multi-Project, C5) ---
40
+
41
+ /**
42
+ * Resolves the project root for hook file-system access by delegating to the
43
+ * zero-dependency `resolveProjectRoot` primitive (RFC-040 C1): walks up from
44
+ * `cwd` for the nearest ancestor holding a `.orchestrator` sentinel. Returns
45
+ * `null` — never throws — when no ancestor up to the filesystem root has the
46
+ * sentinel (unregistered cwd).
47
+ *
48
+ * Deliberately has NO `__dirname` fallback: falling back to the
49
+ * Orchestrator's own repo root would leak this project's vault
50
+ * (.claude/agents, project-guidelines/, agent-memory) into an unregistered
51
+ * project's session. Exported as a thin, directly-testable wrapper so
52
+ * session-start.ts, subagent-stop.ts and workflow-guard.ts share one
53
+ * resolution boundary.
54
+ */
55
+ export function resolveHookProjectRoot(cwd: string): string | null {
56
+ return resolveProjectRoot(cwd);
57
+ }
58
+
59
+ /**
60
+ * Resolves the `.claude/agent-memory` base directory for the CURRENT hook
61
+ * invocation: under the RESOLVED project root when the cwd is registered,
62
+ * falling back to `cwd` itself when unresolved (preserves prior behaviour —
63
+ * this fallback is intentionally different from resolveHookProjectRoot's
64
+ * no-fallback contract, since callers here need *some* writable directory
65
+ * regardless of registration state).
66
+ */
67
+ export function resolveAgentMemoryBaseDir(cwd: string): string {
68
+ return join(resolveHookProjectRoot(cwd) ?? cwd, '.claude', 'agent-memory');
69
+ }
70
+
71
+ /** Once-per-process guard so a failed/empty resolution is not retried on every getToken() call. */
72
+ let projectIdResolutionAttempted = false;
73
+
74
+ /**
75
+ * Best-effort, once-per-process resolution of PROJECT_ID when it was not
76
+ * supplied via the ORCHESTRATOR_PROJECT_ID env var (RFC-040 Operator
77
+ * Multi-Project, Slice 5/C5). Walks up from `process.cwd()` for the
78
+ * `.orchestrator` sentinel, then resolves the projectId via
79
+ * GET /api/v1/projects/resolve using the already-authenticated Bearer
80
+ * token (the endpoint is excluded from X-Project-ID validation —
81
+ * project-context.plugin.ts EXCLUDED_ROUTES includes '/api/v1/projects').
82
+ *
83
+ * Fail-open: any failure (no sentinel found, rootPath unregistered, network
84
+ * error) leaves PROJECT_ID unchanged ('') and is logged, never thrown —
85
+ * callers proceed with an empty X-Project-ID header exactly as before this
86
+ * change.
87
+ */
88
+ async function ensureProjectIdResolved(token: string): Promise<void> {
89
+ if (PROJECT_ID || projectIdResolutionAttempted) return;
90
+ projectIdResolutionAttempted = true;
91
+
92
+ try {
93
+ const rootPath = resolveHookProjectRoot(process.cwd());
94
+ if (rootPath === null) {
95
+ log(
96
+ 'API-CLIENT',
97
+ 'ensureProjectIdResolved: cwd not registered (.orchestrator sentinel not found) — skipping'
98
+ );
99
+ return;
100
+ }
101
+
102
+ const resolved = await resolveProjectIdByPath(rootPath, token);
103
+ if (resolved) {
104
+ PROJECT_ID = resolved;
105
+ log(
106
+ 'API-CLIENT',
107
+ `ensureProjectIdResolved: resolved projectId=${resolved} rootPath=${rootPath}`
108
+ );
109
+ } else {
110
+ log(
111
+ 'API-CLIENT',
112
+ `ensureProjectIdResolved: rootPath=${rootPath} not registered to any project — skipping`
113
+ );
114
+ }
115
+ } catch (err) {
116
+ log(
117
+ 'API-CLIENT',
118
+ `ensureProjectIdResolved: unexpected failure — ${err instanceof Error ? err.message : String(err)}`
119
+ );
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Returns the current in-memory PROJECT_ID (env value, or the RFC-040 C5
125
+ * lazily-resolved value once a prior getToken() call has resolved it).
126
+ * Sync getter — never triggers resolution itself.
127
+ */
128
+ export function getEffectiveProjectId(): string {
129
+ return PROJECT_ID;
130
+ }
131
+
132
+ /**
133
+ * Overwrites the current in-memory PROJECT_ID with an explicitly-resolved
134
+ * value (RFC-040 TD-206, S372). Used by session-start.ts to feed the
135
+ * precedence-resolved SessionProjectBinding (env-override > selection >
136
+ * cwd-hint, TD-204 ⑥-c) back into the api-client's own scoping BEFORE
137
+ * getActiveWorkflow()/getNextAction() run, so their `X-Project-ID` header
138
+ * reflects the SAME projectId the [ORCHESTRATOR-DATA] line surfaces —
139
+ * closing the display-vs-scoping gap proven empirically in S371 Case-2
140
+ * (a selection-bound session showed the right `project=` but queried the
141
+ * wrong workflow, see AC-C9-4-selection-model-acceptance.md §5).
142
+ *
143
+ * Deliberately a raw assignment, not a "resolve" — the caller has already
144
+ * done precedence resolution; this function trusts the input and never
145
+ * touches the network itself. Passing '' is valid (mirrors the `none`
146
+ * binding source). Because ensureProjectIdResolved's guard is
147
+ * `if (PROJECT_ID || projectIdResolutionAttempted) return;`, setting a
148
+ * non-empty value here also transitively prevents any LATER
149
+ * ensureProjectIdResolved call (e.g. from a subsequent getToken()) from
150
+ * clobbering it with a fresh cwd-based resolution.
151
+ */
152
+ export function setEffectiveProjectId(projectId: string): void {
153
+ PROJECT_ID = projectId;
154
+ }
30
155
 
31
156
  // --- Types ---
32
157
 
@@ -86,8 +211,14 @@ function getCachedToken(): string | null {
86
211
  }
87
212
 
88
213
  async function login(): Promise<string | null> {
89
- if (!PROJECT_ID) return null;
90
-
214
+ // RFC-040 Operator Multi-Project, Slice 5 (C5): the pre-C5 guard used to
215
+ // bail out here when PROJECT_ID was unset. Relaxed deliberately —
216
+ // /api/v1/auth/login is a PUBLIC route (auth.middleware.ts PUBLIC_ROUTES)
217
+ // AND is excluded from X-Project-ID validation
218
+ // (project-context.plugin.ts EXCLUDED_ROUTES includes '/api/v1/auth') —
219
+ // the server ignores X-Project-ID on this route entirely, so login can
220
+ // always proceed and PROJECT_ID is resolved afterwards, once a Bearer
221
+ // token is available (see ensureProjectIdResolved, called from getToken()).
91
222
  try {
92
223
  const resp = await fetch(`${API_URL}/api/v1/auth/login`, {
93
224
  method: 'POST',
@@ -115,7 +246,17 @@ async function login(): Promise<string | null> {
115
246
  }
116
247
 
117
248
  export async function getToken(): Promise<string | null> {
118
- return getCachedToken() || (await login());
249
+ const token = getCachedToken() || (await login());
250
+ if (token) {
251
+ // RFC-040 C5: single funnel point — every caller (getActiveWorkflow,
252
+ // getNextAction, allocateSessionNumber, startInvocation, ...) obtains
253
+ // its token via getToken(), so resolving PROJECT_ID here (once,
254
+ // memoized) applies it to ALL subsequent project-scoped calls,
255
+ // including any later re-invocation of login() itself (login() reads
256
+ // the module-level PROJECT_ID at call time, not a stale closure).
257
+ await ensureProjectIdResolved(token);
258
+ }
259
+ return token;
119
260
  }
120
261
 
121
262
  // --- API Helpers ---
@@ -160,6 +301,59 @@ async function apiPost<T>(
160
301
  }
161
302
  }
162
303
 
304
+ /**
305
+ * Resolves a projectId given an absolute root path via
306
+ * GET /api/v1/projects/resolve (excluded from X-Project-ID validation —
307
+ * project-agnostic by design, requires only a Bearer token). Fail-open:
308
+ * returns null on 404 (unregistered rootPath), network error, or any other
309
+ * non-2xx response — reuses apiGet's fail-open contract, never throws.
310
+ */
311
+ export async function resolveProjectIdByPath(
312
+ rootPath: string,
313
+ token: string
314
+ ): Promise<string | null> {
315
+ const resp = await apiGet<{ projectId: string }>(
316
+ `/api/v1/projects/resolve?rootPath=${encodeURIComponent(rootPath)}`,
317
+ token
318
+ );
319
+ return resp?.projectId ?? null;
320
+ }
321
+
322
+ /** Minimal shape of a registered project needed by ⑤-B / ⑤-C consumers. */
323
+ export interface RegisteredProjectSummary {
324
+ id: string;
325
+ name: string;
326
+ rootPath: string;
327
+ }
328
+
329
+ /**
330
+ * Lists every registered project (Postgres SoT) via GET /api/v1/projects —
331
+ * the SAME `ProjectRegistryService` domain path ResolveProjectByPathUseCase
332
+ * (C2 `/resolve`) and ListProjectsUseCase both read from (RFC-040 TD-204
333
+ * ⑤-B). Two consumers:
334
+ * - additional-directories-sync.ts (⑤-B): needs every registered rootPath
335
+ * to union-merge into settings.json's additionalDirectories.
336
+ * - session-start.ts (⑤-C): needs {id, name} pairs to list as
337
+ * AskUserQuestion options in the 2-phase selection prompt.
338
+ *
339
+ * Fail-open (mirrors every other api-client query): returns [] on missing
340
+ * token, non-2xx, network error, or a non-array response body — never
341
+ * throws, never blocks the caller.
342
+ */
343
+ export async function listRegisteredProjects(): Promise<RegisteredProjectSummary[]> {
344
+ const token = await getToken();
345
+ if (!token) return [];
346
+
347
+ const resp = await apiGet<{ id: string; name: string; rootPath: string }[]>(
348
+ '/api/v1/projects',
349
+ token
350
+ );
351
+
352
+ if (!Array.isArray(resp)) return [];
353
+
354
+ return resp.map((p) => ({ id: p.id, name: p.name, rootPath: p.rootPath }));
355
+ }
356
+
163
357
  // --- Workflow Queries ---
164
358
 
165
359
  export async function getActiveWorkflow(): Promise<WorkflowStatus | null> {
@@ -402,6 +596,165 @@ export function consumeInvocationState(workflowId: string, agentType: string): b
402
596
  }
403
597
  }
404
598
 
599
+ /**
600
+ * Lists all live (not-yet-consumed) invocation state payloads recorded for a
601
+ * given workflowId, across every agentType file (TD-203).
602
+ *
603
+ * subagent-start.ts writes one state file per <workflowId>-<agentType> pair
604
+ * (buildInvocationStatePath). A caller that does not know a-priori which
605
+ * agentType(s) were dispatched — e.g. dangling-guard.ts's Stop hook, which
606
+ * fires on the MAIN session's turn end and has no direct knowledge of which
607
+ * background sub-agent(s) are in-flight for the active workflow — needs to
608
+ * discover ALL live invocations for the workflow, not just one agentType.
609
+ *
610
+ * workflowId is a UUID (fixed 36-char format), so no workflowId is ever a
611
+ * string-prefix of another — the `${workflowId}-` prefix match is
612
+ * unambiguous, matching buildInvocationStatePath's own naming convention.
613
+ *
614
+ * Fail-open: returns [] when the invocations directory does not exist (no
615
+ * invocation has ever been recorded) or cannot be read. Individual malformed
616
+ * files are skipped (not thrown), matching readInvocationState's contract.
617
+ */
618
+ export function listInvocationStatesForWorkflow(workflowId: string): InvocationStatePayload[] {
619
+ let files: string[];
620
+ try {
621
+ files = readdirSync(INVOCATIONS_DIR);
622
+ } catch {
623
+ return [];
624
+ }
625
+
626
+ const prefix = `${workflowId}-`;
627
+ const results: InvocationStatePayload[] = [];
628
+ for (const file of files) {
629
+ if (!file.startsWith(prefix) || !file.endsWith('.json')) continue;
630
+ try {
631
+ const raw = readFileSync(join(INVOCATIONS_DIR, file), 'utf-8');
632
+ const data = JSON.parse(raw) as Record<string, unknown>;
633
+ if (
634
+ typeof data.invocationId === 'string' &&
635
+ typeof data.startedAt === 'string' &&
636
+ typeof data.agentType === 'string' &&
637
+ typeof data.workflowId === 'string'
638
+ ) {
639
+ results.push({
640
+ invocationId: data.invocationId,
641
+ startedAt: data.startedAt,
642
+ agentType: data.agentType,
643
+ workflowId: data.workflowId,
644
+ });
645
+ }
646
+ } catch {
647
+ // skip malformed file — fail-open, matches readInvocationState contract
648
+ }
649
+ }
650
+ return results;
651
+ }
652
+
653
+ // --- Dispatch Intent (TD-205 bg-dispatch race) ---
654
+
655
+ /** Payload written to the per-workflow dispatch-intent marker file. */
656
+ export interface DispatchIntentPayload {
657
+ workflowId: string;
658
+ stampedAt: string;
659
+ }
660
+
661
+ const DISPATCH_INTENT_DIR = join(STATE_DIR, 'dispatch-intent');
662
+
663
+ /**
664
+ * Grace window for a dispatch-intent marker: SECONDS, not the 4h
665
+ * INVOCATION_STATE_TTL_MS staleness definition used elsewhere. Sized to
666
+ * comfortably exceed subagent-start.ts's own SubagentStart hook timeout
667
+ * (10_000ms, settings.json) plus scheduling slack between the sub-agent
668
+ * spawning and that hook actually running, while staying far short of a
669
+ * genuinely-dangling session (minutes/hours). A marker older than this is
670
+ * NOT treated as covering the race — it falls through to dangling-guard's
671
+ * rule 9 zombie safety net, so this window can never become a permanent
672
+ * hole (TD-205).
673
+ */
674
+ const DISPATCH_INTENT_GRACE_WINDOW_MS = 30_000;
675
+
676
+ /** Returns the per-workflow dispatch-intent marker path: <stateDir>/dispatch-intent/<workflowId>.json */
677
+ export function buildDispatchIntentPath(stateDir: string, workflowId: string): string {
678
+ return join(stateDir, 'dispatch-intent', `${workflowId}.json`);
679
+ }
680
+
681
+ /**
682
+ * Stamps a dispatch-intent marker for workflowId. Called SYNCHRONOUSLY by
683
+ * pre-agent-dispatch.ts (PreToolUse[Agent] — fires before the sub-agent
684
+ * spawns / before the turn yields) so the marker is guaranteed to exist by
685
+ * the time a background-dispatch Stop hook could race ahead of
686
+ * subagent-start.ts's async startInvocation()+writeInvocationState().
687
+ * Fail-open: returns false on any filesystem error.
688
+ */
689
+ export function writeDispatchIntent(workflowId: string): boolean {
690
+ try {
691
+ mkdirSync(DISPATCH_INTENT_DIR, { recursive: true });
692
+ const path = buildDispatchIntentPath(STATE_DIR, workflowId);
693
+ const payload: DispatchIntentPayload = { workflowId, stampedAt: new Date().toISOString() };
694
+ writeFileSync(path, JSON.stringify(payload), 'utf-8');
695
+ return true;
696
+ } catch {
697
+ return false;
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Reads the dispatch-intent marker for workflowId. Returns null when absent,
703
+ * malformed, or unreadable (fail-open).
704
+ */
705
+ export function readDispatchIntent(workflowId: string): DispatchIntentPayload | null {
706
+ try {
707
+ const path = buildDispatchIntentPath(STATE_DIR, workflowId);
708
+ const raw = readFileSync(path, 'utf-8');
709
+ const data = JSON.parse(raw) as Record<string, unknown>;
710
+ if (typeof data.workflowId !== 'string' || typeof data.stampedAt !== 'string') {
711
+ return null;
712
+ }
713
+ return { workflowId: data.workflowId, stampedAt: data.stampedAt };
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Returns true when workflowId has a dispatch-intent marker stamped within
721
+ * the short grace window (TD-205). This is the bg-dispatch-race signal
722
+ * consumed by dangling-guard.ts's rule 6 — deliberately bounded (seconds,
723
+ * NOT the 4h INVOCATION_STATE_TTL_MS) so a genuinely-dead agent whose marker
724
+ * outlives the window still falls through to the rule-9 safety net.
725
+ * Malformed/missing stampedAt is treated as NOT fresh (fail-safe — same
726
+ * posture as isInvocationStateStale's malformed-timestamp handling).
727
+ */
728
+ export function hasFreshDispatchIntent(
729
+ workflowId: string,
730
+ nowMs: number = Date.now(),
731
+ graceWindowMs: number = DISPATCH_INTENT_GRACE_WINDOW_MS
732
+ ): boolean {
733
+ const marker = readDispatchIntent(workflowId);
734
+ if (!marker) return false;
735
+ const stampedAtMs = new Date(marker.stampedAt).getTime();
736
+ if (isNaN(stampedAtMs)) return false;
737
+ return nowMs - stampedAtMs <= graceWindowMs;
738
+ }
739
+
740
+ /**
741
+ * Deletes the per-workflow dispatch-intent marker file. Not currently called
742
+ * from production wiring (the grace window is short enough to self-expire
743
+ * without explicit clearing — see DISPATCH_INTENT_GRACE_WINDOW_MS doc);
744
+ * exported for symmetry with consumeInvocationState and for test cleanup.
745
+ * Returns true if deleted, false if already gone.
746
+ */
747
+ export function consumeDispatchIntent(workflowId: string): boolean {
748
+ try {
749
+ const path = buildDispatchIntentPath(STATE_DIR, workflowId);
750
+ readFileSync(path); // throws if missing — fast existence check
751
+ unlinkSync(path);
752
+ return true;
753
+ } catch {
754
+ return false;
755
+ }
756
+ }
757
+
405
758
  /**
406
759
  * Start tracking an agent invocation.
407
760
  * Returns the invocationId string on success, or null on any failure (fail-open).
@@ -502,6 +855,34 @@ export async function registerCheckpoint(
502
855
  });
503
856
  }
504
857
 
858
+ // --- Session Allocation (RFC-040 Operator Multi-Project, Slice 2C/C9, S358) ---
859
+
860
+ /** Response shape from POST /api/v1/sessions/allocate */
861
+ export interface AllocateSessionResult {
862
+ number: number;
863
+ sessionId: string;
864
+ }
865
+
866
+ /**
867
+ * Allocates a new, globally-numbered session for a project via
868
+ * POST /api/v1/sessions/allocate. Requires the hook's `system`-tier
869
+ * identity (RFC-040 ADR-002, TASK-RBAC-02) — an `admin` identity would be
870
+ * rejected (403) by the endpoint's rbacSystem guard.
871
+ *
872
+ * Fail-open, mirrors registerCheckpoint's shape: returns null on any
873
+ * failure (no token, network error, non-2xx response) so the hook never
874
+ * blocks the session on this call.
875
+ */
876
+ export async function allocateSessionNumber(
877
+ projectId: string
878
+ ): Promise<AllocateSessionResult | null> {
879
+ const token = await getToken();
880
+ if (!token) return null;
881
+ return apiPost<AllocateSessionResult>(`/api/v1/sessions/allocate`, token, {
882
+ projectId,
883
+ });
884
+ }
885
+
505
886
  // --- Telemetry ---
506
887
 
507
888
  /**
@@ -0,0 +1,81 @@
1
+ /**
2
+ * session-selection.ts — RFC-040 TD-204 ⑤-C.
3
+ *
4
+ * Persists a session's explicit project SELECTION — the dormant precedence
5
+ * tier in session-start.ts's `resolveSessionProjectBinding` (TD-204 ⑥-c):
6
+ *
7
+ * env-override (pin) > selection > cwd-hint > none
8
+ *
9
+ * Phase 1 of the 2-phase session-start flow (feature-flagged, default OFF)
10
+ * prompts the operator to choose a project when no binding resolves. Once
11
+ * chosen, the choice is written here, keyed by Claude Code's `session_id`,
12
+ * so a later SessionStart re-fire (matcher `startup|resume`) can read it
13
+ * back as the `selection` tier and inject the scoped [ORCHESTRATOR-DATA]
14
+ * ("phase 2"). NOTE: the interactive consumer that CALLS
15
+ * writeSessionSelection() after the operator answers (an MCP tool or
16
+ * slash command) is explicitly OUT of Phase I scope — see the
17
+ * implementation report. This module ships the read/write primitives and
18
+ * session-start.ts's READ-side wiring only.
19
+ *
20
+ * Class C (context/telemetry) hook classification (TD-204 §31-34): every
21
+ * function here degrades benignly — never throws, fails open to
22
+ * `undefined` on any error. No hardcoded `~/.claude` path: `stateDir` is
23
+ * always an explicit parameter (installer-compatible, same discipline as
24
+ * additional-directories-sync.ts).
25
+ */
26
+
27
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ export interface SessionSelectionRecord {
31
+ projectId: string;
32
+ selectedAt: string;
33
+ }
34
+
35
+ /** Sanitizes sessionId into a safe file-name fragment — no path traversal. */
36
+ function selectionFilePath(stateDir: string, sessionId: string): string {
37
+ const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
38
+ return join(stateDir, `session-selection-${safeId}.json`);
39
+ }
40
+
41
+ /**
42
+ * Reads the persisted project selection for `sessionId`, or `undefined`
43
+ * when none exists / the file is malformed / any fs error occurs (Class C
44
+ * benign degrade — never throws).
45
+ */
46
+ export function readSessionSelection(stateDir: string, sessionId: string): string | undefined {
47
+ if (sessionId === '') return undefined;
48
+ try {
49
+ const filePath = selectionFilePath(stateDir, sessionId);
50
+ if (!existsSync(filePath)) return undefined;
51
+ const raw = readFileSync(filePath, 'utf-8');
52
+ const record = JSON.parse(raw) as Partial<SessionSelectionRecord>;
53
+ return typeof record.projectId === 'string' && record.projectId !== ''
54
+ ? record.projectId
55
+ : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Persists `projectId` as the selection for `sessionId`. No-op (never
63
+ * throws) when either argument is empty, or on any fs failure (Class C
64
+ * benign degrade — a failed selection write must never crash the caller
65
+ * that just answered an AskUserQuestion prompt).
66
+ */
67
+ export function writeSessionSelection(
68
+ stateDir: string,
69
+ sessionId: string,
70
+ projectId: string
71
+ ): void {
72
+ if (sessionId === '' || projectId === '') return;
73
+ try {
74
+ mkdirSync(stateDir, { recursive: true });
75
+ const filePath = selectionFilePath(stateDir, sessionId);
76
+ const record: SessionSelectionRecord = { projectId, selectedAt: new Date().toISOString() };
77
+ writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf-8');
78
+ } catch {
79
+ // Class C benign degrade — never throws.
80
+ }
81
+ }