@orchestrator-claude/definitions 3.43.1 → 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.
- package/agents/adversarial-reviewer.md +35 -29
- package/agents/agentic-architect.md +1 -1
- package/agents/api-extractor.md +1 -1
- package/agents/architecture-mediator.md +1 -1
- package/agents/azure-devops-engineer.md +1 -1
- package/agents/backend-architect.md +1 -1
- package/agents/business-rule-miner.md +1 -1
- package/agents/cicd-platform-engineer.md +1 -1
- package/agents/ciclo-engineer.md +1 -1
- package/agents/code-archaeologist.md +1 -1
- package/agents/contract-architect.md +1 -1
- package/agents/datamodel-architect.md +1 -1
- package/agents/frontend-architect.md +1 -1
- package/agents/frontend-engineer.md +1 -1
- package/agents/harness-architect.md +1 -1
- package/agents/harness-engineer.md +1 -1
- package/agents/implementer.md +1 -1
- package/agents/infra-architect.md +1 -1
- package/agents/legacy-discoverer.md +1 -1
- package/agents/legacy-synthesizer.md +1 -1
- package/agents/methodology-researcher.md +2 -1
- package/agents/operational-artifact-builder.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/presentation-engineer.md +1 -1
- package/agents/python-data-engineer.md +1 -1
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/schema-extractor.md +1 -1
- package/agents/specifier.md +1 -1
- package/agents/task-generator.md +1 -1
- package/hooks/agent-git-guard.mjs +0 -2
- package/hooks/dangling-guard.mjs +18 -3
- package/hooks/gate-guardian.mjs +8617 -273
- package/hooks/gate-guardian.ts +296 -62
- package/hooks/lib/api-client.ts +154 -28
- package/hooks/lib/artifact-gate.ts +518 -0
- package/hooks/lib/artifact-producers.ts +106 -0
- package/hooks/lib/gate-logic.ts +6 -5
- package/hooks/lib/workflow-artifact-client.ts +351 -0
- package/hooks/mailbox-listener.mjs +0 -2
- package/hooks/playwright-output-guard.mjs +0 -2
- package/hooks/post-implement.mjs +18 -3
- package/hooks/pre-agent-dispatch.mjs +18 -3
- package/hooks/security-gate-honest.mjs +0 -2
- package/hooks/session-start.mjs +18 -3
- package/hooks/subagent-start.mjs +18 -3
- package/hooks/subagent-stop.mjs +18 -3
- package/hooks/user-prompt.mjs +18 -3
- package/hooks/verification-before-completion.mjs +0 -2
- package/hooks/workflow-guard.mjs +504 -29
- package/hooks/workflow-guard.ts +370 -27
- package/manifest.json +46 -46
- package/package.json +1 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-gate.ts — the gate-guardian's ledger step over the files on disk (RFC-044 F3, S5; ADR-031).
|
|
3
|
+
*
|
|
4
|
+
* Leaving phase P (P = probeWorkflow(W).currentPhase, never targetPhase), immediately before an ALLOW:
|
|
5
|
+
* 1. observe: list <root>/project-guidelines/workflows/W/**, read and parse each file (sha256 of the WHOLE bytes);
|
|
6
|
+
* 2. validate every file of P (RF-019: the same validator as the CI gate, the constitution of the vault);
|
|
7
|
+
* 3. verify the files of the SEALED phases (!= P) with `exhaustive: {excludePhases: [P]}` (S3c, M6, D-469): a
|
|
8
|
+
* verdict that is not ok, or a registered host file nobody showed (`unobserved`), is a DENY citing the file;
|
|
9
|
+
* 4. register every file of P (idempotent by (key, hash) on the server), with `advancedBySession` and WITHOUT
|
|
10
|
+
* `agentType` (TD-390, D-486 (2), D-485 (1)).
|
|
11
|
+
* Every failure is a DENY with its reason; nothing here throws to the caller (fail closed).
|
|
12
|
+
*
|
|
13
|
+
* Amendment (c), D-486 (3): until S6c the content store and the file share the `adversarial_review` key, and the
|
|
14
|
+
* ledger compares against the LAST revision whatever its locator. A sealed adversarial_review file is therefore held
|
|
15
|
+
* out of the exhaustive call: if its key comes back in `unobserved`, the last revision is a host file and it is
|
|
16
|
+
* verified strictly; if it does not, the last revision is content-store (or there is no row), and only a
|
|
17
|
+
* hash-mismatch is skipped (the MinIO bytes are not the file). NOT_REGISTERED and an incomplete row still deny.
|
|
18
|
+
*
|
|
19
|
+
* Out of scope on purpose (TD-391): a file deleted in the CURRENT phase, Bash in the current phase, quick mode
|
|
20
|
+
* (the caller never gets here), and a delete after the last advance or completeWorkflow.
|
|
21
|
+
*
|
|
22
|
+
* The vault root is the git toplevel of the PROCESS (ADR-031 D1), resolved here with `git rev-parse`, so the
|
|
23
|
+
* bundle never pulls vault-projection's gate/git.ts (PL-ADR-3).
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from 'node:child_process';
|
|
26
|
+
import type { Dirent } from 'node:fs';
|
|
27
|
+
import { open, readdir } from 'node:fs/promises';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import {
|
|
30
|
+
PHASE_PATTERN,
|
|
31
|
+
SESSION_ID_PATTERN,
|
|
32
|
+
WORKFLOW_ARTIFACT_ROOT,
|
|
33
|
+
loadConstitution,
|
|
34
|
+
parseVaultPath,
|
|
35
|
+
parseWorkflowArtifact,
|
|
36
|
+
validateWorkflowArtifact,
|
|
37
|
+
vaultPathFor,
|
|
38
|
+
type ConstitutionVocab,
|
|
39
|
+
type VerifyItemFailed,
|
|
40
|
+
type VerifyObservedItem,
|
|
41
|
+
type WorkflowArtifactContext,
|
|
42
|
+
type WorkflowArtifactKey,
|
|
43
|
+
type WorkflowArtifactViolation,
|
|
44
|
+
} from '@orchestrator-claude/vault-projection/workflow-artifact';
|
|
45
|
+
import type { AdversarialVerdictResult } from './api-client.js';
|
|
46
|
+
import {
|
|
47
|
+
defaultClientDeps,
|
|
48
|
+
probeWorkflow,
|
|
49
|
+
registerArtifact,
|
|
50
|
+
verifyObserved,
|
|
51
|
+
type ClientDeps,
|
|
52
|
+
type ProbeResult,
|
|
53
|
+
type RegisterInput,
|
|
54
|
+
type RegisterResult,
|
|
55
|
+
type UnobservedArtifact,
|
|
56
|
+
type VerifyOptions,
|
|
57
|
+
type VerifyResult,
|
|
58
|
+
} from './workflow-artifact-client.js';
|
|
59
|
+
|
|
60
|
+
export const ADVERSARIAL_REVIEW_TYPE = 'adversarial_review';
|
|
61
|
+
/** A phase artifact above this size is a DENY (EFBIG): a text artifact, never a blob. */
|
|
62
|
+
export const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
|
|
63
|
+
const CONSTITUTION_REL = 'project-guidelines/.docs-constitution.yaml';
|
|
64
|
+
/** How many violations a DENY reason lists before it says "and N more". */
|
|
65
|
+
const MAX_LISTED = 12;
|
|
66
|
+
|
|
67
|
+
const RUNBOOK =
|
|
68
|
+
'Runbook "API fora" (RFC-044 F3): curl -sS -m 3 "${ORCHESTRATOR_API_URL:-http://localhost:3001}/api/v1/health" ' +
|
|
69
|
+
'(200 = API up; then check token/project). The gate fails closed by design: do not edit hooks.json nor the .mjs.';
|
|
70
|
+
|
|
71
|
+
export interface ArtifactGateDeps {
|
|
72
|
+
/** The git toplevel of the process (ADR-031 D1); null outside a git checkout. */
|
|
73
|
+
getRepoToplevel: () => string | null;
|
|
74
|
+
/** Repo-relative paths of <wf>/<phase>/*.md and <wf>/*.md, sorted; [] when the workflow has no directory. */
|
|
75
|
+
listWorkflowFiles: (root: string, workflowId: string) => Promise<readonly string[]>;
|
|
76
|
+
/** Whole bytes; rejects with `code` (ENOENT, EACCES, EISDIR, EFBIG above `maxBytes`). */
|
|
77
|
+
readFile: (absPath: string, maxBytes: number) => Promise<Uint8Array>;
|
|
78
|
+
probeWorkflow: (workflowId: string) => Promise<ProbeResult>;
|
|
79
|
+
registerArtifact: (input: RegisterInput) => Promise<RegisterResult>;
|
|
80
|
+
verifyObserved: (
|
|
81
|
+
workflowId: string,
|
|
82
|
+
items: readonly VerifyObservedItem[],
|
|
83
|
+
options?: VerifyOptions
|
|
84
|
+
) => Promise<VerifyResult>;
|
|
85
|
+
/** Throws when the constitution is missing, unreadable or malformed (no embedded fallback, RF-019). */
|
|
86
|
+
loadConstitution: (path: string) => ConstitutionVocab;
|
|
87
|
+
validateArtifact: (
|
|
88
|
+
frontmatter: Readonly<Record<string, unknown>> | null,
|
|
89
|
+
ctx: WorkflowArtifactContext
|
|
90
|
+
) => readonly WorkflowArtifactViolation[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const codeOf = (e: unknown): string => {
|
|
94
|
+
const code = (e as { code?: unknown } | null)?.code;
|
|
95
|
+
return typeof code === 'string' ? code : e instanceof Error ? e.message : String(e);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function fsError(code: string, message: string): Error {
|
|
99
|
+
return Object.assign(new Error(`${code}: ${message}`), { code });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `git rev-parse --show-toplevel` from `cwd`; null when it is not a git checkout (never throws). */
|
|
103
|
+
export function gitToplevel(cwd: string): string | null {
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
109
|
+
timeout: 5000,
|
|
110
|
+
}).trim();
|
|
111
|
+
return out.length > 0 ? out : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readdirOrEmpty(dir: string): Promise<Dirent[]> {
|
|
118
|
+
try {
|
|
119
|
+
return await readdir(dir, { withFileTypes: true });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (codeOf(e) === 'ENOENT') return [];
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function listWorkflowFilesFs(root: string, workflowId: string): Promise<readonly string[]> {
|
|
127
|
+
const relBase = `${WORKFLOW_ARTIFACT_ROOT}/${workflowId}`;
|
|
128
|
+
const found: string[] = [];
|
|
129
|
+
for (const entry of await readdirOrEmpty(join(root, relBase))) {
|
|
130
|
+
if (entry.isDirectory() && !entry.name.endsWith('.md')) {
|
|
131
|
+
for (const inner of await readdirOrEmpty(join(root, relBase, entry.name))) {
|
|
132
|
+
if (inner.name.endsWith('.md')) found.push(`${relBase}/${entry.name}/${inner.name}`);
|
|
133
|
+
}
|
|
134
|
+
} else if (entry.name.endsWith('.md')) {
|
|
135
|
+
found.push(`${relBase}/${entry.name}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return found.sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function readFileFs(absPath: string, maxBytes: number): Promise<Uint8Array> {
|
|
142
|
+
const handle = await open(absPath, 'r');
|
|
143
|
+
try {
|
|
144
|
+
const stat = await handle.stat();
|
|
145
|
+
if (stat.isDirectory()) throw fsError('EISDIR', absPath);
|
|
146
|
+
if (stat.size > maxBytes)
|
|
147
|
+
throw fsError('EFBIG', `${absPath} has ${String(stat.size)} bytes (max ${String(maxBytes)})`);
|
|
148
|
+
return await handle.readFile();
|
|
149
|
+
} finally {
|
|
150
|
+
await handle.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Production deps. `clientDeps` lets a test point the client at its own API (URL, token, project). */
|
|
155
|
+
export function defaultArtifactGateDeps(clientDeps?: ClientDeps): ArtifactGateDeps {
|
|
156
|
+
const client = (): ClientDeps => clientDeps ?? defaultClientDeps();
|
|
157
|
+
return {
|
|
158
|
+
getRepoToplevel: () => gitToplevel(process.cwd()),
|
|
159
|
+
listWorkflowFiles: listWorkflowFilesFs,
|
|
160
|
+
readFile: readFileFs,
|
|
161
|
+
probeWorkflow: (workflowId) => probeWorkflow(workflowId, client()),
|
|
162
|
+
registerArtifact: (input) => registerArtifact(input, client()),
|
|
163
|
+
verifyObserved: (workflowId, items, options) =>
|
|
164
|
+
verifyObserved(workflowId, items, client(), options),
|
|
165
|
+
loadConstitution: (path) => loadConstitution(path),
|
|
166
|
+
validateArtifact: validateWorkflowArtifact,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─────────────────────────────────────────────────────────────
|
|
171
|
+
// Authorship of the advance (TD-390, D-486 (2), D-485 (1))
|
|
172
|
+
// ─────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
export type SessionOf =
|
|
175
|
+
| { kind: 'ok'; advancedBySession: string }
|
|
176
|
+
| { kind: 'omitted'; why: 'absent' | 'invalid' };
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The stdin `session_id` when it is a string inside SESSION_ID_PATTERN; otherwise the field is OMITTED, never sent
|
|
180
|
+
* (the server would answer 400, and a 400 is a DENY). Authorship is audit data, not the fence: an unmeasured stdin
|
|
181
|
+
* shape (TD-390 sec. 3: interactive main, sub-agent, --agent) must not block every advance.
|
|
182
|
+
*/
|
|
183
|
+
export function sessionOf(raw: unknown): SessionOf {
|
|
184
|
+
if (raw === undefined || raw === null || raw === '') return { kind: 'omitted', why: 'absent' };
|
|
185
|
+
if (typeof raw !== 'string' || !SESSION_ID_PATTERN.test(raw))
|
|
186
|
+
return { kind: 'omitted', why: 'invalid' };
|
|
187
|
+
return { kind: 'ok', advancedBySession: raw };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────────────────────────────────────
|
|
191
|
+
// Observation of the disk
|
|
192
|
+
// ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export interface ObservedFile {
|
|
195
|
+
readonly rel: string;
|
|
196
|
+
readonly key: WorkflowArtifactKey;
|
|
197
|
+
readonly sha256: string;
|
|
198
|
+
readonly sizeBytes: number;
|
|
199
|
+
readonly frontmatter: Record<string, unknown> | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type Observation =
|
|
203
|
+
| {
|
|
204
|
+
kind: 'ok';
|
|
205
|
+
workflowId: string;
|
|
206
|
+
/** The phase being left, lowercase. */
|
|
207
|
+
phase: string;
|
|
208
|
+
root: string | null;
|
|
209
|
+
files: readonly ObservedFile[];
|
|
210
|
+
}
|
|
211
|
+
| { kind: 'deny'; reason: string };
|
|
212
|
+
|
|
213
|
+
const deny = (detail: string): { kind: 'deny'; reason: string } => ({
|
|
214
|
+
kind: 'deny',
|
|
215
|
+
reason: `Gate Guardian (RFC-044 F3, workflow artifacts on disk): ${detail}`,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function probeFailure(probe: Exclude<ProbeResult, { kind: 'ok' }>, workflowId: string): string {
|
|
219
|
+
if (probe.kind === 'not-found') return `workflow ${workflowId} not found by the API.`;
|
|
220
|
+
const http = probe.httpStatus === undefined ? '' : `, HTTP ${String(probe.httpStatus)}`;
|
|
221
|
+
return `could not confirm workflow ${workflowId} with the API (cause: ${probe.cause}${http}). ${RUNBOOK}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Probe (the phase being left), toplevel, listing, reading and parsing. No register, no verify. */
|
|
225
|
+
export async function observeWorkflow(
|
|
226
|
+
workflowId: string,
|
|
227
|
+
deps: ArtifactGateDeps
|
|
228
|
+
): Promise<Observation> {
|
|
229
|
+
try {
|
|
230
|
+
const probe = await deps.probeWorkflow(workflowId);
|
|
231
|
+
if (probe.kind !== 'ok') return deny(probeFailure(probe, workflowId));
|
|
232
|
+
const phase = (probe.currentPhase ?? '').toLowerCase();
|
|
233
|
+
if (!PHASE_PATTERN.test(phase)) {
|
|
234
|
+
return deny(
|
|
235
|
+
`workflow ${workflowId} has no current phase the gate can read (${JSON.stringify(probe.currentPhase)}); the phase being left is unknown.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const root = deps.getRepoToplevel();
|
|
239
|
+
if (root === null) return { kind: 'ok', workflowId, phase, root, files: [] };
|
|
240
|
+
let rels: readonly string[];
|
|
241
|
+
try {
|
|
242
|
+
rels = await deps.listWorkflowFiles(root, workflowId);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return deny(`cannot list ${WORKFLOW_ARTIFACT_ROOT}/${workflowId}: ${codeOf(e)}.`);
|
|
245
|
+
}
|
|
246
|
+
const files: ObservedFile[] = [];
|
|
247
|
+
for (const rel of rels) {
|
|
248
|
+
const key = parseVaultPath(rel);
|
|
249
|
+
if (key?.workflowId !== workflowId) {
|
|
250
|
+
return deny(
|
|
251
|
+
`${rel} is not ${WORKFLOW_ARTIFACT_ROOT}/<workflow_id>/<phase>/<artifact_type>.md (ADR-031 D2).`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
let bytes: Uint8Array;
|
|
255
|
+
try {
|
|
256
|
+
bytes = await deps.readFile(join(root, rel), MAX_ARTIFACT_BYTES);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return deny(`cannot read ${rel}: ${codeOf(e)}.`);
|
|
259
|
+
}
|
|
260
|
+
const parsed = parseWorkflowArtifact(bytes);
|
|
261
|
+
files.push({
|
|
262
|
+
rel,
|
|
263
|
+
key,
|
|
264
|
+
sha256: parsed.sha256,
|
|
265
|
+
sizeBytes: bytes.byteLength,
|
|
266
|
+
frontmatter: parsed.frontmatter,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return { kind: 'ok', workflowId, phase, root, files };
|
|
270
|
+
} catch (e) {
|
|
271
|
+
return deny(
|
|
272
|
+
`unexpected error while observing the workflow (${e instanceof Error ? e.message : String(e)}).`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─────────────────────────────────────────────────────────────
|
|
278
|
+
// RF-020: the adversarial verdict of the file comes first
|
|
279
|
+
// ─────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
export type FileVerdict =
|
|
282
|
+
| { kind: 'none' }
|
|
283
|
+
| { kind: 'invalid'; reason: string }
|
|
284
|
+
| { kind: 'verdict'; result: AdversarialVerdictResult };
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* workflows/<wf>/<P>/adversarial_review.md: `verdict` (string) and `iteration` (a non-negative integer). No file
|
|
288
|
+
* -> none (the caller falls back to the content store). A file without a string verdict is invalid (a DENY, never
|
|
289
|
+
* a fail-open); an iteration that is not an integer keeps TD-375 (iteration_rejected: PASS proceeds, else DENY).
|
|
290
|
+
*/
|
|
291
|
+
export function fileAdversarialVerdict(
|
|
292
|
+
observation: Extract<Observation, { kind: 'ok' }>
|
|
293
|
+
): FileVerdict {
|
|
294
|
+
const file = observation.files.find(
|
|
295
|
+
(f) => f.key.phase === observation.phase && f.key.artifactType === ADVERSARIAL_REVIEW_TYPE
|
|
296
|
+
);
|
|
297
|
+
if (file === undefined) return { kind: 'none' };
|
|
298
|
+
const fm = file.frontmatter;
|
|
299
|
+
if (fm === null) return { kind: 'invalid', reason: `${file.rel} has no valid YAML frontmatter.` };
|
|
300
|
+
const { verdict, iteration } = fm;
|
|
301
|
+
if (typeof verdict !== 'string' || verdict.length === 0) {
|
|
302
|
+
return {
|
|
303
|
+
kind: 'invalid',
|
|
304
|
+
reason: `${file.rel}: verdict must be a non-empty string, got ${(JSON.stringify(verdict) as string | undefined) ?? 'nothing'}.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (typeof iteration === 'number' && Number.isSafeInteger(iteration) && iteration >= 0) {
|
|
308
|
+
return { kind: 'verdict', result: { kind: 'ok', verdict, iteration } };
|
|
309
|
+
}
|
|
310
|
+
const detail = (JSON.stringify(iteration) as string | undefined)?.slice(0, 40) ?? 'absent';
|
|
311
|
+
return { kind: 'verdict', result: { kind: 'iteration_rejected', verdict, detail } };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────────────────────────────────────────
|
|
315
|
+
// The ledger step before the ALLOW
|
|
316
|
+
// ─────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
export interface LedgerContext {
|
|
319
|
+
workflowId: string;
|
|
320
|
+
advancedBySession?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type LedgerOutcome = { ok: true; summary: string } | { ok: false; reason: string };
|
|
324
|
+
|
|
325
|
+
type Log = (msg: string) => void;
|
|
326
|
+
|
|
327
|
+
const fail = (detail: string): LedgerOutcome => ({ ok: false, reason: deny(detail).reason });
|
|
328
|
+
|
|
329
|
+
function listed(lines: readonly string[]): string {
|
|
330
|
+
const head = lines.slice(0, MAX_LISTED).join('; ');
|
|
331
|
+
return lines.length > MAX_LISTED
|
|
332
|
+
? `${head}; and ${String(lines.length - MAX_LISTED)} more`
|
|
333
|
+
: head;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function verifyFailure(result: Exclude<VerifyResult, { kind: 'ok' }>, workflowId: string): string {
|
|
337
|
+
if (result.kind === 'probe-failed') return probeFailure(result.probe, workflowId);
|
|
338
|
+
if (result.kind === 'rejected') {
|
|
339
|
+
return `verify-observed rejected by the API (${result.code}, HTTP ${String(result.httpStatus)})${result.detail === undefined ? '' : `: ${result.detail}`}.`;
|
|
340
|
+
}
|
|
341
|
+
const http = result.httpStatus === undefined ? '' : `, HTTP ${String(result.httpStatus)}`;
|
|
342
|
+
return `verify-observed unavailable (cause: ${result.cause}${http}). ${RUNBOOK}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const itemOf = (f: ObservedFile): VerifyObservedItem => ({
|
|
346
|
+
phase: f.key.phase,
|
|
347
|
+
artifactType: f.key.artifactType,
|
|
348
|
+
observedHash: f.sha256,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const pathOf = (workflowId: string, phase: string, artifactType: string): string =>
|
|
352
|
+
vaultPathFor({ workflowId, phase, artifactType });
|
|
353
|
+
|
|
354
|
+
function describeFailed(workflowId: string, f: VerifyItemFailed): string {
|
|
355
|
+
const reason = f.reason === undefined ? '' : ` (${f.reason})`;
|
|
356
|
+
const revision =
|
|
357
|
+
f.revision === undefined || f.revision === null
|
|
358
|
+
? ''
|
|
359
|
+
: `, ledger revision ${String(f.revision)}`;
|
|
360
|
+
return `${pathOf(workflowId, f.phase, f.artifactType)}: ${f.code}${reason}${revision}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function validateCurrent(
|
|
364
|
+
current: readonly ObservedFile[],
|
|
365
|
+
observation: Extract<Observation, { kind: 'ok' }>,
|
|
366
|
+
deps: ArtifactGateDeps
|
|
367
|
+
): string | null {
|
|
368
|
+
if (current.length === 0 || observation.root === null) return null;
|
|
369
|
+
let constitution: ConstitutionVocab;
|
|
370
|
+
try {
|
|
371
|
+
constitution = deps.loadConstitution(join(observation.root, CONSTITUTION_REL));
|
|
372
|
+
} catch (e) {
|
|
373
|
+
return `cannot load the constitution ${CONSTITUTION_REL} to validate the files of '${observation.phase}' (${e instanceof Error ? e.message : String(e)}).`;
|
|
374
|
+
}
|
|
375
|
+
const violations = current.flatMap((f) =>
|
|
376
|
+
deps
|
|
377
|
+
.validateArtifact(f.frontmatter, { path: f.rel, constitution })
|
|
378
|
+
.map((v) => `${v.file} ${v.field}: ${v.message}`)
|
|
379
|
+
);
|
|
380
|
+
return violations.length === 0
|
|
381
|
+
? null
|
|
382
|
+
: `the files of the phase being left ('${observation.phase}') violate the workflow-artifact schema (RF-019): ${listed(violations)}.`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Sealed phases: exhaustive verify of the plain files, then the adversarial_review files on their own (D-486 (3)). */
|
|
386
|
+
async function verifySealed(
|
|
387
|
+
sealed: readonly ObservedFile[],
|
|
388
|
+
observation: Extract<Observation, { kind: 'ok' }>,
|
|
389
|
+
deps: ArtifactGateDeps,
|
|
390
|
+
log: Log
|
|
391
|
+
): Promise<string | null> {
|
|
392
|
+
const { workflowId, phase } = observation;
|
|
393
|
+
const plain = sealed.filter((f) => f.key.artifactType !== ADVERSARIAL_REVIEW_TYPE);
|
|
394
|
+
const adversarial = sealed.filter((f) => f.key.artifactType === ADVERSARIAL_REVIEW_TYPE);
|
|
395
|
+
|
|
396
|
+
const first = await deps.verifyObserved(workflowId, plain.map(itemOf), {
|
|
397
|
+
exhaustive: { excludePhases: [phase] },
|
|
398
|
+
});
|
|
399
|
+
if (first.kind !== 'ok') return verifyFailure(first, workflowId);
|
|
400
|
+
if (first.unobserved === undefined) {
|
|
401
|
+
return (
|
|
402
|
+
'verify-observed answered without `unobserved` to an exhaustive request (an API without S3c?). ' +
|
|
403
|
+
RUNBOOK
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const problems = first.failed.map((f) => describeFailed(workflowId, f));
|
|
407
|
+
|
|
408
|
+
const vaultFileAdversarial = new Set<string>();
|
|
409
|
+
const missing: UnobservedArtifact[] = [];
|
|
410
|
+
for (const u of first.unobserved) {
|
|
411
|
+
const onDisk = adversarial.some((f) => f.key.phase === u.phase.toLowerCase());
|
|
412
|
+
if (u.artifactType === ADVERSARIAL_REVIEW_TYPE && onDisk)
|
|
413
|
+
vaultFileAdversarial.add(u.phase.toLowerCase());
|
|
414
|
+
else missing.push(u);
|
|
415
|
+
}
|
|
416
|
+
for (const u of missing) {
|
|
417
|
+
problems.push(
|
|
418
|
+
`${pathOf(workflowId, u.phase, u.artifactType)}: registered (ledger revision ${String(u.revision)}) and missing from disk in a sealed phase (D-469)`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (adversarial.length > 0) {
|
|
423
|
+
const second = await deps.verifyObserved(workflowId, adversarial.map(itemOf));
|
|
424
|
+
if (second.kind !== 'ok') return verifyFailure(second, workflowId);
|
|
425
|
+
for (const f of second.failed) {
|
|
426
|
+
const contentStoreLast = !vaultFileAdversarial.has(f.phase.toLowerCase());
|
|
427
|
+
if (
|
|
428
|
+
contentStoreLast &&
|
|
429
|
+
f.code === 'ARTIFACT_INTEGRITY_VIOLATED' &&
|
|
430
|
+
f.reason === 'hash-mismatch'
|
|
431
|
+
) {
|
|
432
|
+
log(
|
|
433
|
+
`skip ${pathOf(workflowId, f.phase, f.artifactType)}: last revision of the key is content-store, not this file (D-486 (3), until S6c)`
|
|
434
|
+
);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
problems.push(describeFailed(workflowId, f));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return problems.length === 0
|
|
442
|
+
? null
|
|
443
|
+
: `the sealed phases do not match the ledger: ${listed(problems)}. Restore the file(s) (e.g. git checkout) and advance again.`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function registerCurrent(
|
|
447
|
+
current: readonly ObservedFile[],
|
|
448
|
+
observation: Extract<Observation, { kind: 'ok' }>,
|
|
449
|
+
ctx: LedgerContext,
|
|
450
|
+
deps: ArtifactGateDeps
|
|
451
|
+
): Promise<{ error: string } | { recorded: number; unchanged: number }> {
|
|
452
|
+
let recorded = 0;
|
|
453
|
+
let unchanged = 0;
|
|
454
|
+
for (const f of current) {
|
|
455
|
+
const result = await deps.registerArtifact({
|
|
456
|
+
workflowId: observation.workflowId,
|
|
457
|
+
phase: observation.phase,
|
|
458
|
+
artifactType: f.key.artifactType,
|
|
459
|
+
vaultPath: f.rel,
|
|
460
|
+
contentHash: f.sha256,
|
|
461
|
+
sizeBytes: f.sizeBytes,
|
|
462
|
+
frontmatter: f.frontmatter ?? {},
|
|
463
|
+
...(ctx.advancedBySession !== undefined && { advancedBySession: ctx.advancedBySession }),
|
|
464
|
+
});
|
|
465
|
+
if (result.kind === 'ok') {
|
|
466
|
+
if (result.response.status === 'recorded') recorded++;
|
|
467
|
+
else unchanged++;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (result.kind === 'rejected') {
|
|
471
|
+
const detail = result.detail === undefined ? '' : `: ${result.detail}`;
|
|
472
|
+
return {
|
|
473
|
+
error: `register of ${f.rel} rejected by the API (${result.code}, HTTP ${String(result.httpStatus)})${detail}.`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const http = result.httpStatus === undefined ? '' : `, HTTP ${String(result.httpStatus)}`;
|
|
477
|
+
return {
|
|
478
|
+
error: `register of ${f.rel} unavailable (cause: ${result.cause}${http}). ${RUNBOOK}`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return { recorded, unchanged };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The single ledger step every ALLOW goes through: validate P, verify the sealed phases, register P.
|
|
486
|
+
* ok:false is a DENY with the reason; an exception is caught here and is a DENY too.
|
|
487
|
+
*/
|
|
488
|
+
export async function registerBeforeAllow(
|
|
489
|
+
observation: Extract<Observation, { kind: 'ok' }>,
|
|
490
|
+
ctx: LedgerContext,
|
|
491
|
+
deps: ArtifactGateDeps,
|
|
492
|
+
log: Log
|
|
493
|
+
): Promise<LedgerOutcome> {
|
|
494
|
+
try {
|
|
495
|
+
const current = observation.files.filter((f) => f.key.phase === observation.phase);
|
|
496
|
+
const sealed = observation.files.filter((f) => f.key.phase !== observation.phase);
|
|
497
|
+
if (observation.root === null)
|
|
498
|
+
log('git toplevel unknown: the observation is empty, the ledger is still asked');
|
|
499
|
+
|
|
500
|
+
const invalid = validateCurrent(current, observation, deps);
|
|
501
|
+
if (invalid !== null) return fail(invalid);
|
|
502
|
+
|
|
503
|
+
const mismatch = await verifySealed(sealed, observation, deps, log);
|
|
504
|
+
if (mismatch !== null) return fail(mismatch);
|
|
505
|
+
|
|
506
|
+
const registered = await registerCurrent(current, observation, ctx, deps);
|
|
507
|
+
if ('error' in registered) return fail(registered.error);
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
ok: true,
|
|
511
|
+
summary: `ledger: phase=${observation.phase} sealed-verified=${String(sealed.length)} recorded=${String(registered.recorded)} unchanged=${String(registered.unchanged)}`,
|
|
512
|
+
};
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return fail(
|
|
515
|
+
`unexpected error in the ledger step (${e instanceof Error ? e.message : String(e)}).`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-producers.ts — ARTIFACT_PRODUCER_AGENTS (RFC-044 F3, S4, PL-ADR-4; ratifies Q-PM-3).
|
|
3
|
+
*
|
|
4
|
+
* The agents that R7 of workflow-guard lets write a workflow artifact under
|
|
5
|
+
* project-guidelines/workflows/<wf>/<phase>/<type>.md: the 30 agents that carried `artifactStore` on their
|
|
6
|
+
* `tools:` line when F3 was planned (S556, measured again in S557). Bare names: the hook normalizes the
|
|
7
|
+
* `orchestrator:` prefix (normalizeAgentName) before asking.
|
|
8
|
+
*
|
|
9
|
+
* Frozen on purpose. A new producer is an edit of this list in a PR, never a side effect of a `tools:` line;
|
|
10
|
+
* the unit test asserts this set is a SUPERSET of the agents with `artifactStore` (when F4 removes the tool
|
|
11
|
+
* from everyone, that assertion turns vacuous and F4 rewrites it).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const NAMES: readonly string[] = [
|
|
15
|
+
'adversarial-reviewer',
|
|
16
|
+
'agentic-architect',
|
|
17
|
+
'api-extractor',
|
|
18
|
+
'architecture-mediator',
|
|
19
|
+
'azure-devops-engineer',
|
|
20
|
+
'backend-architect',
|
|
21
|
+
'business-rule-miner',
|
|
22
|
+
'cicd-platform-engineer',
|
|
23
|
+
'ciclo-engineer',
|
|
24
|
+
'code-archaeologist',
|
|
25
|
+
'contract-architect',
|
|
26
|
+
'datamodel-architect',
|
|
27
|
+
'frontend-architect',
|
|
28
|
+
'frontend-engineer',
|
|
29
|
+
'harness-architect',
|
|
30
|
+
'harness-engineer',
|
|
31
|
+
'implementer',
|
|
32
|
+
'infra-architect',
|
|
33
|
+
'legacy-discoverer',
|
|
34
|
+
'legacy-synthesizer',
|
|
35
|
+
'methodology-researcher',
|
|
36
|
+
'operational-artifact-builder',
|
|
37
|
+
'planner',
|
|
38
|
+
'presentation-engineer',
|
|
39
|
+
'python-data-engineer',
|
|
40
|
+
'researcher',
|
|
41
|
+
'reviewer',
|
|
42
|
+
'schema-extractor',
|
|
43
|
+
'specifier',
|
|
44
|
+
'task-generator',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function refuser(constant: string): () => never {
|
|
48
|
+
return (): never => {
|
|
49
|
+
throw new TypeError(`${constant} is frozen: edit the list in artifact-producers.ts`);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function frozenSet(values: readonly string[], constant: string): ReadonlySet<string> {
|
|
54
|
+
const set = new Set(values);
|
|
55
|
+
const refuse = refuser(constant);
|
|
56
|
+
Object.defineProperties(set, {
|
|
57
|
+
add: { value: refuse },
|
|
58
|
+
delete: { value: refuse },
|
|
59
|
+
clear: { value: refuse },
|
|
60
|
+
});
|
|
61
|
+
return Object.freeze(set);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const ARTIFACT_PRODUCER_AGENTS: ReadonlySet<string> = frozenSet(
|
|
65
|
+
NAMES,
|
|
66
|
+
'ARTIFACT_PRODUCER_AGENTS'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The artifact type the adversarial-reviewer may write. A LITERAL on purpose: artifact-gate.ts exports the same
|
|
71
|
+
* value (ADVERSARIAL_REVIEW_TYPE) but imports child_process and fs/promises, which would then be bundled into
|
|
72
|
+
* workflow-guard.mjs. The unit test asserts both are equal.
|
|
73
|
+
*/
|
|
74
|
+
export const PINNED_ADVERSARIAL_REVIEW_TYPE = 'adversarial_review';
|
|
75
|
+
|
|
76
|
+
function frozenMap(
|
|
77
|
+
entries: readonly (readonly [string, ReadonlySet<string> | null])[]
|
|
78
|
+
): ReadonlyMap<string, ReadonlySet<string> | null> {
|
|
79
|
+
const map = new Map(entries);
|
|
80
|
+
const refuse = refuser('VAULT_ONLY_WRITERS');
|
|
81
|
+
Object.defineProperties(map, {
|
|
82
|
+
set: { value: refuse },
|
|
83
|
+
delete: { value: refuse },
|
|
84
|
+
clear: { value: refuse },
|
|
85
|
+
});
|
|
86
|
+
return Object.freeze(map);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* VAULT_ONLY_WRITERS (RFC-044 F3, S6c; R8 of workflow-guard). The agents whose Write is fenced to two holes only:
|
|
91
|
+
* their artifact under project-guidelines/workflows/<wf>/<phase>/<type>.md of a managed project (then R7 decides),
|
|
92
|
+
* and their OWN memory dir <project root>/.claude/agent-memory/<slug>/. Anything else is a DENY, before rules 1-7.
|
|
93
|
+
*
|
|
94
|
+
* The value is the set of artifact types the agent may write there; `null` = any type R7 accepts.
|
|
95
|
+
* - adversarial-reviewer -> { adversarial_review }: R-6 ("MUST NOT write to the artifact under review") held by
|
|
96
|
+
* the hook, not only by the charter.
|
|
97
|
+
* - methodology-researcher -> null.
|
|
98
|
+
*
|
|
99
|
+
* Declared, not inferred from `tools:` ("has Write and no Edit/Bash" catches 9 agents; DV-P3 rejected deriving at
|
|
100
|
+
* run time). A subset of ARTIFACT_PRODUCER_AGENTS (asserted). Answers TD-392 only for these two; it stays open for
|
|
101
|
+
* the other 30 agents with Write.
|
|
102
|
+
*/
|
|
103
|
+
export const VAULT_ONLY_WRITERS: ReadonlyMap<string, ReadonlySet<string> | null> = frozenMap([
|
|
104
|
+
['adversarial-reviewer', frozenSet([PINNED_ADVERSARIAL_REVIEW_TYPE], 'VAULT_ONLY_WRITERS')],
|
|
105
|
+
['methodology-researcher', null],
|
|
106
|
+
]);
|
package/hooks/lib/gate-logic.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export type RiskLevel = 'HIGH' | 'LOW';
|
|
8
|
-
export type GateStrategy = '
|
|
8
|
+
export type GateStrategy = 'ALLOW_LOW_RISK' | 'ASK_HUMAN' | 'BLOCK_AND_REQUIRE';
|
|
9
9
|
|
|
10
10
|
export interface GateContext {
|
|
11
11
|
workflowId: string;
|
|
@@ -38,7 +38,7 @@ export function classifyRisk(targetPhase: string): RiskLevel {
|
|
|
38
38
|
* Derive the gate strategy based on context.
|
|
39
39
|
* Rules (evaluated in order):
|
|
40
40
|
* 1. HIGH risk → ASK_HUMAN
|
|
41
|
-
* 2. default →
|
|
41
|
+
* 2. default → ALLOW_LOW_RISK (allowed without an approval check; the hook observes no approval)
|
|
42
42
|
*
|
|
43
43
|
* BLOCK_AND_REQUIRE exists as a type but has no trigger rule yet.
|
|
44
44
|
*/
|
|
@@ -50,9 +50,10 @@ export function deriveGateStrategy(ctx: GateContext): GateDecision {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// Default:
|
|
53
|
+
// Default: low-risk phases pass without any approval check (TD-373: the label
|
|
54
|
+
// must not claim an approval this hook never observed).
|
|
54
55
|
return {
|
|
55
|
-
strategy: '
|
|
56
|
-
reason: `Phase '${ctx.targetPhase}' is low-risk —
|
|
56
|
+
strategy: 'ALLOW_LOW_RISK',
|
|
57
|
+
reason: `Phase '${ctx.targetPhase}' is low-risk — allowed without an approval check; no approval observed by this hook.`,
|
|
57
58
|
};
|
|
58
59
|
}
|