agentxchain 2.150.0 → 2.151.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.150.0",
3
+ "version": "2.151.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
+ }
@@ -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,
@@ -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 30_000;
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 || {});
@@ -2665,6 +2665,91 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2665
2665
  });
2666
2666
 
2667
2667
  if (gateResult.action === 'awaiting_human_approval') {
2668
+ // BUG-59 (DEC-BUG59-PLAN-LAYERED-FIX-001, slice 3): before falling back to
2669
+ // the BUG-52 "human already unblocked" advancement path, consult
2670
+ // approval_policy. If the configured policy auto-approves this transition
2671
+ // (and the gate is not credentialed), advance directly and write an
2672
+ // `approval_policy` ledger entry matching the accepted-turn path shape at
2673
+ // governed-state.js:4909-4919. Credentialed gates are hard-stopped inside
2674
+ // evaluateApprovalPolicy per DEC-BUG59-CREDENTIALED-GATE-HARD-STOP-001, so
2675
+ // a credentialed gate lands here with action === 'require_human' and falls
2676
+ // through to the existing approvePhaseTransition path (which itself
2677
+ // requires paused/blocked status produced by a real human unblock).
2678
+ const approvalResult = evaluateApprovalPolicy({
2679
+ gateResult,
2680
+ gateType: 'phase_transition',
2681
+ state: { ...currentState, history: historyEntries },
2682
+ config,
2683
+ });
2684
+
2685
+ if (approvalResult.action === 'auto_approve') {
2686
+ const now = new Date().toISOString();
2687
+ const prevPhase = currentState.phase;
2688
+ const nextState = {
2689
+ ...currentState,
2690
+ phase: gateResult.next_phase,
2691
+ phase_entered_at: now,
2692
+ blocked_on: null,
2693
+ blocked_reason: null,
2694
+ last_gate_failure: null,
2695
+ pending_phase_transition: null,
2696
+ queued_phase_transition: null,
2697
+ phase_gate_status: {
2698
+ ...(currentState.phase_gate_status || {}),
2699
+ [gateResult.gate_id || 'no_gate']: 'passed',
2700
+ },
2701
+ };
2702
+ writeState(root, nextState);
2703
+ appendJsonl(root, LEDGER_PATH, {
2704
+ type: 'approval_policy',
2705
+ gate_type: 'phase_transition',
2706
+ action: 'auto_approve',
2707
+ matched_rule: approvalResult.matched_rule,
2708
+ from_phase: prevPhase,
2709
+ to_phase: gateResult.next_phase,
2710
+ reason: approvalResult.reason,
2711
+ gate_id: gateResult.gate_id || null,
2712
+ timestamp: now,
2713
+ });
2714
+ const retiredIntentIds = retireApprovedPhaseScopedIntents(root, nextState, config, prevPhase, now);
2715
+ if (retiredIntentIds.length > 0) {
2716
+ emitRunEvent(root, 'intent_retired_by_phase_advance', {
2717
+ run_id: nextState.run_id,
2718
+ phase: nextState.phase,
2719
+ status: nextState.status,
2720
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2721
+ payload: {
2722
+ exited_phase: prevPhase,
2723
+ entered_phase: gateResult.next_phase,
2724
+ retired_count: retiredIntentIds.length,
2725
+ retired_intent_ids: retiredIntentIds,
2726
+ },
2727
+ });
2728
+ }
2729
+ emitRunEvent(root, 'phase_entered', {
2730
+ run_id: nextState.run_id,
2731
+ phase: nextState.phase,
2732
+ status: nextState.status,
2733
+ turn: phaseSource.turn_id ? { turn_id: phaseSource.turn_id, role_id: phaseSource.role || phaseSource.assigned_role || null } : undefined,
2734
+ payload: {
2735
+ from: prevPhase,
2736
+ to: gateResult.next_phase,
2737
+ gate_id: gateResult.gate_id || 'no_gate',
2738
+ trigger: 'auto_approved',
2739
+ },
2740
+ });
2741
+ return {
2742
+ ok: true,
2743
+ state: attachLegacyCurrentTurnAlias(nextState),
2744
+ advanced: true,
2745
+ from_phase: prevPhase,
2746
+ to_phase: gateResult.next_phase,
2747
+ gate_id: gateResult.gate_id || null,
2748
+ gateResult,
2749
+ approval_policy: approvalResult,
2750
+ };
2751
+ }
2752
+
2668
2753
  const pausedState = {
2669
2754
  ...currentState,
2670
2755
  status: 'paused',
@@ -2689,6 +2774,7 @@ export function reconcilePhaseAdvanceBeforeDispatch(root, config, state = null)
2689
2774
  to_phase: approved.state?.phase || gateResult.next_phase || null,
2690
2775
  gate_id: gateResult.gate_id || null,
2691
2776
  gateResult,
2777
+ approval_policy: approvalResult,
2692
2778
  };
2693
2779
  }
2694
2780
 
@@ -5499,6 +5585,7 @@ function _acceptGovernedTurnLocked(root, config, opts) {
5499
5585
  status: 'completed',
5500
5586
  payload: { completed_at: updatedState.completed_at || now },
5501
5587
  });
5588
+ recordRunHistory(root, updatedState, config, 'completed');
5502
5589
  }
5503
5590
 
5504
5591
  // Session checkpoint — non-fatal, written after every successful acceptance
@@ -83,6 +83,7 @@ const VALID_SCAFFOLD_BLUEPRINT_KEYS = new Set([
83
83
  'gates',
84
84
  'policies',
85
85
  'workflow_kit',
86
+ 'approval_policy',
86
87
  ]);
87
88
 
88
89
  function validateScaffoldBlueprint(scaffoldBlueprint, errors) {
@@ -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
 
@@ -61,7 +61,14 @@
61
61
  "type": ["array", "object"]
62
62
  },
63
63
  "approval_policy": {
64
- "type": ["object", "null"]
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 30000."
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 >30 seconds with NO startup proof
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: 30 seconds (configurable via run_loop.startup_watchdog_ms
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 = 30 * 1000; // 30 seconds (BUG-51)
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": [