agentxchain 2.150.0 → 2.152.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/package.json +2 -1
- package/scripts/collect-pack-sha-diagnostic.mjs +344 -0
- package/src/commands/init.js +36 -4
- package/src/commands/resume.js +48 -1
- package/src/lib/adapters/local-cli-adapter.js +2 -1
- package/src/lib/approval-policy.js +44 -0
- package/src/lib/governed-state.js +257 -8
- package/src/lib/governed-templates.js +1 -0
- package/src/lib/normalized-config.js +23 -0
- package/src/lib/run-events.js +1 -0
- package/src/lib/schemas/agentxchain-config.schema.json +90 -2
- package/src/lib/stale-turn-watchdog.js +3 -3
- package/src/templates/governed/enterprise-app.json +35 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentxchain",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.152.0",
|
|
4
4
|
"description": "CLI for AgentXchain — governed multi-agent software delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"bump:release": "bash scripts/release-bump.sh",
|
|
38
38
|
"sync:homebrew": "bash scripts/sync-homebrew.sh",
|
|
39
39
|
"verify:post-publish": "bash scripts/verify-post-publish.sh",
|
|
40
|
+
"collect:pack-sha-diagnostic": "node scripts/collect-pack-sha-diagnostic.mjs",
|
|
40
41
|
"build:macos": "bun build bin/agentxchain.js --compile --target=bun-darwin-arm64 --outfile=dist/agentxchain-macos-arm64",
|
|
41
42
|
"build:linux": "bun build bin/agentxchain.js --compile --target=bun-linux-x64 --outfile=dist/agentxchain-linux-x64",
|
|
42
43
|
"publish:npm": "bash scripts/publish-npm.sh"
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Collect pack-SHA diagnostic evidence from `publish-npm-on-tag.yml` runs.
|
|
4
|
+
*
|
|
5
|
+
* Purpose:
|
|
6
|
+
* Turn 129 (`DEC-PUBLISH-WORKFLOW-PACK-SHA-DIAGNOSTIC-ONLY-001`) added
|
|
7
|
+
* runner-local `npm pack` SHA capture + registry `dist.shasum`/`dist.integrity`
|
|
8
|
+
* comparison to the publish workflow as diagnostic-only evidence. Each
|
|
9
|
+
* published tag now emits `PACK_SHA_DIAGNOSTIC:` and `PACK_INTEGRITY_DIAGNOSTIC:`
|
|
10
|
+
* log lines with MATCH/MISMATCH verdicts.
|
|
11
|
+
*
|
|
12
|
+
* A real reproducible-publish gate cannot be designed until we have ≥3 release
|
|
13
|
+
* cycles of evidence. This script turns the per-run log lines into a
|
|
14
|
+
* multi-release evidence view so the threshold can be evaluated at a glance.
|
|
15
|
+
*
|
|
16
|
+
* Behavior:
|
|
17
|
+
* Default: uses `gh run list` to fetch the last N `publish-npm-on-tag.yml`
|
|
18
|
+
* runs, then `gh run view <id> --log` to scrape the two diagnostic tags from
|
|
19
|
+
* each run's logs, and prints a table summary plus aggregate MATCH/MISMATCH
|
|
20
|
+
* counts.
|
|
21
|
+
*
|
|
22
|
+
* Test / offline mode: `--log-file <path>` parses a single saved log instead
|
|
23
|
+
* of calling `gh`. Useful for unit tests and local debugging without GH auth.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* cd cli && npm run collect:pack-sha-diagnostic -- # last 10 runs
|
|
27
|
+
* cd cli && npm run collect:pack-sha-diagnostic -- --limit 20
|
|
28
|
+
* node cli/scripts/collect-pack-sha-diagnostic.mjs # direct path
|
|
29
|
+
* node cli/scripts/collect-pack-sha-diagnostic.mjs --limit 20
|
|
30
|
+
* node cli/scripts/collect-pack-sha-diagnostic.mjs --format json
|
|
31
|
+
* node cli/scripts/collect-pack-sha-diagnostic.mjs --workflow publish-npm-on-tag.yml
|
|
32
|
+
* node cli/scripts/collect-pack-sha-diagnostic.mjs --log-file /tmp/run.log
|
|
33
|
+
*
|
|
34
|
+
* How to read the output:
|
|
35
|
+
* - `MATCH` means the workflow's runner-local pack value matched the npm
|
|
36
|
+
* registry value for that release run.
|
|
37
|
+
* - `MISMATCH` means the runner-local pack value differed from registry
|
|
38
|
+
* truth. Treat it as investigation evidence, not an automatic release
|
|
39
|
+
* failure.
|
|
40
|
+
* - `unavailable` means the diagnostic ran but could not form a comparison
|
|
41
|
+
* (for example, registry metadata was not ready).
|
|
42
|
+
* - `missing` means the diagnostic tag was absent, usually because the run
|
|
43
|
+
* was an already-published rerun and skipped local packing.
|
|
44
|
+
* - Only non-rerun `MATCH` verdicts count toward the "≥3 MATCH" evidence
|
|
45
|
+
* threshold from `DEC-PUBLISH-WORKFLOW-PACK-SHA-DIAGNOSTIC-ONLY-001`.
|
|
46
|
+
* That threshold only permits designing a future gate; it is not a gate
|
|
47
|
+
* by itself.
|
|
48
|
+
*
|
|
49
|
+
* Diagnostic-only. This script does not gate releases, mutate state, or fail
|
|
50
|
+
* on MISMATCH. It prints evidence; a gate is a future decision.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { execFileSync } from 'node:child_process';
|
|
54
|
+
import { readFileSync } from 'node:fs';
|
|
55
|
+
|
|
56
|
+
const DEFAULT_WORKFLOW = 'publish-npm-on-tag.yml';
|
|
57
|
+
const DEFAULT_LIMIT = 10;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a publish workflow log for the Turn 129 diagnostic tags.
|
|
61
|
+
*
|
|
62
|
+
* Returns a plain object with:
|
|
63
|
+
* - shaVerdict: 'MATCH' | 'MISMATCH' | 'unavailable' | 'missing'
|
|
64
|
+
* - shaDetail: the line body after the ':' (MATCH/MISMATCH reason) or null
|
|
65
|
+
* - integrityVerdict: 'MATCH' | 'MISMATCH' | 'unavailable' | 'missing'
|
|
66
|
+
* - integrityDetail: the line body after the ':' or null
|
|
67
|
+
* - version: the `agentxchain@X.Y.Z` version extracted from the SHA tag, or null
|
|
68
|
+
*
|
|
69
|
+
* A log with no `PACK_SHA_DIAGNOSTIC:` tag returns shaVerdict = 'missing'
|
|
70
|
+
* (the diagnostic step did not run — e.g. `already_published` rerun).
|
|
71
|
+
*
|
|
72
|
+
* A log whose SHA tag says "unavailable" (registry dist missing, runner pack
|
|
73
|
+
* failed) returns shaVerdict = 'unavailable' — distinct from MATCH/MISMATCH
|
|
74
|
+
* because the diagnostic could not form a verdict.
|
|
75
|
+
*/
|
|
76
|
+
export function parseDiagnosticLines(logText) {
|
|
77
|
+
const shaRegex = /PACK_SHA_DIAGNOSTIC:\s*([^\n]+)/;
|
|
78
|
+
const integrityRegex = /PACK_INTEGRITY_DIAGNOSTIC:\s*([^\n]+)/;
|
|
79
|
+
|
|
80
|
+
const classifyVerdict = (detail) => {
|
|
81
|
+
if (!detail) return 'missing';
|
|
82
|
+
const head = detail.trim().split(/\s+/)[0] ?? '';
|
|
83
|
+
if (head === 'MATCH') return 'MATCH';
|
|
84
|
+
if (head === 'MISMATCH') return 'MISMATCH';
|
|
85
|
+
return 'unavailable';
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const shaMatch = logText.match(shaRegex);
|
|
89
|
+
const integrityMatch = logText.match(integrityRegex);
|
|
90
|
+
|
|
91
|
+
const shaDetail = shaMatch ? shaMatch[1].trim() : null;
|
|
92
|
+
const integrityDetail = integrityMatch ? integrityMatch[1].trim() : null;
|
|
93
|
+
|
|
94
|
+
const shaVerdict = shaMatch ? classifyVerdict(shaDetail) : 'missing';
|
|
95
|
+
const integrityVerdict = integrityMatch
|
|
96
|
+
? classifyVerdict(integrityDetail)
|
|
97
|
+
: 'missing';
|
|
98
|
+
|
|
99
|
+
// Try to pull `agentxchain@X.Y.Z` from either diagnostic line.
|
|
100
|
+
let version = null;
|
|
101
|
+
const versionSource = `${shaDetail ?? ''} ${integrityDetail ?? ''}`;
|
|
102
|
+
const versionMatch = versionSource.match(/agentxchain@(\d+\.\d+\.\d+)/);
|
|
103
|
+
if (versionMatch) version = versionMatch[1];
|
|
104
|
+
|
|
105
|
+
return { shaVerdict, shaDetail, integrityVerdict, integrityDetail, version };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Render an array of run records as a fixed-width text table.
|
|
110
|
+
* Pure function, no side effects — safe to call from tests.
|
|
111
|
+
*/
|
|
112
|
+
export function renderTable(rows) {
|
|
113
|
+
if (rows.length === 0) {
|
|
114
|
+
return 'No publish-npm-on-tag.yml runs found.';
|
|
115
|
+
}
|
|
116
|
+
const header = ['version', 'run_id', 'sha', 'integrity', 'created_at', 'url'];
|
|
117
|
+
const body = rows.map((r) => [
|
|
118
|
+
r.version ?? '-',
|
|
119
|
+
String(r.runId ?? '-'),
|
|
120
|
+
r.shaVerdict,
|
|
121
|
+
r.integrityVerdict,
|
|
122
|
+
r.createdAt ?? '-',
|
|
123
|
+
r.url ?? '-',
|
|
124
|
+
]);
|
|
125
|
+
const widths = header.map((h, i) =>
|
|
126
|
+
Math.max(h.length, ...body.map((row) => row[i].length)),
|
|
127
|
+
);
|
|
128
|
+
const pad = (cells) =>
|
|
129
|
+
cells.map((c, i) => c.padEnd(widths[i])).join(' ');
|
|
130
|
+
const lines = [pad(header), pad(widths.map((w) => '-'.repeat(w)))];
|
|
131
|
+
for (const row of body) lines.push(pad(row));
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Summarize MATCH/MISMATCH/unavailable/missing counts across rows.
|
|
137
|
+
* Used by `renderTable` callers to emit the "≥3 releases of MATCH" threshold
|
|
138
|
+
* status described in DEC-PUBLISH-WORKFLOW-PACK-SHA-DIAGNOSTIC-ONLY-001.
|
|
139
|
+
*/
|
|
140
|
+
export function summarize(rows) {
|
|
141
|
+
const count = (field) => {
|
|
142
|
+
const tally = { MATCH: 0, MISMATCH: 0, unavailable: 0, missing: 0 };
|
|
143
|
+
for (const r of rows) {
|
|
144
|
+
const verdict = r[field];
|
|
145
|
+
if (verdict in tally) tally[verdict] += 1;
|
|
146
|
+
}
|
|
147
|
+
return tally;
|
|
148
|
+
};
|
|
149
|
+
const sha = count('shaVerdict');
|
|
150
|
+
const integrity = count('integrityVerdict');
|
|
151
|
+
return { totalRuns: rows.length, sha, integrity };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseArgs(argv) {
|
|
155
|
+
const args = {
|
|
156
|
+
limit: DEFAULT_LIMIT,
|
|
157
|
+
workflow: DEFAULT_WORKFLOW,
|
|
158
|
+
format: 'table',
|
|
159
|
+
logFile: null,
|
|
160
|
+
repo: null,
|
|
161
|
+
};
|
|
162
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
163
|
+
const arg = argv[i];
|
|
164
|
+
if (arg === '--limit') {
|
|
165
|
+
args.limit = Number(argv[i + 1]);
|
|
166
|
+
i += 1;
|
|
167
|
+
} else if (arg === '--workflow') {
|
|
168
|
+
args.workflow = argv[i + 1];
|
|
169
|
+
i += 1;
|
|
170
|
+
} else if (arg === '--format') {
|
|
171
|
+
args.format = argv[i + 1];
|
|
172
|
+
i += 1;
|
|
173
|
+
} else if (arg === '--log-file') {
|
|
174
|
+
args.logFile = argv[i + 1];
|
|
175
|
+
i += 1;
|
|
176
|
+
} else if (arg === '--repo') {
|
|
177
|
+
args.repo = argv[i + 1];
|
|
178
|
+
i += 1;
|
|
179
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
180
|
+
args.help = true;
|
|
181
|
+
} else {
|
|
182
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!Number.isInteger(args.limit) || args.limit <= 0) {
|
|
186
|
+
throw new Error(`--limit must be a positive integer, got: ${args.limit}`);
|
|
187
|
+
}
|
|
188
|
+
if (!['table', 'json'].includes(args.format)) {
|
|
189
|
+
throw new Error(`--format must be "table" or "json", got: ${args.format}`);
|
|
190
|
+
}
|
|
191
|
+
return args;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function printHelp() {
|
|
195
|
+
process.stdout.write(
|
|
196
|
+
[
|
|
197
|
+
'Usage: node cli/scripts/collect-pack-sha-diagnostic.mjs [options]',
|
|
198
|
+
'',
|
|
199
|
+
'Options:',
|
|
200
|
+
' --limit <N> Number of recent runs to inspect (default: 10)',
|
|
201
|
+
' --workflow <name> Workflow filename (default: publish-npm-on-tag.yml)',
|
|
202
|
+
' --format table|json Output format (default: table)',
|
|
203
|
+
' --log-file <path> Parse a single saved log file instead of calling gh',
|
|
204
|
+
' --repo <owner/name> Override repo (defaults to gh current repo)',
|
|
205
|
+
' -h, --help Show this help',
|
|
206
|
+
'',
|
|
207
|
+
'Emits MATCH/MISMATCH/unavailable/missing counts for PACK_SHA_DIAGNOSTIC',
|
|
208
|
+
'and PACK_INTEGRITY_DIAGNOSTIC tags. Diagnostic-only; never fails.',
|
|
209
|
+
'',
|
|
210
|
+
].join('\n'),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ghJson(args) {
|
|
215
|
+
const out = execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
216
|
+
return JSON.parse(out);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function ghText(args) {
|
|
220
|
+
return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function collectFromGh({ limit, workflow, repo }) {
|
|
224
|
+
const listArgs = [
|
|
225
|
+
'run', 'list',
|
|
226
|
+
'--workflow', workflow,
|
|
227
|
+
'--limit', String(limit),
|
|
228
|
+
'--json', 'databaseId,displayTitle,conclusion,createdAt,url,headBranch,headSha',
|
|
229
|
+
];
|
|
230
|
+
if (repo) listArgs.push('--repo', repo);
|
|
231
|
+
|
|
232
|
+
let runs;
|
|
233
|
+
try {
|
|
234
|
+
runs = ghJson(listArgs);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Failed to list workflow runs via gh. Is the GitHub CLI installed and authenticated? (${err.message})`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rows = [];
|
|
242
|
+
for (const run of runs) {
|
|
243
|
+
const viewArgs = ['run', 'view', String(run.databaseId), '--log'];
|
|
244
|
+
if (repo) viewArgs.push('--repo', repo);
|
|
245
|
+
let log = '';
|
|
246
|
+
try {
|
|
247
|
+
log = ghText(viewArgs);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// gh run view --log fails when logs are expired (>90d) or mid-run.
|
|
250
|
+
// Record the run with missing verdicts rather than aborting the whole sweep.
|
|
251
|
+
rows.push({
|
|
252
|
+
runId: run.databaseId,
|
|
253
|
+
displayTitle: run.displayTitle,
|
|
254
|
+
conclusion: run.conclusion,
|
|
255
|
+
createdAt: run.createdAt,
|
|
256
|
+
url: run.url,
|
|
257
|
+
headBranch: run.headBranch,
|
|
258
|
+
headSha: run.headSha,
|
|
259
|
+
shaVerdict: 'missing',
|
|
260
|
+
integrityVerdict: 'missing',
|
|
261
|
+
shaDetail: null,
|
|
262
|
+
integrityDetail: null,
|
|
263
|
+
version: null,
|
|
264
|
+
logError: err.message,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const parsed = parseDiagnosticLines(log);
|
|
269
|
+
rows.push({
|
|
270
|
+
runId: run.databaseId,
|
|
271
|
+
displayTitle: run.displayTitle,
|
|
272
|
+
conclusion: run.conclusion,
|
|
273
|
+
createdAt: run.createdAt,
|
|
274
|
+
url: run.url,
|
|
275
|
+
headBranch: run.headBranch,
|
|
276
|
+
headSha: run.headSha,
|
|
277
|
+
...parsed,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return rows;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function main(argv) {
|
|
284
|
+
let args;
|
|
285
|
+
try {
|
|
286
|
+
args = parseArgs(argv);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
process.stderr.write(`collect-pack-sha-diagnostic: ${err.message}\n`);
|
|
289
|
+
printHelp();
|
|
290
|
+
process.exit(2);
|
|
291
|
+
}
|
|
292
|
+
if (args.help) {
|
|
293
|
+
printHelp();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let rows;
|
|
298
|
+
if (args.logFile) {
|
|
299
|
+
const log = readFileSync(args.logFile, 'utf8');
|
|
300
|
+
rows = [{ runId: null, createdAt: null, url: args.logFile, ...parseDiagnosticLines(log) }];
|
|
301
|
+
} else {
|
|
302
|
+
rows = collectFromGh({ limit: args.limit, workflow: args.workflow, repo: args.repo });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (args.format === 'json') {
|
|
306
|
+
process.stdout.write(JSON.stringify({ rows, summary: summarize(rows) }, null, 2));
|
|
307
|
+
process.stdout.write('\n');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const table = renderTable(rows);
|
|
312
|
+
const summary = summarize(rows);
|
|
313
|
+
process.stdout.write(`${table}\n\n`);
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
[
|
|
316
|
+
`Runs inspected: ${summary.totalRuns}`,
|
|
317
|
+
`SHA MATCH: ${summary.sha.MATCH}`,
|
|
318
|
+
`SHA MISMATCH: ${summary.sha.MISMATCH}`,
|
|
319
|
+
`SHA unavailable: ${summary.sha.unavailable}`,
|
|
320
|
+
`SHA missing: ${summary.sha.missing} (rerun / no diagnostic)`,
|
|
321
|
+
`INTEGRITY MATCH: ${summary.integrity.MATCH}`,
|
|
322
|
+
`INTEGRITY MISMATCH: ${summary.integrity.MISMATCH}`,
|
|
323
|
+
`INTEGRITY unavailable: ${summary.integrity.unavailable}`,
|
|
324
|
+
`INTEGRITY missing: ${summary.integrity.missing}`,
|
|
325
|
+
'',
|
|
326
|
+
'Diagnostic-only. ≥3 MATCH on both SHA + INTEGRITY is the threshold',
|
|
327
|
+
'named in DEC-PUBLISH-WORKFLOW-PACK-SHA-DIAGNOSTIC-ONLY-001 before any',
|
|
328
|
+
'reproducible-publish gate can be designed.',
|
|
329
|
+
'',
|
|
330
|
+
].join('\n'),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Only run main when invoked directly (not when imported by tests).
|
|
335
|
+
const invokedDirectly =
|
|
336
|
+
import.meta.url === `file://${process.argv[1]}` ||
|
|
337
|
+
(process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, '')));
|
|
338
|
+
|
|
339
|
+
if (invokedDirectly) {
|
|
340
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
341
|
+
process.stderr.write(`collect-pack-sha-diagnostic: ${err.stack || err.message}\n`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
});
|
|
344
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -195,15 +195,44 @@ const GOVERNED_ROUTING = {
|
|
|
195
195
|
const GOVERNED_GATES = {
|
|
196
196
|
planning_signoff: {
|
|
197
197
|
requires_files: ['.planning/PM_SIGNOFF.md', '.planning/ROADMAP.md', '.planning/SYSTEM_SPEC.md'],
|
|
198
|
-
requires_human_approval: true
|
|
198
|
+
requires_human_approval: true,
|
|
199
|
+
credentialed: false
|
|
199
200
|
},
|
|
200
201
|
implementation_complete: {
|
|
201
202
|
requires_files: ['.planning/IMPLEMENTATION_NOTES.md'],
|
|
202
|
-
requires_verification_pass: true
|
|
203
|
+
requires_verification_pass: true,
|
|
204
|
+
credentialed: false
|
|
203
205
|
},
|
|
204
206
|
qa_ship_verdict: {
|
|
205
207
|
requires_files: ['.planning/acceptance-matrix.md', '.planning/ship-verdict.md', '.planning/RELEASE_NOTES.md'],
|
|
206
|
-
requires_human_approval: true
|
|
208
|
+
requires_human_approval: true,
|
|
209
|
+
requires_verification_pass: true,
|
|
210
|
+
credentialed: false
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const GOVERNED_APPROVAL_POLICY = {
|
|
215
|
+
phase_transitions: {
|
|
216
|
+
default: 'require_human',
|
|
217
|
+
rules: [
|
|
218
|
+
{
|
|
219
|
+
from_phase: 'planning',
|
|
220
|
+
to_phase: 'implementation',
|
|
221
|
+
action: 'auto_approve',
|
|
222
|
+
when: {
|
|
223
|
+
gate_passed: true,
|
|
224
|
+
credentialed_gate: false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
run_completion: {
|
|
230
|
+
action: 'auto_approve',
|
|
231
|
+
when: {
|
|
232
|
+
gate_passed: true,
|
|
233
|
+
all_phases_visited: true,
|
|
234
|
+
credentialed_gate: false
|
|
235
|
+
}
|
|
207
236
|
}
|
|
208
237
|
};
|
|
209
238
|
|
|
@@ -713,6 +742,7 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
|
|
|
713
742
|
|
|
714
743
|
const routing = cloneJsonCompatible(blueprint?.routing || GOVERNED_ROUTING);
|
|
715
744
|
const gates = cloneJsonCompatible(blueprint?.gates || GOVERNED_GATES);
|
|
745
|
+
const approvalPolicy = cloneJsonCompatible(blueprint?.approval_policy || GOVERNED_APPROVAL_POLICY);
|
|
716
746
|
const effectiveWorkflowKitConfig = workflowKitConfig || cloneJsonCompatible(blueprint?.workflow_kit || null);
|
|
717
747
|
const prompts = Object.fromEntries(
|
|
718
748
|
Object.keys(roles).map((roleId) => [roleId, `.agentxchain/prompts/${roleId}.md`])
|
|
@@ -725,6 +755,7 @@ function buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitC
|
|
|
725
755
|
runtimes,
|
|
726
756
|
routing,
|
|
727
757
|
gates,
|
|
758
|
+
approvalPolicy,
|
|
728
759
|
policies,
|
|
729
760
|
prompts,
|
|
730
761
|
workflowKitConfig: effectiveWorkflowKitConfig,
|
|
@@ -778,7 +809,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
|
|
|
778
809
|
const template = loadGovernedTemplate(templateId);
|
|
779
810
|
const { runtime: localDevRuntime } = resolveGovernedLocalDevRuntime(runtimeOptions);
|
|
780
811
|
const scaffoldConfig = buildScaffoldConfigFromTemplate(template, localDevRuntime, workflowKitConfig, runtimeOptions);
|
|
781
|
-
const { roles, runtimes, routing, gates, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
|
|
812
|
+
const { roles, runtimes, routing, gates, approvalPolicy, policies, prompts, workflowKitConfig: effectiveWorkflowKitConfig } = scaffoldConfig;
|
|
782
813
|
const scaffoldWorkflowKitConfig = effectiveWorkflowKitConfig
|
|
783
814
|
? normalizeWorkflowKit(effectiveWorkflowKitConfig, Object.keys(routing))
|
|
784
815
|
: null;
|
|
@@ -804,6 +835,7 @@ export function scaffoldGoverned(dir, projectName, projectId, templateId = 'gene
|
|
|
804
835
|
runtimes,
|
|
805
836
|
routing,
|
|
806
837
|
gates,
|
|
838
|
+
approval_policy: approvalPolicy,
|
|
807
839
|
budget: {
|
|
808
840
|
per_turn_max_usd: 2.0,
|
|
809
841
|
per_run_max_usd: 50.0,
|
package/src/commands/resume.js
CHANGED
|
@@ -94,6 +94,7 @@ export async function resumeCommand(opts) {
|
|
|
94
94
|
const activeTurns = getActiveTurns(state);
|
|
95
95
|
const resumeVia = opts?._via || 'resume';
|
|
96
96
|
const turnResumeVia = opts?._via || 'resume --turn';
|
|
97
|
+
let skipRetainedRedispatch = false;
|
|
97
98
|
|
|
98
99
|
if (state.status === 'active' && activeCount > 0) {
|
|
99
100
|
if (activeCount === 1) {
|
|
@@ -142,7 +143,53 @@ export async function resumeCommand(opts) {
|
|
|
142
143
|
// patched defensively) once the schema citation + migration citation are
|
|
143
144
|
// documented in code and the coverage matrix.
|
|
144
145
|
|
|
145
|
-
if (state.status === 'blocked' && activeCount > 0) {
|
|
146
|
+
if (state.status === 'blocked' && activeCount > 0 && resumeVia === 'operator_unblock') {
|
|
147
|
+
const reactivated = reactivateGovernedRun(root, state, { via: resumeVia, notificationConfig: config });
|
|
148
|
+
if (!reactivated.ok) {
|
|
149
|
+
console.log(chalk.red(`Failed to reactivate blocked run: ${reactivated.error}`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
state = reactivated.state;
|
|
153
|
+
console.log(chalk.green(`Resumed blocked run: ${state.run_id}`));
|
|
154
|
+
if (reactivated.migration_notice) {
|
|
155
|
+
console.log(chalk.yellow(reactivated.migration_notice));
|
|
156
|
+
}
|
|
157
|
+
if (reactivated.phantom_notice) {
|
|
158
|
+
console.log(chalk.yellow(reactivated.phantom_notice));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const phaseReconciliation = reconcilePhaseAdvanceBeforeDispatch(root, config, state, {
|
|
162
|
+
allow_active_turn_cleanup: true,
|
|
163
|
+
allow_standing_gate: true,
|
|
164
|
+
});
|
|
165
|
+
if (!phaseReconciliation.ok && !phaseReconciliation.state) {
|
|
166
|
+
console.log(chalk.red(`Failed to reconcile phase gate before dispatch: ${phaseReconciliation.error}`));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
state = phaseReconciliation.state || state;
|
|
170
|
+
if (phaseReconciliation.advanced) {
|
|
171
|
+
console.log(chalk.green(`Advanced phase before dispatch: ${phaseReconciliation.from_phase} → ${phaseReconciliation.to_phase}`));
|
|
172
|
+
skipRetainedRedispatch = true;
|
|
173
|
+
} else {
|
|
174
|
+
markRunBlocked(root, {
|
|
175
|
+
category: 'needs_human',
|
|
176
|
+
blockedOn: state.blocked_on || 'human:unblock_reconcile_failed',
|
|
177
|
+
recovery: {
|
|
178
|
+
typed_reason: 'needs_human',
|
|
179
|
+
owner: 'human',
|
|
180
|
+
recovery_action: 'agentxchain approve-transition or agentxchain gate show <gate>',
|
|
181
|
+
turn_retained: true,
|
|
182
|
+
detail: 'Operator unblock resolved the escalation, but no phase transition could be materialized from the current gate state.',
|
|
183
|
+
},
|
|
184
|
+
turnId: opts.turn || null,
|
|
185
|
+
notificationConfig: config,
|
|
186
|
+
});
|
|
187
|
+
console.log(chalk.red('Unblock did not materialize a phase transition; leaving the run blocked for manual recovery.'));
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (state.status === 'blocked' && activeCount > 0 && !skipRetainedRedispatch) {
|
|
146
193
|
let retainedTurn = null;
|
|
147
194
|
if (opts.turn) {
|
|
148
195
|
retainedTurn = activeTurns[opts.turn];
|
|
@@ -41,6 +41,7 @@ const DIAGNOSTIC_ENV_KEYS = [
|
|
|
41
41
|
'AGENTXCHAIN_TURN_ID',
|
|
42
42
|
];
|
|
43
43
|
const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
|
|
44
|
+
const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Launch a local CLI subprocess for a governed turn.
|
|
@@ -579,7 +580,7 @@ function resolveStartupWatchdogMs(config, runtime) {
|
|
|
579
580
|
if (Number.isInteger(config?.run_loop?.startup_watchdog_ms) && config.run_loop.startup_watchdog_ms > 0) {
|
|
580
581
|
return config.run_loop.startup_watchdog_ms;
|
|
581
582
|
}
|
|
582
|
-
return
|
|
583
|
+
return DEFAULT_STARTUP_WATCHDOG_MS;
|
|
583
584
|
}
|
|
584
585
|
|
|
585
586
|
/**
|
|
@@ -37,7 +37,26 @@ export function evaluateApprovalPolicy({ gateResult, gateType, state, config })
|
|
|
37
37
|
return evaluatePhaseTransitionPolicy({ gateResult, state, config, policy });
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// BUG-59 (DEC-BUG59-CREDENTIALED-GATE-HARD-STOP-001): gate definitions may
|
|
41
|
+
// carry `credentialed: true` to mark gates protecting external, irreversible,
|
|
42
|
+
// or operator-owned credentialed actions. Credentialed gates are never
|
|
43
|
+
// auto-approvable by policy, even under a catch-all `default: auto_approve`
|
|
44
|
+
// rule. The guard runs before any rule evaluation so a missing `when` block
|
|
45
|
+
// cannot bypass it.
|
|
46
|
+
function isCredentialedGate(config, gateId) {
|
|
47
|
+
if (!gateId) return false;
|
|
48
|
+
return config?.gates?.[gateId]?.credentialed === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
|
|
52
|
+
if (isCredentialedGate(config, gateResult?.gate_id)) {
|
|
53
|
+
return {
|
|
54
|
+
action: 'require_human',
|
|
55
|
+
matched_rule: null,
|
|
56
|
+
reason: 'credentialed gate — policy auto-approval forbidden',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
const rc = policy.run_completion;
|
|
42
61
|
if (!rc || !rc.action) {
|
|
43
62
|
return { action: 'require_human', matched_rule: null, reason: 'no run_completion policy' };
|
|
@@ -59,6 +78,14 @@ function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
|
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
function evaluatePhaseTransitionPolicy({ gateResult, state, config, policy }) {
|
|
81
|
+
if (isCredentialedGate(config, gateResult?.gate_id)) {
|
|
82
|
+
return {
|
|
83
|
+
action: 'require_human',
|
|
84
|
+
matched_rule: null,
|
|
85
|
+
reason: 'credentialed gate — policy auto-approval forbidden',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
const pt = policy.phase_transitions;
|
|
63
90
|
if (!pt) {
|
|
64
91
|
return { action: 'require_human', matched_rule: null, reason: 'no phase_transitions policy' };
|
|
@@ -120,6 +147,23 @@ function checkConditions(when, { gateResult, state, config }) {
|
|
|
120
147
|
}
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
// credentialed_gate (BUG-59, DEC-BUG59-CREDENTIALED-GATE-PREDICATE-NEGATIVE-ONLY-001):
|
|
151
|
+
// only `false` is a valid runtime value — asserts the gate is NOT credentialed
|
|
152
|
+
// as a defensive precondition. Credentialed gates are hard-stopped upstream so
|
|
153
|
+
// this predicate never sees them when value is `false` (matches → condition ok).
|
|
154
|
+
// Value `true` is treated as unmet because the hard-stop prevents credentialed
|
|
155
|
+
// gates from reaching condition evaluation anyway; schema validation (slice 2)
|
|
156
|
+
// will reject `true` at config load time for unambiguous intent.
|
|
157
|
+
if (Object.prototype.hasOwnProperty.call(when, 'credentialed_gate')) {
|
|
158
|
+
const gateIsCredentialed = config?.gates?.[gateResult?.gate_id]?.credentialed === true;
|
|
159
|
+
if (when.credentialed_gate === false && gateIsCredentialed) {
|
|
160
|
+
return { ok: false, reason: 'condition credentialed_gate: false not met — gate is credentialed' };
|
|
161
|
+
}
|
|
162
|
+
if (when.credentialed_gate === true) {
|
|
163
|
+
return { ok: false, reason: 'condition credentialed_gate: true not supported — credentialed gates are hard-stopped upstream' };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
123
167
|
// all_phases_visited: every routing phase must appear in history
|
|
124
168
|
if (when.all_phases_visited === true) {
|
|
125
169
|
const routingPhases = Object.keys(config.routing || {});
|
|
@@ -1600,6 +1600,95 @@ function resolvePhaseTransitionSource(historyEntries, gateFailure, fallbackTurnI
|
|
|
1600
1600
|
return requestedSource;
|
|
1601
1601
|
}
|
|
1602
1602
|
|
|
1603
|
+
function buildStandingPhaseTransitionSource(state, config) {
|
|
1604
|
+
const phase = state?.phase;
|
|
1605
|
+
const routing = phase ? config?.routing?.[phase] : null;
|
|
1606
|
+
const gateId = routing?.exit_gate || null;
|
|
1607
|
+
const nextPhase = getNextPhase(phase, config?.routing || {});
|
|
1608
|
+
if (!phase || !gateId || !nextPhase) {
|
|
1609
|
+
return null;
|
|
1610
|
+
}
|
|
1611
|
+
if ((state?.phase_gate_status || {})[gateId] !== 'pending') {
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
turn_id: state?.last_completed_turn_id || state?.blocked_reason?.turn_id || null,
|
|
1616
|
+
run_id: state?.run_id || null,
|
|
1617
|
+
role: null,
|
|
1618
|
+
assigned_role: null,
|
|
1619
|
+
phase,
|
|
1620
|
+
status: 'completed',
|
|
1621
|
+
phase_transition_request: nextPhase,
|
|
1622
|
+
summary: `Synthetic ${gateId} transition source for operator-unblocked standing gate.`,
|
|
1623
|
+
verification: { status: 'pass' },
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function getPhaseRoles(config, phase) {
|
|
1628
|
+
const routing = config?.routing?.[phase] || {};
|
|
1629
|
+
const roles = new Set();
|
|
1630
|
+
if (typeof routing.entry_role === 'string' && routing.entry_role) {
|
|
1631
|
+
roles.add(routing.entry_role);
|
|
1632
|
+
}
|
|
1633
|
+
if (Array.isArray(routing.allowed_next_roles)) {
|
|
1634
|
+
for (const role of routing.allowed_next_roles) {
|
|
1635
|
+
if (typeof role === 'string' && role) roles.add(role);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return roles;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function cleanupPhaseAdvanceArtifacts(root, state, config, fromPhase) {
|
|
1642
|
+
const phaseRoles = getPhaseRoles(config, fromPhase);
|
|
1643
|
+
const activeTurns = getActiveTurns(state);
|
|
1644
|
+
const removedTurnIds = [];
|
|
1645
|
+
const nextActiveTurns = {};
|
|
1646
|
+
for (const [turnId, turn] of Object.entries(activeTurns)) {
|
|
1647
|
+
const role = turn?.assigned_role || turn?.role_id || turn?.role || null;
|
|
1648
|
+
if (phaseRoles.has(role) && turn?.status !== 'accepted' && turn?.status !== 'completed') {
|
|
1649
|
+
removedTurnIds.push(turnId);
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
nextActiveTurns[turnId] = turn;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const nextReservations = { ...(state?.budget_reservations || {}) };
|
|
1656
|
+
const clearedBudgetTurnIds = [];
|
|
1657
|
+
for (const turnId of removedTurnIds) {
|
|
1658
|
+
if (Object.prototype.hasOwnProperty.call(nextReservations, turnId)) {
|
|
1659
|
+
delete nextReservations[turnId];
|
|
1660
|
+
clearedBudgetTurnIds.push(turnId);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const removedDispatchTurnIds = [];
|
|
1665
|
+
for (const turnId of removedTurnIds) {
|
|
1666
|
+
const dispatchDir = join(root, getDispatchTurnDir(turnId));
|
|
1667
|
+
if (existsSync(dispatchDir)) {
|
|
1668
|
+
try {
|
|
1669
|
+
rmSync(dispatchDir, { recursive: true, force: true });
|
|
1670
|
+
removedDispatchTurnIds.push(turnId);
|
|
1671
|
+
} catch {
|
|
1672
|
+
// Best-effort cleanup; state correctness must not depend on filesystem pruning.
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
return {
|
|
1678
|
+
state: {
|
|
1679
|
+
...state,
|
|
1680
|
+
active_turns: nextActiveTurns,
|
|
1681
|
+
budget_reservations: nextReservations,
|
|
1682
|
+
},
|
|
1683
|
+
payload: {
|
|
1684
|
+
from_phase: fromPhase,
|
|
1685
|
+
removed_turn_ids: removedTurnIds,
|
|
1686
|
+
cleared_budget_turn_ids: clearedBudgetTurnIds,
|
|
1687
|
+
removed_dispatch_turn_ids: removedDispatchTurnIds,
|
|
1688
|
+
},
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1603
1692
|
function buildBlockedReason({ category, recovery, turnId, blockedAt = new Date().toISOString() }) {
|
|
1604
1693
|
return {
|
|
1605
1694
|
category,
|
|
@@ -2355,7 +2444,7 @@ export function markRunBlocked(root, details) {
|
|
|
2355
2444
|
blockedAt,
|
|
2356
2445
|
});
|
|
2357
2446
|
|
|
2358
|
-
|
|
2447
|
+
let updatedState = {
|
|
2359
2448
|
...state,
|
|
2360
2449
|
status: 'blocked',
|
|
2361
2450
|
blocked_on: details.blockedOn,
|
|
@@ -2607,13 +2696,15 @@ export function reactivateGovernedRun(root, state, details = {}) {
|
|
|
2607
2696
|
};
|
|
2608
2697
|
}
|
|
2609
2698
|
|
|
2610
|
-
export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null) {
|
|
2699
|
+
export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null, opts = {}) {
|
|
2611
2700
|
const currentState = state && typeof state === 'object' ? state : readState(root);
|
|
2612
2701
|
if (!currentState) {
|
|
2613
2702
|
return { ok: false, error: 'No governed state.json found' };
|
|
2614
2703
|
}
|
|
2615
2704
|
|
|
2616
|
-
|
|
2705
|
+
const activeTurnCount = getActiveTurnCount(currentState);
|
|
2706
|
+
const allowActiveTurnCleanup = opts?.allow_active_turn_cleanup === true;
|
|
2707
|
+
if (currentState.status !== 'active' || (activeTurnCount > 0 && !allowActiveTurnCleanup)) {
|
|
2617
2708
|
return {
|
|
2618
2709
|
ok: true,
|
|
2619
2710
|
state: attachLegacyCurrentTurnAlias(currentState),
|
|
@@ -2643,12 +2734,15 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2643
2734
|
}
|
|
2644
2735
|
|
|
2645
2736
|
const historyEntries = readJsonlEntries(root, HISTORY_PATH);
|
|
2646
|
-
|
|
2737
|
+
let phaseSource = resolvePhaseTransitionSource(
|
|
2647
2738
|
historyEntries,
|
|
2648
2739
|
gateFailure,
|
|
2649
2740
|
currentState.last_completed_turn_id || null,
|
|
2650
2741
|
currentState.queued_phase_transition || null,
|
|
2651
2742
|
);
|
|
2743
|
+
if (!phaseSource?.phase_transition_request && opts?.allow_standing_gate === true) {
|
|
2744
|
+
phaseSource = buildStandingPhaseTransitionSource(currentState, config);
|
|
2745
|
+
}
|
|
2652
2746
|
if (!phaseSource?.phase_transition_request) {
|
|
2653
2747
|
return {
|
|
2654
2748
|
ok: true,
|
|
@@ -2665,6 +2759,104 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2665
2759
|
});
|
|
2666
2760
|
|
|
2667
2761
|
if (gateResult.action === 'awaiting_human_approval') {
|
|
2762
|
+
// BUG-59 (DEC-BUG59-PLAN-LAYERED-FIX-001, slice 3): before falling back to
|
|
2763
|
+
// the BUG-52 "human already unblocked" advancement path, consult
|
|
2764
|
+
// approval_policy. If the configured policy auto-approves this transition
|
|
2765
|
+
// (and the gate is not credentialed), advance directly and write an
|
|
2766
|
+
// `approval_policy` ledger entry matching the accepted-turn path shape at
|
|
2767
|
+
// governed-state.js:4909-4919. Credentialed gates are hard-stopped inside
|
|
2768
|
+
// evaluateApprovalPolicy per DEC-BUG59-CREDENTIALED-GATE-HARD-STOP-001, so
|
|
2769
|
+
// a credentialed gate lands here with action === 'require_human' and falls
|
|
2770
|
+
// through to the existing approvePhaseTransition path (which itself
|
|
2771
|
+
// requires paused/blocked status produced by a real human unblock).
|
|
2772
|
+
const approvalResult = evaluateApprovalPolicy({
|
|
2773
|
+
gateResult,
|
|
2774
|
+
gateType: 'phase_transition',
|
|
2775
|
+
state: { ...currentState, history: historyEntries },
|
|
2776
|
+
config,
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
if (approvalResult.action === 'auto_approve') {
|
|
2780
|
+
const now = new Date().toISOString();
|
|
2781
|
+
const prevPhase = currentState.phase;
|
|
2782
|
+
let nextState = {
|
|
2783
|
+
...currentState,
|
|
2784
|
+
phase: gateResult.next_phase,
|
|
2785
|
+
phase_entered_at: now,
|
|
2786
|
+
blocked_on: null,
|
|
2787
|
+
blocked_reason: null,
|
|
2788
|
+
last_gate_failure: null,
|
|
2789
|
+
pending_phase_transition: null,
|
|
2790
|
+
queued_phase_transition: null,
|
|
2791
|
+
phase_gate_status: {
|
|
2792
|
+
...(currentState.phase_gate_status || {}),
|
|
2793
|
+
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
2794
|
+
},
|
|
2795
|
+
};
|
|
2796
|
+
const cleanup = cleanupPhaseAdvanceArtifacts(root, nextState, config, prevPhase);
|
|
2797
|
+
nextState = cleanup.state;
|
|
2798
|
+
writeState(root, nextState);
|
|
2799
|
+
appendJsonl(root, LEDGER_PATH, {
|
|
2800
|
+
type: 'approval_policy',
|
|
2801
|
+
gate_type: 'phase_transition',
|
|
2802
|
+
action: 'auto_approve',
|
|
2803
|
+
matched_rule: approvalResult.matched_rule,
|
|
2804
|
+
from_phase: prevPhase,
|
|
2805
|
+
to_phase: gateResult.next_phase,
|
|
2806
|
+
reason: approvalResult.reason,
|
|
2807
|
+
gate_id: gateResult.gate_id || null,
|
|
2808
|
+
timestamp: now,
|
|
2809
|
+
});
|
|
2810
|
+
const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
|
|
2811
|
+
if (retiredIntentIds.length > 0) {
|
|
2812
|
+
emitRunEvent(root, 'intent_retired_by_phase_advance', {
|
|
2813
|
+
run_id: nextState.run_id,
|
|
2814
|
+
phase: nextState.phase,
|
|
2815
|
+
status: nextState.status,
|
|
2816
|
+
turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
|
|
2817
|
+
payload: {
|
|
2818
|
+
exited_phase: prevPhase,
|
|
2819
|
+
entered_phase: gateResult.next_phase,
|
|
2820
|
+
retired_count: retiredIntentIds.length,
|
|
2821
|
+
retired_intent_ids: retiredIntentIds,
|
|
2822
|
+
},
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
emitRunEvent(root, 'phase_entered', {
|
|
2826
|
+
run_id: nextState.run_id,
|
|
2827
|
+
phase: nextState.phase,
|
|
2828
|
+
status: nextState.status,
|
|
2829
|
+
turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
|
|
2830
|
+
payload: {
|
|
2831
|
+
from: prevPhase,
|
|
2832
|
+
to: gateResult.next_phase,
|
|
2833
|
+
gate_id: gateResult.gate_id || 'no_gate',
|
|
2834
|
+
trigger: 'auto_approved',
|
|
2835
|
+
},
|
|
2836
|
+
});
|
|
2837
|
+
emitRunEvent(root, 'phase_cleanup', {
|
|
2838
|
+
run_id: nextState.run_id,
|
|
2839
|
+
phase: nextState.phase,
|
|
2840
|
+
status: nextState.status,
|
|
2841
|
+
payload: {
|
|
2842
|
+
...cleanup.payload,
|
|
2843
|
+
to_phase: gateResult.next_phase,
|
|
2844
|
+
gate_id: gateResult.gate_id || null,
|
|
2845
|
+
trigger: 'auto_approved',
|
|
2846
|
+
},
|
|
2847
|
+
});
|
|
2848
|
+
return {
|
|
2849
|
+
ok: true,
|
|
2850
|
+
state: attachLegacyCurrentTurnAlias(nextState),
|
|
2851
|
+
advanced: true,
|
|
2852
|
+
from_phase: prevPhase,
|
|
2853
|
+
to_phase: gateResult.next_phase,
|
|
2854
|
+
gate_id: gateResult.gate_id || null,
|
|
2855
|
+
gateResult,
|
|
2856
|
+
approval_policy: approvalResult,
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2668
2860
|
const pausedState = {
|
|
2669
2861
|
...currentState,
|
|
2670
2862
|
status: 'paused',
|
|
@@ -2689,6 +2881,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2689
2881
|
to_phase: approved.state?.phase || gateResult.next_phase || null,
|
|
2690
2882
|
gate_id: gateResult.gate_id || null,
|
|
2691
2883
|
gateResult,
|
|
2884
|
+
approval_policy: approvalResult,
|
|
2692
2885
|
};
|
|
2693
2886
|
}
|
|
2694
2887
|
|
|
@@ -2703,7 +2896,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2703
2896
|
|
|
2704
2897
|
const now = new Date().toISOString();
|
|
2705
2898
|
const prevPhase = currentState.phase;
|
|
2706
|
-
|
|
2899
|
+
let nextState = {
|
|
2707
2900
|
...currentState,
|
|
2708
2901
|
phase: gateResult.next_phase,
|
|
2709
2902
|
phase_entered_at: now,
|
|
@@ -2717,6 +2910,8 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2717
2910
|
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
2718
2911
|
},
|
|
2719
2912
|
};
|
|
2913
|
+
const cleanup = cleanupPhaseAdvanceArtifacts(root, nextState, config, prevPhase);
|
|
2914
|
+
nextState = cleanup.state;
|
|
2720
2915
|
|
|
2721
2916
|
writeState(root, nextState);
|
|
2722
2917
|
const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
|
|
@@ -2746,6 +2941,18 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
|
|
|
2746
2941
|
trigger: 'reconciled_before_dispatch',
|
|
2747
2942
|
},
|
|
2748
2943
|
});
|
|
2944
|
+
emitRunEvent(root, 'phase_cleanup', {
|
|
2945
|
+
run_id: nextState.run_id,
|
|
2946
|
+
phase: nextState.phase,
|
|
2947
|
+
status: nextState.status,
|
|
2948
|
+
turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
|
|
2949
|
+
payload: {
|
|
2950
|
+
...cleanup.payload,
|
|
2951
|
+
to_phase: gateResult.next_phase,
|
|
2952
|
+
gate_id: gateResult.gate_id || null,
|
|
2953
|
+
trigger: 'reconciled_before_dispatch',
|
|
2954
|
+
},
|
|
2955
|
+
});
|
|
2749
2956
|
|
|
2750
2957
|
return {
|
|
2751
2958
|
ok: true,
|
|
@@ -2790,7 +2997,7 @@ export function initializeGovernedRun(root, config, options = {}) {
|
|
|
2790
2997
|
const now = new Date().toISOString();
|
|
2791
2998
|
const provenance = buildDefaultRunProvenance(options.provenance);
|
|
2792
2999
|
const repoDecisions = getActiveRepoDecisions(root);
|
|
2793
|
-
|
|
3000
|
+
let updatedState = {
|
|
2794
3001
|
...state,
|
|
2795
3002
|
run_id: runId,
|
|
2796
3003
|
created_at: now,
|
|
@@ -4518,7 +4725,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4518
4725
|
const remainingReservations = { ...(state.budget_reservations || {}) };
|
|
4519
4726
|
delete remainingReservations[currentTurn.turn_id];
|
|
4520
4727
|
const costUsd = turnResult.cost?.usd || 0;
|
|
4521
|
-
|
|
4728
|
+
let updatedState = {
|
|
4522
4729
|
...state,
|
|
4523
4730
|
turn_sequence: acceptedSequence,
|
|
4524
4731
|
last_completed_turn_id: currentTurn.turn_id,
|
|
@@ -4860,6 +5067,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4860
5067
|
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
4861
5068
|
};
|
|
4862
5069
|
updatedState.queued_phase_transition = null;
|
|
5070
|
+
const cleanup = cleanupPhaseAdvanceArtifacts(root, updatedState, config, prevPhase);
|
|
5071
|
+
updatedState = cleanup.state;
|
|
4863
5072
|
const retiredIntentIds = retireApprovedPhaseScopedIntents(root, updatedState, config, prevPhase, now);
|
|
4864
5073
|
if (retiredIntentIds.length > 0) {
|
|
4865
5074
|
emitRunEvent(root, 'intent_retired_by_phase_advance', {
|
|
@@ -4887,6 +5096,18 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4887
5096
|
trigger: 'auto',
|
|
4888
5097
|
},
|
|
4889
5098
|
});
|
|
5099
|
+
emitRunEvent(root, 'phase_cleanup', {
|
|
5100
|
+
run_id: updatedState.run_id,
|
|
5101
|
+
phase: updatedState.phase,
|
|
5102
|
+
status: updatedState.status,
|
|
5103
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
5104
|
+
payload: {
|
|
5105
|
+
...cleanup.payload,
|
|
5106
|
+
to_phase: gateResult.next_phase,
|
|
5107
|
+
gate_id: gateResult.gate_id || null,
|
|
5108
|
+
trigger: 'auto',
|
|
5109
|
+
},
|
|
5110
|
+
});
|
|
4890
5111
|
} else if (gateResult.action === 'awaiting_human_approval') {
|
|
4891
5112
|
// Evaluate approval policy — may auto-approve
|
|
4892
5113
|
const approvalResult = evaluateApprovalPolicy({
|
|
@@ -4906,6 +5127,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4906
5127
|
[gateResult.gate_id || 'no_gate']: 'passed',
|
|
4907
5128
|
};
|
|
4908
5129
|
updatedState.queued_phase_transition = null;
|
|
5130
|
+
const cleanup = cleanupPhaseAdvanceArtifacts(root, updatedState, config, prevPhase);
|
|
5131
|
+
updatedState = cleanup.state;
|
|
4909
5132
|
ledgerEntries.push({
|
|
4910
5133
|
type: 'approval_policy',
|
|
4911
5134
|
gate_type: 'phase_transition',
|
|
@@ -4944,6 +5167,18 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
4944
5167
|
trigger: 'auto_approved',
|
|
4945
5168
|
},
|
|
4946
5169
|
});
|
|
5170
|
+
emitRunEvent(root, 'phase_cleanup', {
|
|
5171
|
+
run_id: updatedState.run_id,
|
|
5172
|
+
phase: updatedState.phase,
|
|
5173
|
+
status: updatedState.status,
|
|
5174
|
+
turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
|
|
5175
|
+
payload: {
|
|
5176
|
+
...cleanup.payload,
|
|
5177
|
+
to_phase: gateResult.next_phase,
|
|
5178
|
+
gate_id: gateResult.gate_id || null,
|
|
5179
|
+
trigger: 'auto_approved',
|
|
5180
|
+
},
|
|
5181
|
+
});
|
|
4947
5182
|
} else {
|
|
4948
5183
|
updatedState.status = 'paused';
|
|
4949
5184
|
updatedState.blocked_on = `human_approval:${gateResult.gate_id}`;
|
|
@@ -5499,6 +5734,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
|
|
|
5499
5734
|
status: 'completed',
|
|
5500
5735
|
payload: { completed_at: updatedState.completed_at || now },
|
|
5501
5736
|
});
|
|
5737
|
+
recordRunHistory(root, updatedState, config, 'completed');
|
|
5502
5738
|
}
|
|
5503
5739
|
|
|
5504
5740
|
// Session checkpoint — non-fatal, written after every successful acceptance
|
|
@@ -5888,7 +6124,7 @@ export function approvePhaseTransition(root, config, opts = {}) {
|
|
|
5888
6124
|
appendJsonl(root, LEDGER_PATH, entry);
|
|
5889
6125
|
}
|
|
5890
6126
|
|
|
5891
|
-
|
|
6127
|
+
let updatedState = {
|
|
5892
6128
|
...state,
|
|
5893
6129
|
phase: transition.to,
|
|
5894
6130
|
phase_entered_at: new Date().toISOString(),
|
|
@@ -5902,6 +6138,8 @@ export function approvePhaseTransition(root, config, opts = {}) {
|
|
|
5902
6138
|
[transition.gate]: 'passed',
|
|
5903
6139
|
},
|
|
5904
6140
|
};
|
|
6141
|
+
const cleanup = cleanupPhaseAdvanceArtifacts(root, updatedState, config, transition.from);
|
|
6142
|
+
updatedState = cleanup.state;
|
|
5905
6143
|
|
|
5906
6144
|
writeState(root, updatedState);
|
|
5907
6145
|
clearSlaReminders(root, 'pending_phase_transition');
|
|
@@ -5922,6 +6160,17 @@ export function approvePhaseTransition(root, config, opts = {}) {
|
|
|
5922
6160
|
trigger: 'human_approved',
|
|
5923
6161
|
},
|
|
5924
6162
|
});
|
|
6163
|
+
emitRunEvent(root, 'phase_cleanup', {
|
|
6164
|
+
run_id: updatedState.run_id,
|
|
6165
|
+
phase: updatedState.phase,
|
|
6166
|
+
status: 'active',
|
|
6167
|
+
payload: {
|
|
6168
|
+
...cleanup.payload,
|
|
6169
|
+
to_phase: transition.to,
|
|
6170
|
+
gate_id: transition.gate || null,
|
|
6171
|
+
trigger: 'human_approved',
|
|
6172
|
+
},
|
|
6173
|
+
});
|
|
5925
6174
|
|
|
5926
6175
|
// Session checkpoint — non-fatal
|
|
5927
6176
|
writeSessionCheckpoint(root, updatedState, 'phase_approved');
|
|
@@ -557,6 +557,7 @@ export function validateV4Config(data, projectRoot) {
|
|
|
557
557
|
// Gates (optional but validated if present)
|
|
558
558
|
if (data.gates) {
|
|
559
559
|
validateGateActionsConfig(data.gates, errors);
|
|
560
|
+
validateGateCredentialedConfig(data.gates, errors);
|
|
560
561
|
if (data.gates && typeof data.gates === 'object' && !Array.isArray(data.gates) && data.routing) {
|
|
561
562
|
for (const [, route] of Object.entries(data.routing)) {
|
|
562
563
|
if (route.exit_gate && !data.gates[route.exit_gate]) {
|
|
@@ -996,6 +997,21 @@ export function validateWorkflowKitConfig(wk, routing, roles, runtimes = {}) {
|
|
|
996
997
|
|
|
997
998
|
const VALID_APPROVAL_ACTIONS = ['auto_approve', 'require_human'];
|
|
998
999
|
|
|
1000
|
+
function validateGateCredentialedConfig(gates, errors) {
|
|
1001
|
+
if (!gates || typeof gates !== 'object' || Array.isArray(gates)) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
for (const [gateId, gate] of Object.entries(gates)) {
|
|
1006
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) {
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (gate.credentialed !== undefined && typeof gate.credentialed !== 'boolean') {
|
|
1010
|
+
errors.push(`gates.${gateId}.credentialed must be a boolean when provided`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
999
1015
|
/**
|
|
1000
1016
|
* Validate the approval_policy config section.
|
|
1001
1017
|
* Returns an array of error strings.
|
|
@@ -1098,6 +1114,13 @@ function validateApprovalWhen(when, prefix) {
|
|
|
1098
1114
|
if (when.all_phases_visited !== undefined && typeof when.all_phases_visited !== 'boolean') {
|
|
1099
1115
|
errors.push(`${prefix}.when.all_phases_visited must be a boolean`);
|
|
1100
1116
|
}
|
|
1117
|
+
if (when.credentialed_gate !== undefined) {
|
|
1118
|
+
if (typeof when.credentialed_gate !== 'boolean') {
|
|
1119
|
+
errors.push(`${prefix}.when.credentialed_gate must be a boolean`);
|
|
1120
|
+
} else if (when.credentialed_gate !== false) {
|
|
1121
|
+
errors.push(`${prefix}.when.credentialed_gate must be false when provided (DEC-BUG59-CREDENTIALED-GATE-PREDICATE-NEGATIVE-ONLY-001)`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1101
1124
|
return errors;
|
|
1102
1125
|
}
|
|
1103
1126
|
|
package/src/lib/run-events.js
CHANGED
|
@@ -61,7 +61,14 @@
|
|
|
61
61
|
"type": ["array", "object"]
|
|
62
62
|
},
|
|
63
63
|
"approval_policy": {
|
|
64
|
-
"
|
|
64
|
+
"oneOf": [
|
|
65
|
+
{
|
|
66
|
+
"$ref": "#/$defs/approval_policy"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"type": "null"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
65
72
|
},
|
|
66
73
|
"timeouts": {
|
|
67
74
|
"type": ["object", "null"]
|
|
@@ -91,7 +98,7 @@
|
|
|
91
98
|
"startup_watchdog_ms": {
|
|
92
99
|
"type": "integer",
|
|
93
100
|
"minimum": 1,
|
|
94
|
-
"description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default
|
|
101
|
+
"description": "Milliseconds to wait after dispatch for worker attach/first-output proof before retaining the turn as failed_start. Default 180000."
|
|
95
102
|
},
|
|
96
103
|
"stale_turn_threshold_ms": {
|
|
97
104
|
"type": "integer",
|
|
@@ -220,6 +227,87 @@
|
|
|
220
227
|
},
|
|
221
228
|
"requires_verification_pass": {
|
|
222
229
|
"type": "boolean"
|
|
230
|
+
},
|
|
231
|
+
"credentialed": {
|
|
232
|
+
"type": "boolean",
|
|
233
|
+
"description": "When true, this gate protects a credentialed, irreversible, or operator-owned action and cannot be auto-approved by approval_policy."
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
"additionalProperties": true
|
|
237
|
+
},
|
|
238
|
+
"approval_policy": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {
|
|
241
|
+
"phase_transitions": {
|
|
242
|
+
"$ref": "#/$defs/approval_phase_transitions"
|
|
243
|
+
},
|
|
244
|
+
"run_completion": {
|
|
245
|
+
"$ref": "#/$defs/approval_run_completion"
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"additionalProperties": true
|
|
249
|
+
},
|
|
250
|
+
"approval_phase_transitions": {
|
|
251
|
+
"type": "object",
|
|
252
|
+
"properties": {
|
|
253
|
+
"default": {
|
|
254
|
+
"enum": ["auto_approve", "require_human"]
|
|
255
|
+
},
|
|
256
|
+
"rules": {
|
|
257
|
+
"type": "array",
|
|
258
|
+
"items": {
|
|
259
|
+
"$ref": "#/$defs/approval_phase_rule"
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
"additionalProperties": true
|
|
264
|
+
},
|
|
265
|
+
"approval_phase_rule": {
|
|
266
|
+
"type": "object",
|
|
267
|
+
"properties": {
|
|
268
|
+
"from_phase": {
|
|
269
|
+
"$ref": "#/$defs/non_empty_string"
|
|
270
|
+
},
|
|
271
|
+
"to_phase": {
|
|
272
|
+
"$ref": "#/$defs/non_empty_string"
|
|
273
|
+
},
|
|
274
|
+
"action": {
|
|
275
|
+
"enum": ["auto_approve", "require_human"]
|
|
276
|
+
},
|
|
277
|
+
"when": {
|
|
278
|
+
"$ref": "#/$defs/approval_when"
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
"additionalProperties": true
|
|
282
|
+
},
|
|
283
|
+
"approval_run_completion": {
|
|
284
|
+
"type": "object",
|
|
285
|
+
"properties": {
|
|
286
|
+
"action": {
|
|
287
|
+
"enum": ["auto_approve", "require_human"]
|
|
288
|
+
},
|
|
289
|
+
"when": {
|
|
290
|
+
"$ref": "#/$defs/approval_when"
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"additionalProperties": true
|
|
294
|
+
},
|
|
295
|
+
"approval_when": {
|
|
296
|
+
"type": "object",
|
|
297
|
+
"properties": {
|
|
298
|
+
"gate_passed": {
|
|
299
|
+
"type": "boolean"
|
|
300
|
+
},
|
|
301
|
+
"roles_participated": {
|
|
302
|
+
"$ref": "#/$defs/string_array"
|
|
303
|
+
},
|
|
304
|
+
"all_phases_visited": {
|
|
305
|
+
"type": "boolean"
|
|
306
|
+
},
|
|
307
|
+
"credentialed_gate": {
|
|
308
|
+
"type": "boolean",
|
|
309
|
+
"enum": [false],
|
|
310
|
+
"description": "Only false is valid. Credentialed gates are hard-stopped before policy rule matching."
|
|
223
311
|
}
|
|
224
312
|
},
|
|
225
313
|
"additionalProperties": true
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two-tier lazy idle-threshold detection:
|
|
5
5
|
*
|
|
6
6
|
* 1. **Fast startup watchdog (BUG-51):** if an active turn has been
|
|
7
|
-
* `dispatched`/`starting`/`running` for >
|
|
7
|
+
* `dispatched`/`starting`/`running` for >180 seconds with NO startup proof
|
|
8
8
|
* (no first-byte output recorded on the turn or in dispatch-progress) and
|
|
9
9
|
* NO staged result, it is a "ghost turn" — the subprocess never reached a
|
|
10
10
|
* healthy running state. Transitions to `failed_start` immediately.
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* requiring a background daemon.
|
|
25
25
|
*
|
|
26
26
|
* Default thresholds:
|
|
27
|
-
* - Startup watchdog:
|
|
27
|
+
* - Startup watchdog: 180 seconds (configurable via run_loop.startup_watchdog_ms
|
|
28
28
|
* or runtimes.<id>.startup_watchdog_ms for local_cli runtimes)
|
|
29
29
|
* - local_cli stale turns: 10 minutes
|
|
30
30
|
* - api_proxy stale turns: 5 minutes
|
|
@@ -42,7 +42,7 @@ import { hasMeaningfulStagedResult } from './staged-result-proof.js';
|
|
|
42
42
|
|
|
43
43
|
const DEFAULT_LOCAL_CLI_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
|
44
44
|
const DEFAULT_API_PROXY_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
45
|
-
const DEFAULT_STARTUP_WATCHDOG_MS =
|
|
45
|
+
const DEFAULT_STARTUP_WATCHDOG_MS = 180 * 1000; // 180 seconds (BUG-54)
|
|
46
46
|
const LEGACY_STAGING_PATH = '.agentxchain/staging/turn-result.json';
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -125,21 +125,51 @@
|
|
|
125
125
|
"gates": {
|
|
126
126
|
"planning_signoff": {
|
|
127
127
|
"requires_files": [".planning/PM_SIGNOFF.md", ".planning/ROADMAP.md", ".planning/SYSTEM_SPEC.md"],
|
|
128
|
-
"requires_human_approval": true
|
|
128
|
+
"requires_human_approval": true,
|
|
129
|
+
"credentialed": false
|
|
129
130
|
},
|
|
130
131
|
"architecture_review": {
|
|
131
|
-
"requires_files": [".planning/ARCHITECTURE.md"]
|
|
132
|
+
"requires_files": [".planning/ARCHITECTURE.md"],
|
|
133
|
+
"credentialed": false
|
|
132
134
|
},
|
|
133
135
|
"implementation_complete": {
|
|
134
136
|
"requires_files": [".planning/IMPLEMENTATION_NOTES.md"],
|
|
135
|
-
"requires_verification_pass": true
|
|
137
|
+
"requires_verification_pass": true,
|
|
138
|
+
"credentialed": false
|
|
136
139
|
},
|
|
137
140
|
"security_review_signoff": {
|
|
138
|
-
"requires_files": [".planning/SECURITY_REVIEW.md"]
|
|
141
|
+
"requires_files": [".planning/SECURITY_REVIEW.md"],
|
|
142
|
+
"credentialed": false
|
|
139
143
|
},
|
|
140
144
|
"qa_ship_verdict": {
|
|
141
145
|
"requires_files": [".planning/acceptance-matrix.md", ".planning/ship-verdict.md", ".planning/RELEASE_NOTES.md"],
|
|
142
|
-
"requires_human_approval": true
|
|
146
|
+
"requires_human_approval": true,
|
|
147
|
+
"requires_verification_pass": true,
|
|
148
|
+
"credentialed": false
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"approval_policy": {
|
|
152
|
+
"phase_transitions": {
|
|
153
|
+
"default": "require_human",
|
|
154
|
+
"rules": [
|
|
155
|
+
{
|
|
156
|
+
"from_phase": "planning",
|
|
157
|
+
"to_phase": "architecture",
|
|
158
|
+
"action": "auto_approve",
|
|
159
|
+
"when": {
|
|
160
|
+
"gate_passed": true,
|
|
161
|
+
"credentialed_gate": false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
"run_completion": {
|
|
167
|
+
"action": "auto_approve",
|
|
168
|
+
"when": {
|
|
169
|
+
"gate_passed": true,
|
|
170
|
+
"all_phases_visited": true,
|
|
171
|
+
"credentialed_gate": false
|
|
172
|
+
}
|
|
143
173
|
}
|
|
144
174
|
},
|
|
145
175
|
"policies": [
|