agentxchain 2.155.72 → 2.156.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.
Files changed (49) hide show
  1. package/README.md +4 -8
  2. package/bin/agentxchain.js +22 -0
  3. package/dashboard/app.js +54 -0
  4. package/dashboard/components/org-audit-trail.js +161 -0
  5. package/dashboard/components/org-history.js +140 -0
  6. package/dashboard/components/org-overview.js +145 -0
  7. package/dashboard/components/org-runs.js +168 -0
  8. package/dashboard/index.html +4 -0
  9. package/package.json +4 -5
  10. package/scripts/migrate-node-test-to-vitest.mjs +98 -0
  11. package/scripts/release-postflight.sh +1 -1
  12. package/scripts/release-preflight.sh +5 -5
  13. package/scripts/verify-post-publish.sh +1 -1
  14. package/src/commands/ci-report.js +80 -0
  15. package/src/commands/doctor.js +22 -1
  16. package/src/commands/intake-approve.js +1 -0
  17. package/src/commands/replay.js +1 -0
  18. package/src/commands/run.js +50 -0
  19. package/src/commands/serve.js +64 -0
  20. package/src/commands/step.js +63 -1
  21. package/src/commands/verify.js +1 -0
  22. package/src/lib/adapters/local-cli-adapter.js +326 -2
  23. package/src/lib/api/execution-worker.js +192 -0
  24. package/src/lib/api/hosted-runner.js +494 -0
  25. package/src/lib/api/job-queue.js +152 -0
  26. package/src/lib/api/org-state-aggregator.js +428 -0
  27. package/src/lib/api/project-registry.js +148 -0
  28. package/src/lib/api/protocol-bridge.js +476 -0
  29. package/src/lib/approval-policy.js +12 -0
  30. package/src/lib/ci-reporter.js +188 -0
  31. package/src/lib/claude-local-auth.js +89 -1
  32. package/src/lib/connector-probe.js +21 -0
  33. package/src/lib/continuous-run.js +51 -3
  34. package/src/lib/dashboard/bridge-server.js +10 -5
  35. package/src/lib/dispatch-bundle.js +7 -3
  36. package/src/lib/dispatch-progress.js +9 -0
  37. package/src/lib/governed-state.js +5 -4
  38. package/src/lib/intake.js +32 -6
  39. package/src/lib/normalized-config.js +4 -0
  40. package/src/lib/recovery-classification.js +158 -0
  41. package/src/lib/report.js +91 -0
  42. package/src/lib/run-events.js +7 -1
  43. package/src/lib/schemas/agentxchain-config.schema.json +10 -0
  44. package/src/lib/scope-overlap.js +214 -0
  45. package/src/lib/turn-checkpoint.js +33 -3
  46. package/src/lib/turn-result-validator.js +47 -6
  47. package/src/lib/validation.js +11 -3
  48. package/src/lib/verification-replay.js +125 -4
  49. package/src/lib/vision-reader.js +16 -1
@@ -138,7 +138,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
138
138
  }
139
139
 
140
140
  // ── Stage C: Artifact Validation ───────────────────────────────────────
141
- const artifactResult = validateArtifact(turnResult, config);
141
+ const artifactResult = validateArtifact(turnResult, config, state);
142
142
  if (artifactResult.errors.length > 0) {
143
143
  return result('artifact', 'artifact_error', artifactResult.errors, artifactResult.warnings);
144
144
  }
