@orchestrator-claude/definitions 3.32.1 → 3.33.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.
- package/README.md +113 -0
- package/agents/debug-sidecar.md +1 -1
- package/agents/doc-sidecar.md +1 -1
- package/agents/test-sidecar.md +1 -1
- package/dist/cli.js +78 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/DiffCommand.js +1 -1
- package/dist/commands/DiffCommand.js.map +1 -1
- package/dist/commands/InstallCommand.d.ts +41 -0
- package/dist/commands/InstallCommand.d.ts.map +1 -0
- package/dist/commands/InstallCommand.js +253 -0
- package/dist/commands/InstallCommand.js.map +1 -0
- package/dist/http/ApiClient.d.ts +5 -0
- package/dist/http/ApiClient.d.ts.map +1 -1
- package/dist/http/ApiClient.js.map +1 -1
- package/dist/install/DevCheckoutDetector.d.ts +66 -0
- package/dist/install/DevCheckoutDetector.d.ts.map +1 -0
- package/dist/install/DevCheckoutDetector.js +150 -0
- package/dist/install/DevCheckoutDetector.js.map +1 -0
- package/dist/install/HooksWirer.d.ts +29 -0
- package/dist/install/HooksWirer.d.ts.map +1 -0
- package/dist/install/HooksWirer.js +80 -0
- package/dist/install/HooksWirer.js.map +1 -0
- package/dist/install/LocalDiffReporter.d.ts +25 -0
- package/dist/install/LocalDiffReporter.d.ts.map +1 -0
- package/dist/install/LocalDiffReporter.js +50 -0
- package/dist/install/LocalDiffReporter.js.map +1 -0
- package/dist/install/Materializer.d.ts +67 -0
- package/dist/install/Materializer.d.ts.map +1 -0
- package/dist/install/Materializer.js +196 -0
- package/dist/install/Materializer.js.map +1 -0
- package/dist/install/McpRegistrar.d.ts +63 -0
- package/dist/install/McpRegistrar.d.ts.map +1 -0
- package/dist/install/McpRegistrar.js +114 -0
- package/dist/install/McpRegistrar.js.map +1 -0
- package/dist/install/ProcessRunner.d.ts +25 -0
- package/dist/install/ProcessRunner.d.ts.map +1 -0
- package/dist/install/ProcessRunner.js +41 -0
- package/dist/install/ProcessRunner.js.map +1 -0
- package/dist/install/ProjectSettingsNeutralizer.d.ts +39 -0
- package/dist/install/ProjectSettingsNeutralizer.d.ts.map +1 -0
- package/dist/install/ProjectSettingsNeutralizer.js +100 -0
- package/dist/install/ProjectSettingsNeutralizer.js.map +1 -0
- package/dist/install/types.d.ts +52 -0
- package/dist/install/types.d.ts.map +1 -0
- package/dist/install/types.js +8 -0
- package/dist/install/types.js.map +1 -0
- package/dist/manifest/ManifestLoader.d.ts.map +1 -1
- package/dist/manifest/ManifestLoader.js +3 -2
- package/dist/manifest/ManifestLoader.js.map +1 -1
- package/dist/manifest/types.d.ts +3 -0
- package/dist/manifest/types.d.ts.map +1 -1
- package/dist/scripts/bundle-hooks.d.ts +39 -0
- package/dist/scripts/bundle-hooks.d.ts.map +1 -0
- package/dist/scripts/bundle-hooks.js +165 -0
- package/dist/scripts/bundle-hooks.js.map +1 -0
- package/dist/scripts/generate-manifest.d.ts +22 -3
- package/dist/scripts/generate-manifest.d.ts.map +1 -1
- package/dist/scripts/generate-manifest.js +78 -35
- package/dist/scripts/generate-manifest.js.map +1 -1
- package/hooks/dangling-guard.mjs +7426 -0
- package/hooks/dangling-guard.ts +223 -6
- package/hooks/gate-guardian.mjs +463 -0
- package/hooks/lib/additional-directories-sync.ts +295 -0
- package/hooks/lib/api-client.ts +388 -7
- package/hooks/lib/session-selection.ts +81 -0
- package/hooks/mailbox-listener.mjs +259 -0
- package/hooks/post-compact.mjs +7931 -0
- package/hooks/post-implement.mjs +238 -0
- package/hooks/session-start.mjs +8234 -0
- package/hooks/session-start.ts +264 -23
- package/hooks/sprint-registry.mjs +59 -0
- package/hooks/subagent-start.mjs +289 -0
- package/hooks/subagent-stop.mjs +7793 -0
- package/hooks/subagent-stop.ts +4 -2
- package/hooks/user-prompt.mjs +329 -0
- package/hooks/workflow-guard.mjs +7805 -0
- package/hooks/workflow-guard.ts +360 -119
- package/hooks-settings-template.json +63 -0
- package/manifest.json +54 -68
- package/mcp-servers.json +48 -0
- package/package.json +10 -4
- package/personas/auditor-compliance.md +104 -0
- package/personas/cfo-conservador.md +88 -0
- package/personas/designer-perfeccionista.md +104 -0
- package/personas/diretor-cetico-fmcg.md +80 -0
- package/personas/engenheiro-cetico-construcao.md +107 -0
package/hooks/lib/api-client.ts
CHANGED
|
@@ -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
|
-
'
|
|
31
|
+
'system@orchestrator.local';
|
|
27
32
|
const AUTH_PASSWORD =
|
|
28
|
-
process.env.ORCHESTRATOR_ADMIN_PASSWORD || process.env.ORCHESTRATOR_AUTH_PASSWORD || '
|
|
29
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|