@orchestrator-claude/definitions 3.44.0 → 3.44.1

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 (52) hide show
  1. package/agents/adversarial-reviewer.md +35 -29
  2. package/agents/agentic-architect.md +1 -1
  3. package/agents/api-extractor.md +1 -1
  4. package/agents/architecture-mediator.md +1 -1
  5. package/agents/azure-devops-engineer.md +1 -1
  6. package/agents/backend-architect.md +1 -1
  7. package/agents/business-rule-miner.md +1 -1
  8. package/agents/cicd-platform-engineer.md +1 -1
  9. package/agents/ciclo-engineer.md +1 -1
  10. package/agents/code-archaeologist.md +1 -1
  11. package/agents/contract-architect.md +1 -1
  12. package/agents/datamodel-architect.md +1 -1
  13. package/agents/frontend-architect.md +1 -1
  14. package/agents/frontend-engineer.md +1 -1
  15. package/agents/harness-architect.md +1 -1
  16. package/agents/harness-engineer.md +1 -1
  17. package/agents/implementer.md +1 -1
  18. package/agents/infra-architect.md +1 -1
  19. package/agents/legacy-discoverer.md +1 -1
  20. package/agents/legacy-synthesizer.md +1 -1
  21. package/agents/methodology-researcher.md +2 -1
  22. package/agents/operational-artifact-builder.md +1 -1
  23. package/agents/planner.md +1 -1
  24. package/agents/presentation-engineer.md +1 -1
  25. package/agents/python-data-engineer.md +1 -1
  26. package/agents/researcher.md +1 -1
  27. package/agents/reviewer.md +1 -1
  28. package/agents/schema-extractor.md +1 -1
  29. package/agents/specifier.md +1 -1
  30. package/agents/task-generator.md +1 -1
  31. package/hooks/agent-git-guard.mjs +0 -2
  32. package/hooks/dangling-guard.mjs +18 -3
  33. package/hooks/gate-guardian.mjs +8244 -20
  34. package/hooks/gate-guardian.ts +154 -24
  35. package/hooks/lib/api-client.ts +30 -7
  36. package/hooks/lib/artifact-gate.ts +518 -0
  37. package/hooks/lib/artifact-producers.ts +106 -0
  38. package/hooks/lib/workflow-artifact-client.ts +351 -0
  39. package/hooks/mailbox-listener.mjs +0 -2
  40. package/hooks/playwright-output-guard.mjs +0 -2
  41. package/hooks/post-implement.mjs +18 -3
  42. package/hooks/pre-agent-dispatch.mjs +18 -3
  43. package/hooks/security-gate-honest.mjs +0 -2
  44. package/hooks/session-start.mjs +18 -3
  45. package/hooks/subagent-start.mjs +18 -3
  46. package/hooks/subagent-stop.mjs +18 -3
  47. package/hooks/user-prompt.mjs +18 -3
  48. package/hooks/verification-before-completion.mjs +0 -2
  49. package/hooks/workflow-guard.mjs +504 -29
  50. package/hooks/workflow-guard.ts +370 -27
  51. package/manifest.json +46 -46
  52. package/package.json +1 -1
@@ -17,6 +17,32 @@
17
17
  * foreign (trc/red-bull) cwd, where they become load-bearing: a Classe-A
18
18
  * gate NEVER no-ops silently on an unrecognized project path.
19
19
  *
