agentxchain 2.155.27 → 2.155.29

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.
@@ -707,6 +707,7 @@ program
707
707
  .option('--turn <id>', 'Target a specific active turn when multiple turns exist')
708
708
  .option('--checkpoint', 'Checkpoint the accepted turn to git immediately after acceptance')
709
709
  .option('--resolution <mode>', 'Conflict resolution mode for conflicted turns (standard, human_merge)', 'standard')
710
+ .option('--normalize-artifact-type <type>', 'Normalize an empty workspace artifact to a safe artifact type before acceptance (currently: review)')
710
711
  .action(acceptTurnCommand);
711
712
 
712
713
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.27",
3
+ "version": "2.155.29",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@ export async function acceptTurnCommand(opts = {}) {
22
22
  const result = acceptGovernedTurn(root, config, {
23
23
  turnId: opts.turn,
24
24
  resolutionMode: opts.resolution || 'standard',
25
+ normalizeArtifactType: opts.normalizeArtifactType || null,
25
26
  });
26
27
  if (!result.ok) {
27
28
  if (result.error_code === 'policy_escalation' || result.error_code === 'policy_violation') {
@@ -544,6 +544,8 @@ You are the **${role.title}** on this project.
544
544
  1. Read the previous turn and challenge it explicitly.
545
545
  2. Do your work according to your mandate.
546
546
  3. Write your structured turn result to the turn-scoped staging path printed by the orchestrator (\`.agentxchain/staging/<turn_id>/turn-result.json\`).
547
+ 4. If you make zero repo file edits, set \`artifact.type: "review"\` and \`files_changed: []\`.
548
+ 5. Only set \`artifact.type: "workspace"\` when you actually modified repo files and listed every changed path in \`files_changed\`.
547
549
 
548
550
  ## File Access
549
551
 
@@ -31,7 +31,7 @@ import {
31
31
  resolveIntent,
32
32
  buildVisionIdleExpansionSignal,
33
33
  } from './intake.js';
34
- import { loadProjectState } from './config.js';
34
+ import { loadProjectContext, loadProjectState } from './config.js';
35
35
  import { safeWriteJson } from './safe-write.js';
36
36
  import { emitRunEvent } from './run-events.js';
37
37
  import { reissueTurn } from './governed-state.js';
@@ -54,6 +54,15 @@ import {
54
54
 
55
55
  const CONTINUOUS_SESSION_PATH = '.agentxchain/continuous-session.json';
56
56
 
57
+ function getRoadmapReplenishmentTriageHints(root) {
58
+ const context = loadProjectContext(root);
59
+ const config = context?.config || null;
60
+ return {
61
+ preferred_role: config?.roles?.pm ? 'pm' : null,
62
+ phase_scope: config?.routing?.planning ? 'planning' : null,
63
+ };
64
+ }
65
+
57
66
  // ---------------------------------------------------------------------------
58
67
  // Session state
59
68
  // ---------------------------------------------------------------------------
@@ -719,66 +728,84 @@ export function seedFromVision(root, visionPath, options = {}) {
719
728
  };
720
729
  }
721
730
 
731
+ // BUG-77: Once the roadmap has no unchecked work, check whether it is
732
+ // exhausted while VISION still has unplanned sections before considering
733
+ // broad per-goal vision candidates. Otherwise accumulated projects with old
734
+ // generic vision candidates can bypass PM roadmap replenishment.
735
+ const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
736
+ if (exhaustion.open) {
737
+ const sectionNames = exhaustion.unplanned_sections.join(', ');
738
+ const replenishmentEvent = recordEvent(root, {
739
+ source: 'vision_scan',
740
+ category: 'roadmap_exhausted_vision_open',
741
+ signal: {
742
+ description: `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${sectionNames}`,
743
+ unplanned_sections: exhaustion.unplanned_sections,
744
+ latest_milestone: exhaustion.latest_milestone,
745
+ derived: true,
746
+ },
747
+ evidence: [
748
+ { type: 'text', value: `All ${exhaustion.total_milestones} roadmap milestones checked. VISION sections not yet planned: ${sectionNames}` },
749
+ ],
750
+ });
751
+
752
+ if (!replenishmentEvent.ok) {
753
+ if (replenishmentEvent.deduplicated) {
754
+ return { ok: true, idle: true };
755
+ }
756
+ return { ok: false, error: `intake record failed: ${replenishmentEvent.error}` };
757
+ }
758
+
759
+ if (replenishmentEvent.deduplicated) {
760
+ return { ok: true, idle: true };
761
+ }
762
+
763
+ const replenishmentIntentId = replenishmentEvent.intent.intent_id;
764
+ const replenishmentHints = getRoadmapReplenishmentTriageHints(root);
765
+ const triageResult = triageIntent(root, replenishmentIntentId, {
766
+ priority: 'p1',
767
+ template: 'generic',
768
+ ...(replenishmentHints.preferred_role ? { preferred_role: replenishmentHints.preferred_role } : {}),
769
+ ...(replenishmentHints.phase_scope ? { phase_scope: replenishmentHints.phase_scope } : {}),
770
+ charter: `[roadmap-replenishment] Derive next bounded roadmap increment from VISION.md. Unplanned scope: ${sectionNames}. Current roadmap checked through ${exhaustion.latest_milestone}. Read .planning/VISION.md and .planning/ROADMAP.md to select the next testable milestone. Produce concrete unchecked M${exhaustion.total_milestones + 1} items. Do not re-verify previous completed milestones.`,
771
+ acceptance_contract: [
772
+ `New unchecked milestone items added to .planning/ROADMAP.md`,
773
+ `Milestone scope derived from VISION.md sections: ${sectionNames}`,
774
+ `Milestone is bounded, testable, and does not duplicate existing checked milestones`,
775
+ ],
776
+ });
777
+
778
+ if (!triageResult.ok) {
779
+ return { ok: false, error: `triage failed: ${triageResult.error}` };
780
+ }
781
+
782
+ const triageApproval = options.triageApproval || 'auto';
783
+ if (triageApproval === 'auto') {
784
+ const approveResult = approveIntent(root, replenishmentIntentId, {
785
+ approver: 'continuous_loop',
786
+ reason: 'roadmap-replenishment auto-approval (BUG-77)',
787
+ });
788
+ if (!approveResult.ok) {
789
+ return { ok: false, error: `approve failed: ${approveResult.error}` };
790
+ }
791
+ }
792
+
793
+ return {
794
+ ok: true,
795
+ idle: false,
796
+ intentId: replenishmentIntentId,
797
+ section: 'Roadmap replenishment',
798
+ goal: `Derive next increment from: ${sectionNames}`,
799
+ source: 'roadmap_replenishment',
800
+ };
801
+ }
802
+
722
803
  const result = deriveVisionCandidates(root, visionPath);
723
804
  if (!result.ok) {
724
805
  return { ok: false, error: result.error };
725
806
  }
726
807
 
727
808
  if (result.candidates.length === 0) {
728
- // BUG-77: Before declaring idle, check if roadmap is exhausted but VISION
729
- // has unplanned scope. If so, seed a PM roadmap-replenishment intent to
730
- // derive the next bounded milestone instead of idle-exiting.
731
- const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
732
- if (exhaustion.open) {
733
- const sectionNames = exhaustion.unplanned_sections.join(', ');
734
- const replenishmentEvent = recordEvent(root, {
735
- source: 'vision_scan',
736
- category: 'roadmap_exhausted_vision_open',
737
- signal: {
738
- description: `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${sectionNames}`,
739
- unplanned_sections: exhaustion.unplanned_sections,
740
- latest_milestone: exhaustion.latest_milestone,
741
- derived: true,
742
- },
743
- evidence: [
744
- { type: 'text', value: `All ${exhaustion.total_milestones} roadmap milestones checked. VISION sections not yet planned: ${sectionNames}` },
745
- ],
746
- });
747
-
748
- if (replenishmentEvent.ok && !replenishmentEvent.deduplicated) {
749
- const replenishmentIntentId = replenishmentEvent.intent.intent_id;
750
- const triageResult = triageIntent(root, replenishmentIntentId, {
751
- priority: 'p1',
752
- template: 'generic',
753
- charter: `[roadmap-replenishment] Derive next bounded roadmap increment from VISION.md. Unplanned scope: ${sectionNames}. Current roadmap checked through ${exhaustion.latest_milestone}. Read .planning/VISION.md and .planning/ROADMAP.md to select the next testable milestone. Produce concrete unchecked M${exhaustion.total_milestones + 1} items. Do not re-verify previous completed milestones.`,
754
- acceptance_contract: [
755
- `New unchecked milestone items added to .planning/ROADMAP.md`,
756
- `Milestone scope derived from VISION.md sections: ${sectionNames}`,
757
- `Milestone is bounded, testable, and does not duplicate existing checked milestones`,
758
- ],
759
- });
760
-
761
- if (triageResult.ok) {
762
- const triageApproval = options.triageApproval || 'auto';
763
- if (triageApproval === 'auto') {
764
- approveIntent(root, replenishmentIntentId, {
765
- approver: 'continuous_loop',
766
- reason: 'roadmap-replenishment auto-approval (BUG-77)',
767
- });
768
- }
769
-
770
- return {
771
- ok: true,
772
- idle: false,
773
- intentId: replenishmentIntentId,
774
- section: 'Roadmap replenishment',
775
- goal: `Derive next increment from: ${sectionNames}`,
776
- source: 'roadmap_replenishment',
777
- };
778
- }
779
- }
780
- // If deduplicated or triage failed, fall through to idle
781
- }
782
809
  return { ok: true, idle: true };
783
810
  }
784
811
 
@@ -500,6 +500,8 @@ function renderPrompt(role, roleId, turn, state, config, root) {
500
500
  lines.push('- `verification.status: "pass"` is valid only when every `verification.machine_evidence[].exit_code` is `0`');
501
501
  lines.push('- Expected-failure checks must be wrapped in a verifier that exits `0` when the failure occurs as expected; do not list raw non-zero negative-case commands on a passing turn');
502
502
  lines.push('- `artifact.type`: one of `workspace`, `patch`, `commit`, `review`');
503
+ lines.push('- If you make zero repo file edits, set `artifact.type` to `"review"` and `files_changed` to `[]`.');
504
+ lines.push('- Only set `artifact.type` to `"workspace"` when you actually modified repo files and listed every changed path in `files_changed`.');
503
505
  lines.push('- `proposed_next_role`: must be in allowed_next_roles for current phase, or `human`');
504
506
  if (role.write_authority === 'review_only') {
505
507
  lines.push('- `objections`: **must be non-empty** (challenge requirement for review_only roles)');
@@ -4182,6 +4182,13 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4182
4182
  error_code: 'protocol_error',
4183
4183
  };
4184
4184
  }
4185
+ if (opts.normalizeArtifactType && opts.normalizeArtifactType !== 'review') {
4186
+ return {
4187
+ ok: false,
4188
+ error: `Unknown artifact normalization target "${opts.normalizeArtifactType}". Only "review" is supported.`,
4189
+ error_code: 'protocol_error',
4190
+ };
4191
+ }
4185
4192
 
4186
4193
  if (resolutionMode === 'human_merge') {
4187
4194
  if (!currentTurn.conflict_state) {
@@ -4262,7 +4269,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4262
4269
  }
4263
4270
  }
4264
4271
 
4265
- const validation = validateStagedTurnResult(root, validationState, config, { stagingPath: resolvedStagingPath });
4272
+ const validation = validateStagedTurnResult(root, validationState, config, {
4273
+ stagingPath: resolvedStagingPath,
4274
+ normalizeArtifactType: opts.normalizeArtifactType,
4275
+ });
4266
4276
  if (hooksConfig.after_validation && hooksConfig.after_validation.length > 0) {
4267
4277
  const afterValidationPayload = {
4268
4278
  turn_id: currentTurn.turn_id,
@@ -4317,6 +4327,24 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4317
4327
  validation,
4318
4328
  };
4319
4329
  }
4330
+ const artifactTypeWasAutoNormalized = Array.isArray(validation.normalizations)
4331
+ && validation.normalizations.some((entry) => typeof entry === 'string'
4332
+ && entry.includes('artifact.type: auto-normalized empty workspace artifact to review'));
4333
+ if (artifactTypeWasAutoNormalized) {
4334
+ emitRunEvent(root, 'artifact_type_auto_normalized', {
4335
+ run_id: state.run_id,
4336
+ phase: state.phase,
4337
+ status: state.status,
4338
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4339
+ intent_id: currentTurn.intake_context?.intent_id || null,
4340
+ payload: {
4341
+ original_artifact_type: 'workspace',
4342
+ normalized_artifact_type: 'review',
4343
+ reason: 'empty_files_changed_no_repo_mutation_declared',
4344
+ staging_path: resolvedStagingPath,
4345
+ },
4346
+ });
4347
+ }
4320
4348
 
4321
4349
  const rawTurnResult = validation.turnResult;
4322
4350
  const verificationProducedFiles = normalizeVerificationProducedFiles(rawTurnResult.verification);
@@ -4696,7 +4724,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4696
4724
  && (turnResult.files_changed || []).length === 0
4697
4725
  && (observation.files_changed || []).length === 0) {
4698
4726
  const emptyWsError = 'Turn declared artifact.type: "workspace" but files_changed is empty. '
4699
- + 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended.';
4727
+ + 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended. '
4728
+ + `Recovery: agentxchain accept-turn --turn ${currentTurn.turn_id} --normalize-artifact-type=review`;
4700
4729
  transitionToFailedAcceptance(root, state, currentTurn, emptyWsError, {
4701
4730
  error_code: 'empty_workspace_artifact',
4702
4731
  stage: 'artifact_validation',
@@ -49,6 +49,7 @@ export const VALID_RUN_EVENTS = [
49
49
  'state_reconciled_operator_commits',
50
50
  'operator_commit_reconcile_refused',
51
51
  'charter_materialization_required',
52
+ 'artifact_type_auto_normalized',
52
53
  ];
53
54
 
54
55
  /**
@@ -130,7 +130,7 @@
130
130
  "files_changed": {
131
131
  "type": "array",
132
132
  "items": { "type": "string" },
133
- "description": "Paths of files modified during this turn. Must match observed diff for authoritative runtimes."
133
+ "description": "Paths of files modified during this turn. Workspace artifacts must declare a non-empty list that matches observed diff. Review artifacts must use an empty list."
134
134
  },
135
135
  "artifacts_created": {
136
136
  "type": "array",
@@ -195,7 +195,7 @@
195
195
  "properties": {
196
196
  "type": {
197
197
  "enum": ["workspace", "patch", "commit", "review"],
198
- "description": "workspace = direct local changes, patch = unified diff, commit = git commit, review = no code changes"
198
+ "description": "workspace = direct repo file changes with non-empty files_changed, patch = unified diff, commit = git commit, review = no repo mutations with files_changed: []"
199
199
  },
200
200
  "ref": {
201
201
  "type": ["string", "null"],
@@ -91,6 +91,9 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
91
91
  }
92
92
  }
93
93
  }
94
+ if (opts.normalizeArtifactType === 'review') {
95
+ normContext.forceReviewArtifact = true;
96
+ }
94
97
  const { normalized, corrections } = normalizeTurnResult(turnResult, config, normContext);
95
98
  turnResult = normalized;
96
99
  const sidecarResult = maybeAttachIdleExpansionSidecar(
@@ -155,6 +158,7 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
155
158
  errors: [],
156
159
  warnings: allWarnings,
157
160
  turnResult,
161
+ normalizations: corrections,
158
162
  };
159
163
  }
160
164
 
@@ -1008,6 +1012,28 @@ export function normalizeTurnResult(tr, config, context = {}) {
1008
1012
  const allowedNextRoles = isKnownPhase ? (routing?.[currentPhase]?.allowed_next_roles || []) : [];
1009
1013
  const assignedRole = context.assignedRole || normalized.role || null;
1010
1014
  const isReviewOnly = context.writeAuthority === 'review_only';
1015
+ const filesChangedIsEmpty = Array.isArray(normalized.files_changed) && normalized.files_changed.length === 0;
1016
+ const hasExplicitNoEditLifecycleSignal = normalized.run_completion_request === true
1017
+ || (typeof normalized.phase_transition_request === 'string' && normalized.phase_transition_request.trim().length > 0);
1018
+
1019
+ // ── Rule 0a: empty workspace artifact → no-edit review normalization ──
1020
+ if (
1021
+ normalized.artifact
1022
+ && typeof normalized.artifact === 'object'
1023
+ && !Array.isArray(normalized.artifact)
1024
+ && normalized.artifact.type === 'workspace'
1025
+ && filesChangedIsEmpty
1026
+ && (context.forceReviewArtifact || isReviewOnly || hasExplicitNoEditLifecycleSignal)
1027
+ ) {
1028
+ normalized.artifact = {
1029
+ ...normalized.artifact,
1030
+ type: 'review',
1031
+ ref: typeof normalized.turn_id === 'string' && normalized.turn_id.trim()
1032
+ ? `turn:${normalized.turn_id}`
1033
+ : normalized.artifact.ref ?? null,
1034
+ };
1035
+ corrections.push('artifact.type: auto-normalized empty workspace artifact to review because files_changed is empty and no repo mutation was declared');
1036
+ }
1011
1037
 
1012
1038
  const pickAllowedRoleFallback = () => {
1013
1039
  if (allowedNextRoles.length === 0) return null;