@@ -670,7 +670,7 @@ function validateAssignment(tr, state) {
670
670
 
671
671
  // ── Stage C: Artifact Validation ─────────────────────────────────────────────
672
672
 
673
- function validateArtifact(tr, config) {
673
+ function validateArtifact(tr, config, state = null) {
674
674
  const errors = [];
675
675
  const warnings = [];
676
676
 
@@ -699,6 +699,11 @@ function validateArtifact(tr, config) {
699
699
  `Artifact type "workspace" requires authoritative write authority, but role "${tr.role}" has "${writeAuthority}".`
700
700
  );
701
701
  }
702
+ if ((tr.files_changed || []).length === 0 && !hasCheckpointableProducedFiles(tr)) {
703
+ errors.push(
704
+ 'artifact.type: "workspace" but files_changed is empty. Use artifact type "review" for no-edit turns, or declare checkpointable verification.produced_files entries with disposition "artifact".'
705
+ );
706
+ }
702
707
  }
703
708
 
704
709
  // Check for reserved path modifications
@@ -722,9 +727,17 @@ function validateArtifact(tr, config) {
722
727
  }
723
728
  }
724
729
 
725
- // Warn if files_changed is empty for authoritative + completed turns
726
- if (writeAuthority === 'authoritative' && tr.status === 'completed' && (tr.files_changed || []).length === 0) {
727
- warnings.push('Authoritative role completed with no files_changed — is this intentional?');
730
+ // Implementation-phase completion must be backed by actual product code
731
+ // changes. Planning/review artifacts are supplementary and should not satisfy
732
+ // the implementation_complete gate by themselves.
733
+ if (writeAuthority === 'authoritative' && state?.phase === 'implementation' && tr.status === 'completed') {
734
+ const productFiles = (tr.files_changed || []).filter(f => isProductChangePath(f));
735
+ if (productFiles.length === 0) {
736
+ errors.push(
737
+ `Role "${tr.role}" completed an implementation turn without product code changes in files_changed. ` +
738
+ 'Implementation-phase completion requires at least one non-planning, non-review repo path; planning artifacts alone are not sufficient.'
739
+ );
740
+ }
728
741
  }
729
742
 
730
743
  // Validate proposed_changes for proposed runtimes that cannot write repo files directly.
@@ -772,6 +785,24 @@ function isAllowedReviewPath(filePath) {
772
785
  return filePath.startsWith('.planning/') || filePath.startsWith('.agentxchain/reviews/');
773
786
  }
774
787
 
788
+ function isProductChangePath(filePath) {
789
+ return typeof filePath === 'string'
790
+ && filePath.trim().length > 0
791
+ && !isAllowedReviewPath(filePath)
792
+ && !filePath.startsWith('.agentxchain/staging/');
793
+ }
794
+
795
+ function hasCheckpointableProducedFiles(tr) {
796
+ return Array.isArray(tr.verification?.produced_files)
797
+ && tr.verification.produced_files.some((entry) => (
798
+ entry
799
+ && typeof entry === 'object'
800
+ && typeof entry.path === 'string'
801
+ && entry.path.trim()
802
+ && (entry.disposition == null || entry.disposition === 'artifact')
803
+ ));
804
+ }
805
+
775
806
  // ── Stage D: Verification Validation ─────────────────────────────────────────
776
807
 
777
808
  function validateVerification(tr) {
@@ -1488,7 +1519,17 @@ export function normalizeTurnResult(tr, config, context = {}) {
1488
1519
  && !Array.isArray(normalized.artifact)
1489
1520
  && normalized.artifact.type === 'workspace'
1490
1521
  && filesChangedIsEmpty
1491
- && (context.forceReviewArtifact || hasExplicitNoEditLifecycleSignal)
1522
+ && !hasCheckpointableProducedFiles(normalized)
1523
+ && (
1524
+ context.forceReviewArtifact
1525
+ || hasExplicitNoEditLifecycleSignal
1526
+ // review_only roles (PM/QA) legitimately produce no-edit reviews — an empty
1527
+ // workspace from a COMPLETED review_only turn normalizes to a review. Authoritative
1528
+ // /code-writing roles claiming a completed workspace artifact with no files stay
1529
+ // fail-closed, and non-completed (blocked/failed) turns never normalize (BUG-78).
1530
+ || (isReviewOnly && normalized.status === 'completed')
1531
+ || (normalized.status === 'needs_human' && normalized.proposed_next_role === 'human')
1532
+ )
1492
1533
  ) {
1493
1534
  normalized.artifact = {
1494
1535
  ...normalized.artifact,
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { validateStagedTurnResult, STAGING_PATH } from './turn-result-validator.js';
4
4
  import { getActiveTurn } from './governed-state.js';
5
+ import { getTurnStagingResultPath } from './turn-paths.js';
5
6
  import {
6
7
  validateGovernedProjectTemplate,
7
8
  validateGovernedTemplateRegistry,
@@ -191,11 +192,18 @@ export function validateGovernedProject(root, rawConfig, config, opts = {}) {
191
192
 
192
193
  // ── Staged turn-result validation (the acceptance boundary) ─────────────
193
194
  if (mode === 'turn' && state) {
194
- const stagingAbs = join(root, STAGING_PATH);
195
+ // RB-12: prefer the turn-scoped staging path for the active turn; the shared
196
+ // legacy path is only a fallback. Reading the legacy path unconditionally
197
+ // made `validate --mode turn` look at the wrong file and falsely report a
198
+ // missing turn result for turns staged at the turn-scoped path.
199
+ const activeTurn = getActiveTurn(state) || state.current_turn || null;
200
+ const turnScoped = activeTurn?.turn_id ? getTurnStagingResultPath(activeTurn.turn_id) : null;
201
+ const stagingRel = (turnScoped && existsSync(join(root, turnScoped))) ? turnScoped : STAGING_PATH;
202
+ const stagingAbs = join(root, stagingRel);
195
203
  if (!existsSync(stagingAbs)) {
196
- warnings.push(`No staged turn result found at ${STAGING_PATH}. Agent has not yet emitted a turn result.`);
204
+ warnings.push(`No staged turn result found at ${stagingRel}. Agent has not yet emitted a turn result.`);
197
205
  } else {
198
- const turnValidation = validateStagedTurnResult(root, state, config);
206
+ const turnValidation = validateStagedTurnResult(root, state, config, { stagingPath: stagingRel });
199
207
  if (!turnValidation.ok) {
200
208
  errors.push(
201
209
  `Staged turn result failed at stage "${turnValidation.stage}" (${turnValidation.error_class}):`,
@@ -8,7 +8,7 @@ import { isOperationalPath } from './repo-observer.js';
8
8
 
9
9
  export const DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS = 30_000;
10
10
 
11
- export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS }) {
11
+ export function replayVerificationMachineEvidence({ root, verification, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS, allowCommandExecution = false }) {
12
12
  const verifiedAt = new Date().toISOString();
13
13
  const machineEvidence = Array.isArray(verification?.machine_evidence)
14
14
  ? verification.machine_evidence
@@ -28,6 +28,27 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
28
28
  return payload;
29
29
  }
30
30
 
31
+ // SECURITY (verification-replay hardening): agent-declared machine_evidence
32
+ // commands are UNTRUSTED. Fail-safe default — they are recorded but NOT executed
33
+ // unless the operator has explicitly opted in (allowCommandExecution). When
34
+ // executed, they never run through a shell (see replayEvidenceCommand).
35
+ if (!allowCommandExecution) {
36
+ payload.overall = 'not_executed';
37
+ payload.reason = 'Command replay disabled: agent-declared verification commands are not executed unless explicitly enabled by the operator.';
38
+ payload.commands = machineEvidence.map((entry, index) => ({
39
+ index,
40
+ command: entry?.command ?? null,
41
+ declared_exit_code: entry?.exit_code,
42
+ actual_exit_code: null,
43
+ matched: false,
44
+ executed: false,
45
+ timed_out: false,
46
+ signal: null,
47
+ error: null,
48
+ }));
49
+ return payload;
50
+ }
51
+
31
52
  const workspaceGuard = createReplayWorkspaceGuard(root);
32
53
  try {
33
54
  payload.commands = machineEvidence.map((entry, index) => replayEvidenceCommand(root, entry, index, timeoutMs));
@@ -41,11 +62,33 @@ export function replayVerificationMachineEvidence({ root, verification, timeoutM
41
62
  return payload;
42
63
  }
43
64
 
65
+ // SECURITY: agent-declared commands are NEVER run through a shell. The command is
66
+ // resolved to an argv vector and executed with shell:false, so shell metacharacters
67
+ // (; | && $() backticks globs) cannot chain, pipe, expand, or substitute. (A single
68
+ // declared binary still runs on the host — that is gated by the explicit
69
+ // allowCommandExecution opt-in upstream; a future sandbox/allowlist can constrain
70
+ // which binaries are permitted.)
44
71
  export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VERIFICATION_REPLAY_TIMEOUT_MS) {
45
- const result = spawnSync(entry.command, {
72
+ const argv = resolveEvidenceArgv(entry);
73
+ if (argv.length === 0) {
74
+ return {
75
+ index,
76
+ command: entry?.command ?? null,
77
+ declared_exit_code: entry?.exit_code,
78
+ actual_exit_code: null,
79
+ matched: false,
80
+ executed: false,
81
+ timed_out: false,
82
+ signal: null,
83
+ error: 'No executable command or argv vector could be resolved for this evidence entry.',
84
+ };
85
+ }
86
+
87
+ const [file, ...args] = argv;
88
+ const result = spawnSync(file, args, {
46
89
  cwd: root,
47
90
  encoding: 'utf8',
48
- shell: true,
91
+ shell: false,
49
92
  timeout: timeoutMs,
50
93
  maxBuffer: 1024 * 1024,
51
94
  });
@@ -56,16 +99,94 @@ export function replayEvidenceCommand(root, entry, index, timeoutMs = DEFAULT_VE
56
99
 
57
100
  return {
58
101
  index,
59
- command: entry.command,
102
+ command: entry.command ?? argv.join(' '),
60
103
  declared_exit_code: entry.exit_code,
61
104
  actual_exit_code: actualExitCode,
62
105
  matched: actualExitCode === entry.exit_code,
106
+ executed: true,
63
107
  timed_out: timedOut,
64
108
  signal: result.signal || null,
65
109
  error: errorMessage,
66
110
  };
67
111
  }
68
112
 
113
+ // Resolve an evidence entry to an argv vector for shell-free execution.
114
+ // Prefers a structured `argv` string array; falls back to safe-tokenizing `command`.
115
+ export function resolveEvidenceArgv(entry) {
116
+ if (Array.isArray(entry?.argv) && entry.argv.length > 0 && entry.argv.every((a) => typeof a === 'string')) {
117
+ return entry.argv.slice();
118
+ }
119
+ if (typeof entry?.command === 'string' && entry.command.trim()) {
120
+ return tokenizeCommand(entry.command.trim());
121
+ }
122
+ return [];
123
+ }
124
+
125
+ // Minimal SAFE tokenizer: splits on whitespace, honoring single and double quotes
126
+ // and backslash escapes (so e.g. `node -e "{\"ok\":true}"` keeps its escaped quotes).
127
+ // Performs NO shell expansion — variables, globs, pipes, chaining, and command
128
+ // substitution are preserved as literal characters, never interpreted.
129
+ export function tokenizeCommand(command) {
130
+ if (typeof command !== 'string') {
131
+ return [];
132
+ }
133
+ const tokens = [];
134
+ let current = '';
135
+ let hasToken = false;
136
+ let i = 0;
137
+ const n = command.length;
138
+ while (i < n) {
139
+ const ch = command[i];
140
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
141
+ if (hasToken) {
142
+ tokens.push(current);
143
+ current = '';
144
+ hasToken = false;
145
+ }
146
+ i += 1;
147
+ continue;
148
+ }
149
+ hasToken = true;
150
+ if (ch === "'") {
151
+ // Single-quoted: literal run until the next single quote (no escapes, POSIX-style).
152
+ i += 1;
153
+ while (i < n && command[i] !== "'") {
154
+ current += command[i];
155
+ i += 1;
156
+ }
157
+ i += 1; // consume closing quote (or run off the end)
158
+ continue;
159
+ }
160
+ if (ch === '"') {
161
+ // Double-quoted: backslash escapes only the structural `"` and `\` characters.
162
+ i += 1;
163
+ while (i < n && command[i] !== '"') {
164
+ if (command[i] === '\\' && i + 1 < n && (command[i + 1] === '"' || command[i + 1] === '\\')) {
165
+ current += command[i + 1];
166
+ i += 2;
167
+ } else {
168
+ current += command[i];
169
+ i += 1;
170
+ }
171
+ }
172
+ i += 1; // consume closing quote (or run off the end)
173
+ continue;
174
+ }
175
+ if (ch === '\\' && i + 1 < n) {
176
+ // Bare backslash escape: take the next character literally.
177
+ current += command[i + 1];
178
+ i += 2;
179
+ continue;
180
+ }
181
+ current += ch;
182
+ i += 1;
183
+ }
184
+ if (hasToken) {
185
+ tokens.push(current);
186
+ }
187
+ return tokens;
188
+ }
189
+
69
190
  export function summarizeVerificationReplay(payload) {
70
191
  if (!payload) {
71
192
  return null;
@@ -15,6 +15,18 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
15
  import { join, resolve as pathResolve, isAbsolute } from 'node:path';
16
16
  import { createHash } from 'node:crypto';
17
17
 
18
+ const ROADMAP_TRACKING_ANNOTATION_PATTERN = /<!--\s*tracking\s*:[\s\S]*?-->/i;
19
+ // RB-3: a milestone left unchecked but annotated as delivered/done/shipped is
20
+ // already-built work — skip it so the loop derives genuinely undelivered work
21
+ // instead of re-picking finished items. Distinct from generic comments like
22
+ // `<!-- owner: dev -->`, which stay actionable.
23
+ const ROADMAP_DELIVERED_ANNOTATION_PATTERN = /<!--[^>]*\b(delivered|shipped|implemented|completed|done)\b[^>]*-->/i;
24
+
25
+ export function stripRoadmapTrackingAnnotations(text) {
26
+ if (typeof text !== 'string') return '';
27
+ return text.replace(ROADMAP_TRACKING_ANNOTATION_PATTERN, '').replace(/\s+/g, ' ').trim();
28
+ }
29
+
18
30
  // ---------------------------------------------------------------------------
19
31
  // Parsing
20
32
  // ---------------------------------------------------------------------------
@@ -259,8 +271,10 @@ export function deriveRoadmapCandidates(root, roadmapPath = '.planning/ROADMAP.m
259
271
 
260
272
  const uncheckedMatch = line.match(/^\s*[-*]\s+\[\s\]\s+(.+?)\s*$/);
261
273
  if (!uncheckedMatch || !currentMilestone) continue;
274
+ if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
262
275
 
263
- const goal = uncheckedMatch[1].trim();
276
+ const goal = stripRoadmapTrackingAnnotations(uncheckedMatch[1]);
277
+ if (!goal) continue;
264
278
  const combinedGoal = `${currentMilestone}: ${goal}`;
265
279
  if (isGoalAddressed(combinedGoal, allSignals) || isGoalAddressed(goal, allSignals)) {
266
280
  continue;
@@ -484,6 +498,7 @@ export function detectRoadmapExhaustedVisionOpen(root, visionPath, roadmapPath =
484
498
  continue;
485
499
  }
486
500
  if (currentMilestone && /^\s*[-*]\s+\[\s\]/.test(line)) {
501
+ if (ROADMAP_TRACKING_ANNOTATION_PATTERN.test(line) || ROADMAP_DELIVERED_ANNOTATION_PATTERN.test(line)) continue;
487
502
  hasUnchecked = true;
488
503
  }
489
504
  }