20
+ * R7 (RFC-044 F3, S4) — evaluated FIRST, before rule 1, so SKIP_WORKFLOW_GUARD
21
+ * does not reach it (Q-PM-5). A Write/Edit whose resolved path lies under
22
+ * project-guidelines/workflows/ of a MANAGED project (registered, not
23
+ * `.unmanaged`, DV-P10) is allowed only when: the caller is a sub-agent whose
24
+ * normalized agent_type is in ARTIFACT_PRODUCER_AGENTS; the path is
25
+ * workflows/<wf>/<phase>/<type>.md (parseVaultPath, the one path function);
26
+ * and probeWorkflow(<wf of the path>) answers an ACTIVE workflow whose
27
+ * currentPhase is <phase>. Anything else is a DENY with an R7-* reason; the
28
+ * network failures carry the "API fora" runbook. The network is consulted ONLY
29
+ * for such a path (the 2-pass below is preserved for every other path), and an
30
+ * exception in the R7 branch is a DENY, never the fail-open main().catch.
31
+ *
32
+ * R8 (RFC-044 F3, S6c) — evaluated before R7, pure (no network). For a caller
33
+ * whose normalized agent_type is in VAULT_ONLY_WRITERS (agent_id plays no
34
+ * part; without agent_type R8 does not apply), the decision comes ENTIRELY
35
+ * from R8 + R7, never from rules 1-7:
36
+ * - a workflows/ path of a managed project: an artifact type outside the
37
+ * agent's pinned set is DENY R8-TYPE (no probe); otherwise R7 decides;
38
+ * - its OWN memory dir <targetProjectRoot>/.claude/agent-memory/<slug>/
39
+ * (resolved path, prefix with the trailing separator): allowed, silent
40
+ * as before (R8-MEMORY in the log);
41
+ * - anything else (src/, docs, another project, no project, `.unmanaged`,
42
+ * SKIP_WORKFLOW_GUARD on): DENY R8-OUTSIDE-VAULT;
43
+ * - an exception while deciding for such an agent: DENY R8-EXCEPTION.
44
+ * Edit decides like Write (R8 does not read tool_name).
45
+ *
20
46
  * Allow paths (in evaluation order — see decideWorkflowGuardAction):
21
47
  * 1. SKIP_WORKFLOW_GUARD=true (explicit bypass, all paths)
22
48
  * 2. Non-guarded path (file outside src/ or tests/)
@@ -35,19 +61,27 @@
35
61
  * - No active workflow (target project IS registered)
36
62
  */
37
63
 
38
- import { dirname, join } from 'node:path';
64
+ import { dirname, join, resolve as resolvePath, sep as PATH_SEP } from 'node:path';
39
65
  import { existsSync } from 'node:fs';
66
+ import {
67
+ parseVaultPath,
68
+ WORKFLOW_ARTIFACT_ROOT,
69
+ } from '@orchestrator-claude/vault-projection/workflow-artifact';
40
70
  import {
41
71
  log,
42
72
  readStdin,
43
73
  getField,
44
74
  getActiveWorkflow,
45
75
  getWorkflowMode,
76
+ normalizeAgentName,
46
77
  outputPreToolUse,
47
78
  resolveAgentMemoryBaseDir,
48
79
  resolveHookProjectRoot,
80
+ type WorkflowStatus,
49
81
  } from './lib/api-client.js';
50
82
  import { appendObservation } from './lib/observations-helper.js';
83
+ import { ARTIFACT_PRODUCER_AGENTS, VAULT_ONLY_WRITERS } from './lib/artifact-producers.js';
84
+ import { probeWorkflow, type ProbeResult } from './lib/workflow-artifact-client.js';
51
85
 
52
86
  const HOOK = 'WORKFLOW-GUARD';
53
87
 
@@ -84,12 +118,17 @@ export interface WorkflowGuardDecision {
84
118
  * `matchedRule === 7` as the sole signal for whether the (network) active
85
119
  * workflow must actually be fetched to confirm the decision — see main().
86
120
  */
87
- matchedRule: 1 | 2 | 3 | 4 | 5 | 6 | 7;
121
+ matchedRule: 'R8' | 'R7' | 1 | 2 | 3 | 4 | 5 | 6 | 7;
88
122
  /** Whether outputPreToolUse must be called. Rules 2/3 are silent pass-throughs (pre-existing behavior — no hookSpecificOutput emitted). */
89
123
  emitOutput: boolean;
90
124
  output?: WorkflowGuardOutput;
91
125
  logMessages: string[];
92
126
  observation?: WorkflowGuardObservation;
127
+ /**
128
+ * R7 only: set when the decision still needs probeWorkflow(<wf of the path>). The decision that carries it
129
+ * is already a DENY (R7-PROBE-PENDING), so a caller that forgets to probe fails CLOSED.
130
+ */
131
+ r7NeedsProbe?: { workflowId: string; phase: string };
93
132
  }
94
133
 
95
134
  export interface WorkflowGuardWorkflowInput {
@@ -128,6 +167,237 @@ export interface WorkflowGuardDecisionInput {
128
167
  agentId: string;
129
168
  /** getField(stdin, 'agent_type') — empty string when absent. */
130
169
  agentType: string;
170
+ /** R7: probeWorkflow(<wf of the path>), filled by main() only when a previous decision asked for it. */
171
+ r7Probe?: ProbeResult;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // R7 (RFC-044 F3, S4): workflow artifacts on disk — pure part
176
+ // ---------------------------------------------------------------------------
177
+
178
+ const WORKFLOWS_SEGMENT = `/${WORKFLOW_ARTIFACT_ROOT}/`;
179
+ /** The house definition of "active" (api-client.ts getActiveWorkflow). */
180
+ const ACTIVE_WORKFLOW_STATUSES: ReadonlySet<string> = new Set([
181
+ 'in_progress',
182
+ 'awaiting_agent',
183
+ 'awaiting_approval',
184
+ ]);
185
+
186
+ /** Runbook "API fora" (plan sec. 9), short; the full text lives in the plan and in the S7 amendment. */
187
+ export const R7_RUNBOOK = [
188
+ 'Runbook "API fora" (RFC-044 F3):',
189
+ ' 1. curl -sS -m 3 "${ORCHESTRATOR_API_URL:-http://localhost:3001}/api/v1/health" (200 = API up)',
190
+ ' 2. 200 but still denied (R7-NO-TOKEN, or cause no-token): check ORCHESTRATOR_ADMIN_EMAIL, ORCHESTRATOR_ADMIN_PASSWORD, ORCHESTRATOR_PROJECT_ID',
191
+ ' 3. no answer: bring the API back up (the project container/service); the hook retries on the next Write',
192
+ 'SKIP_WORKFLOW_GUARD nao se aplica a R7, nem .orchestrator/.unmanaged; nao edite hooks.json nem os .mjs.',
193
+ ].join('\n');
194
+
195
+ export type R7PathMatch =
196
+ | { kind: 'none' }
197
+ | { kind: 'shape'; rel: string }
198
+ | { kind: 'key'; rel: string; workflowId: string; phase: string; artifactType: string };
199
+
200
+ /**
201
+ * Does the RESOLVED path lie under .../project-guidelines/workflows/ (any prefix)? Resolution first:
202
+ * `workflows/../specs/x.md` and `workflows.bak/...` do not match; `//` is normalized. A match is then
203
+ * either a well-formed workflows/<wf>/<phase>/<type>.md (parseVaultPath) or a shape violation.
204
+ */
205
+ export function matchR7Path(filePath: string): R7PathMatch {
206
+ if (filePath.length === 0) return { kind: 'none' };
207
+ const resolved = resolvePath(filePath);
208
+ const at = resolved.indexOf(WORKFLOWS_SEGMENT);
209
+ if (at === -1) return { kind: 'none' };
210
+ const rel = resolved.slice(at + 1);
211
+ if (filePath.endsWith('/')) return { kind: 'shape', rel };
212
+ const key = parseVaultPath(rel);
213
+ return key === null ? { kind: 'shape', rel } : { kind: 'key', rel, ...key };
214
+ }
215
+
216
+ function r7Deny(
217
+ label: string,
218
+ message: string,
219
+ logLine: string,
220
+ extra: Partial<WorkflowGuardDecision> = {}
221
+ ): WorkflowGuardDecision {
222
+ return {
223
+ matchedRule: 'R7',
224
+ emitOutput: true,
225
+ output: {
226
+ decision: 'deny',
227
+ reason: `Workflow Guard ${label}: ${message}`,
228
+ context:
229
+ 'R7 (RFC-044 F3): project-guidelines/workflows/<wf>/<phase>/<type>.md is written only by an artifact-producing sub-agent, while <wf> is active and in <phase>.',
230
+ },
231
+ logMessages: [`DENY (${label}) ${logLine}`],
232
+ ...extra,
233
+ };
234
+ }
235
+
236
+ function r7FromProbe(
237
+ probe: ProbeResult,
238
+ target: { rel: string; workflowId: string; phase: string },
239
+ agent: string
240
+ ): WorkflowGuardDecision {
241
+ const where = `file=${target.rel} agent=${agent}`;
242
+ if (probe.kind === 'not-found') {
243
+ return r7Deny(
244
+ 'R7-WORKFLOW-NOT-FOUND',
245
+ `workflow ${target.workflowId} (from the path) does not exist in this project.`,
246
+ where
247
+ );
248
+ }
249
+ if (probe.kind === 'unavailable') {
250
+ // Only a token the API REJECTED proves the API is up. `no-token` is also what a DOWN API gives (login
251
+ // fails before any probe; measured S557 against a closed port), so it stays under API-UNAVAILABLE.
252
+ const label = probe.cause === 'unauthorized' ? 'R7-NO-TOKEN' : 'R7-API-UNAVAILABLE';
253
+ return r7Deny(
254
+ label,
255
+ `could not confirm workflow ${target.workflowId} with the API (cause: ${probe.cause}${probe.httpStatus === undefined ? '' : `, HTTP ${String(probe.httpStatus)}`}).\n${R7_RUNBOOK}`,
256
+ `${where} cause=${probe.cause}`
257
+ );
258
+ }
259
+ if (!ACTIVE_WORKFLOW_STATUSES.has(probe.status)) {
260
+ return r7Deny(
261
+ 'R7-WORKFLOW-NOT-ACTIVE',
262
+ `workflow ${target.workflowId} is ${probe.status}, not active.`,
263
+ where
264
+ );
265
+ }
266
+ if ((probe.currentPhase ?? '').toLowerCase() !== target.phase) {
267
+ return r7Deny(
268
+ 'R7-PHASE-NOT-CURRENT',
269
+ `workflow ${target.workflowId} is in phase ${String(probe.currentPhase)}, not ${target.phase}.`,
270
+ where
271
+ );
272
+ }
273
+ return {
274
+ matchedRule: 'R7',
275
+ emitOutput: true,
276
+ output: {
277
+ decision: 'allow',
278
+ context: `R7: ${agent} writes ${target.rel} (workflow ${target.workflowId} active, phase ${target.phase}).`,
279
+ },
280
+ logMessages: [`ALLOW (R7) ${where}`],
281
+ };
282
+ }
283
+
284
+ /** R7, pure. null = R7 does not apply (not a workflows/ path, or not a managed project): current rules. */
285
+ function decideR7(input: WorkflowGuardDecisionInput): WorkflowGuardDecision | null {
286
+ const match = matchR7Path(input.filePath);
287
+ if (match.kind === 'none') return null;
288
+ if (input.targetProjectRoot === null || input.isUnmanaged) return null;
289
+ return decideR7ForMatch(input, match);
290
+ }
291
+
292
+ /** R7 on a workflows/ path of a managed project (the caller has checked both). */
293
+ function decideR7ForMatch(
294
+ input: WorkflowGuardDecisionInput,
295
+ match: Exclude<R7PathMatch, { kind: 'none' }>
296
+ ): WorkflowGuardDecision {
297
+ const agent = input.agentType ? normalizeAgentName(input.agentType) : '';
298
+ if (agent === '') {
299
+ return r7Deny(
300
+ 'R7-MAIN-AGENT',
301
+ `${match.rel} is written only by an artifact-producing sub-agent, never by the main agent (Q-PM-5).`,
302
+ `file=${match.rel}`
303
+ );
304
+ }
305
+ if (!ARTIFACT_PRODUCER_AGENTS.has(agent)) {
306
+ return r7Deny(
307
+ 'R7-NOT-PRODUCER',
308
+ `agent ${agent} is not an artifact producer (ARTIFACT_PRODUCER_AGENTS).`,
309
+ `file=${match.rel} agent=${agent}`
310
+ );
311
+ }
312
+ if (match.kind === 'shape') {
313
+ return r7Deny(
314
+ 'R7-SHAPE',
315
+ `${match.rel} is not project-guidelines/workflows/<workflow_id>/<phase>/<artifact_type>.md.`,
316
+ `file=${match.rel} agent=${agent}`
317
+ );
318
+ }
319
+ const target = { rel: match.rel, workflowId: match.workflowId, phase: match.phase };
320
+ if (input.r7Probe === undefined) {
321
+ return r7Deny('R7-PROBE-PENDING', 'workflow not confirmed yet.', `file=${match.rel}`, {
322
+ r7NeedsProbe: { workflowId: match.workflowId, phase: match.phase },
323
+ });
324
+ }
325
+ return r7FromProbe(input.r7Probe, target, agent);
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // R8 (RFC-044 F3, S6c): the Write of a VAULT_ONLY_WRITERS agent — pure
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /** The fenced agent's bare name, or null when R8 does not apply (no agent_type, or an agent outside the set). */
333
+ function vaultOnlyWriter(agentType: string): string | null {
334
+ if (agentType === '') return null;
335
+ const agent = normalizeAgentName(agentType);
336
+ return VAULT_ONLY_WRITERS.has(agent) ? agent : null;
337
+ }
338
+
339
+ const R8_CONTEXT =
340
+ 'R8 (RFC-044 F3, S6c): this agent writes only (1) its artifact project-guidelines/workflows/<wf>/<phase>/<type>.md of a managed project, under R7, and (2) its own memory under .claude/agent-memory/<slug>/. SKIP_WORKFLOW_GUARD does not apply.';
341
+
342
+ function r8Deny(label: string, message: string, logLine: string): WorkflowGuardDecision {
343
+ return {
344
+ matchedRule: 'R8',
345
+ emitOutput: true,
346
+ output: {
347
+ decision: 'deny',
348
+ reason: `Workflow Guard ${label}: ${message}`,
349
+ context: R8_CONTEXT,
350
+ },
351
+ logMessages: [`DENY (${label}) ${logLine}`],
352
+ };
353
+ }
354
+
355
+ /** Is the RESOLVED path inside <root>/.claude/agent-memory/<agent>/ (the separator keeps `<agent>-x/` out)? */
356
+ function isOwnMemory(filePath: string, root: string, agent: string): boolean {
357
+ const dir = join(root, '.claude', 'agent-memory', agent) + PATH_SEP;
358
+ return resolvePath(filePath).startsWith(dir);
359
+ }
360
+
361
+ /** R8, pure. null = R8 does not apply (the caller is not a VAULT_ONLY_WRITERS agent): R7 and rules 1-7. */
362
+ function decideR8(input: WorkflowGuardDecisionInput): WorkflowGuardDecision | null {
363
+ const agent = vaultOnlyWriter(input.agentType);
364
+ if (agent === null) return null;
365
+ const where = `file=${input.filePath} agent=${agent}`;
366
+ const match = matchR7Path(input.filePath);
367
+ if (match.kind !== 'none' && input.targetProjectRoot !== null && !input.isUnmanaged) {
368
+ const types = VAULT_ONLY_WRITERS.get(agent) ?? null;
369
+ if (match.kind === 'key' && types !== null && !types.has(match.artifactType)) {
370
+ return r8Deny(
371
+ 'R8-TYPE',
372
+ `agent ${agent} writes only ${[...types].join(', ')} under workflows/, not ${match.artifactType} (R-6: never the artifact under review).`,
373
+ `file=${match.rel} agent=${agent}`
374
+ );
375
+ }
376
+ return decideR7ForMatch(input, match);
377
+ }
378
+ if (
379
+ input.targetProjectRoot !== null &&
380
+ isOwnMemory(input.filePath, input.targetProjectRoot, agent)
381
+ ) {
382
+ return { matchedRule: 'R8', emitOutput: false, logMessages: [`ALLOW (R8-MEMORY) ${where}`] };
383
+ }
384
+ return r8Deny(
385
+ 'R8-OUTSIDE-VAULT',
386
+ `agent ${agent} may not write ${input.filePath}. It writes only project-guidelines/workflows/<wf>/<phase>/<type>.md of a managed project (R7) and its own memory .claude/agent-memory/${agent}/.`,
387
+ where
388
+ );
389
+ }
390
+
391
+ /** An exception while deciding for a VAULT_ONLY_WRITERS agent: a DENY, never main().catch -> exit 0. */
392
+ export function r8ExceptionDecision(error: unknown): WorkflowGuardDecision {
393
+ const message = error instanceof Error ? error.message : String(error);
394
+ return r8Deny('R8-EXCEPTION', `unexpected error (${message}).`, 'exception');
395
+ }
396
+
397
+ /** An exception anywhere in the R7 branch of main(): a DENY, never main().catch -> exit 0 (fail-open). */
398
+ export function r7ExceptionDecision(error: unknown): WorkflowGuardDecision {
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ return r7Deny('R7-EXCEPTION', `unexpected error (${message}).\n${R7_RUNBOOK}`, 'exception');
131
401
  }
132
402
 
133
403
  const CONFIG_DOC_RE = /\.(json|ya?ml|md|env(\..*)?)$/;
@@ -152,6 +422,14 @@ export function decideWorkflowGuardAction(
152
422
  agentType,
153
423
  } = input;
154
424
 
425
+ // R8 (RFC-044 F3, S6c): FIRST — a VAULT_ONLY_WRITERS agent is decided by R8 + R7 only.
426
+ const r8 = decideR8(input);
427
+ if (r8 !== null) return r8;
428
+
429
+ // R7 (RFC-044 F3, S4): BEFORE rule 1 — SKIP_WORKFLOW_GUARD does not reach it.
430
+ const r7 = decideR7(input);
431
+ if (r7 !== null) return r7;
432
+
155
433
  // Rule 1: explicit global bypass
156
434
  if (skipWorkflowGuard) {
157
435
  return {
@@ -340,17 +618,48 @@ export function decideWorkflowGuardAction(
340
618
  };
341
619
  }
342
620
 
343
- async function main(): Promise<void> {
344
- const stdin = await readStdin();
621
+ /** Everything main() reads or writes outside the pure decision; injected by the tests. */
622
+ export interface WorkflowGuardDeps {
623
+ readStdin: () => Promise<Record<string, unknown>>;
624
+ env: Readonly<Record<string, string | undefined>>;
625
+ resolveHookProjectRoot: (dir: string) => string | null;
626
+ isUnmanaged: (projectRoot: string) => boolean;
627
+ getActiveWorkflow: () => Promise<WorkflowStatus | null>;
628
+ getWorkflowMode: (workflowId: string) => Promise<string | null>;
629
+ probeWorkflow: (workflowId: string) => Promise<ProbeResult>;
630
+ output: (output: WorkflowGuardOutput) => void;
631
+ log: (line: string) => void;
632
+ appendObservation: (
633
+ stdin: Record<string, unknown>,
634
+ observation: WorkflowGuardObservation
635
+ ) => void;
636
+ }
637
+
638
+ /** R7 network step: probe the workflow OF THE PATH and decide again; any exception is a DENY. */
639
+ async function decideWithProbe(
640
+ input: WorkflowGuardDecisionInput,
641
+ needs: { workflowId: string },
642
+ deps: WorkflowGuardDeps
643
+ ): Promise<WorkflowGuardDecision> {
644
+ try {
645
+ const r7Probe = await deps.probeWorkflow(needs.workflowId);
646
+ return decideWorkflowGuardAction({ ...input, r7Probe });
647
+ } catch (e) {
648
+ return r7ExceptionDecision(e);
649
+ }
650
+ }
651
+
652
+ export async function mainWithDeps(deps: WorkflowGuardDeps): Promise<void> {
653
+ const stdin = await deps.readStdin();
345
654
  const filePath =
346
655
  getField(stdin, 'tool_input.file_path') ||
347
656
  getField(stdin, 'file_path') ||
348
657
  getField(stdin, 'input.file_path');
349
658
 
350
- log(HOOK, `file_path=${filePath}`);
659
+ deps.log(`file_path=${filePath}`);
351
660
 
352
- const skipWorkflowGuard = process.env.SKIP_WORKFLOW_GUARD === 'true';
353
- const skipSubagentGuard = process.env.SKIP_SUBAGENT_GUARD === 'true';
661
+ const skipWorkflowGuard = deps.env.SKIP_WORKFLOW_GUARD === 'true';
662
+ const skipSubagentGuard = deps.env.SKIP_SUBAGENT_GUARD === 'true';
354
663
  const agentId = getField(stdin, 'agent_id');
355
664
  const agentType = getField(stdin, 'agent_type');
356
665
 
@@ -358,12 +667,10 @@ async function main(): Promise<void> {
358
667
  // (file_path), not the session's cwd. Cheap fs walk-up (no network) — safe
359
668
  // to compute unconditionally for every Write/Edit.
360
669
  const targetDir = filePath.length > 0 ? dirname(filePath) : process.cwd();
361
- const targetProjectRoot = resolveHookProjectRoot(targetDir);
362
- const isUnmanaged =
363
- targetProjectRoot !== null &&
364
- existsSync(join(targetProjectRoot, '.orchestrator', '.unmanaged'));
670
+ const targetProjectRoot = deps.resolveHookProjectRoot(targetDir);
671
+ const isUnmanaged = targetProjectRoot !== null && deps.isUnmanaged(targetProjectRoot);
365
672
 
366
- const baseInput: Omit<WorkflowGuardDecisionInput, 'workflow'> = {
673
+ const baseInput: WorkflowGuardDecisionInput = {
367
674
  filePath,
368
675
  skipWorkflowGuard,
369
676
  skipSubagentGuard,
@@ -371,6 +678,7 @@ async function main(): Promise<void> {
371
678
  isUnmanaged,
372
679
  agentId,
373
680
  agentType,
681
+ workflow: null,
374
682
  };
375
683
 
376
684
  // Two-pass evaluation: try with workflow=null first. Rules 1-5 never
@@ -380,41 +688,76 @@ async function main(): Promise<void> {
380
688
  // unmanaged, and unregistered paths. Only when the trial itself lands on
381
689
  // rule 7 ("no active workflow") do we actually need the network fetch to
382
690
  // confirm — at most one real fetch, matching original behavior exactly.
383
- let decision = decideWorkflowGuardAction({ ...baseInput, workflow: null });
691
+ // R7 has its own network step (probe of the workflow of the PATH), taken
692
+ // only when the trial asks for it (r7NeedsProbe), inside a DENY-on-throw.
693
+ let decision: WorkflowGuardDecision;
694
+ try {
695
+ decision = decideWorkflowGuardAction(baseInput);
696
+ } catch (e) {
697
+ if (vaultOnlyWriter(agentType) !== null) {
698
+ decision = r8ExceptionDecision(e);
699
+ } else {
700
+ if (matchR7Path(filePath).kind === 'none') throw e;
701
+ decision = r7ExceptionDecision(e);
702
+ }
703
+ }
384
704
 
385
- if (decision.matchedRule === 7) {
386
- const activeWorkflow = await getActiveWorkflow();
705
+ if (decision.r7NeedsProbe) {
706
+ decision = await decideWithProbe(baseInput, decision.r7NeedsProbe, deps);
707
+ } else if (decision.matchedRule === 7) {
708
+ const activeWorkflow = await deps.getActiveWorkflow();
387
709
  let workflow: WorkflowGuardWorkflowInput | null = null;
388
710
  if (activeWorkflow) {
389
- const mode = await getWorkflowMode(activeWorkflow.id);
711
+ const mode = await deps.getWorkflowMode(activeWorkflow.id);
390
712
  workflow = { id: activeWorkflow.id, mode, currentPhase: activeWorkflow.currentPhase };
391
713
  }
392
714
  decision = decideWorkflowGuardAction({ ...baseInput, workflow });
393
715
  }
394
716
 
395
717
  for (const line of decision.logMessages) {
396
- log(HOOK, line);
718
+ deps.log(line);
397
719
  }
398
720
 
399
721
  if (decision.observation) {
400
722
  try {
401
- const sessionId = getField(stdin, 'session_id') || getField(stdin, 'claude_session_id') || '';
402
- // RFC-040 Operator Multi-Project — Slice 5 (C5): resolves under the
403
- // project root walked up from cwd (falls back to cwd when unregistered).
404
- const memoryBaseDir = resolveAgentMemoryBaseDir(process.cwd());
405
- appendObservation(memoryBaseDir, 'main', {
406
- timestamp: new Date().toISOString(),
407
- session_id: sessionId,
408
- ...decision.observation,
409
- });
723
+ deps.appendObservation(stdin, decision.observation);
410
724
  } catch {
411
725
  // fail-open — never block on telemetry
412
726
  }
413
727
  }
414
728
 
415
729
  if (decision.emitOutput && decision.output) {
416
- outputPreToolUse(decision.output);
730
+ deps.output(decision.output);
417
731
  }
418
732
  }
419
733
 
734
+ const defaultDeps: WorkflowGuardDeps = {
735
+ readStdin,
736
+ env: process.env,
737
+ resolveHookProjectRoot,
738
+ isUnmanaged: (projectRoot) => existsSync(join(projectRoot, '.orchestrator', '.unmanaged')),
739
+ getActiveWorkflow,
740
+ getWorkflowMode,
741
+ probeWorkflow: (workflowId) => probeWorkflow(workflowId),
742
+ output: outputPreToolUse,
743
+ log: (line) => {
744
+ log(HOOK, line);
745
+ },
746
+ appendObservation: (stdin, observation) => {
747
+ const sessionId = getField(stdin, 'session_id') || getField(stdin, 'claude_session_id') || '';
748
+ // RFC-040 Operator Multi-Project — Slice 5 (C5): resolves under the
749
+ // project root walked up from cwd (falls back to cwd when unregistered).
750
+ const memoryBaseDir = resolveAgentMemoryBaseDir(process.cwd());
751
+ appendObservation(memoryBaseDir, 'main', {
752
+ timestamp: new Date().toISOString(),
753
+ session_id: sessionId,
754
+ ...observation,
755
+ });
756
+ },
757
+ };
758
+
759
+ async function main(): Promise<void> {
760
+ await mainWithDeps(defaultDeps);
761
+ }
762
+
420
763
  main().catch(() => process.exit(0));