agentxchain 2.155.28 → 2.155.30

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,8 @@ 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)')
711
+ .option('--normalize-staged-result', 'Run known-safe staged-result normalization before acceptance')
710
712
  .action(acceptTurnCommand);
711
713
 
712
714
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.28",
3
+ "version": "2.155.30",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,8 @@ 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,
26
+ normalizeStagedResult: Boolean(opts.normalizeStagedResult),
25
27
  });
26
28
  if (!result.ok) {
27
29
  if (result.error_code === 'policy_escalation' || result.error_code === 'policy_violation') {
@@ -136,6 +138,10 @@ export async function acceptTurnCommand(opts = {}) {
136
138
  console.log(` ${chalk.dim('Reason:')} ${errorClass}`);
137
139
  console.log(` ${chalk.dim('Owner:')} human`);
138
140
  console.log(` ${chalk.dim('Action:')} Fix staged result and rerun agentxchain accept-turn, or reject with agentxchain reject-turn --reason "..."`);
141
+ if (result.validation?.errors?.some((err) => /objections\[\d+\]\.statement/.test(err))) {
142
+ const retainedTurnId = opts.turn || result.state?.current_turn?.turn_id || '(turn_id)';
143
+ console.log(` ${chalk.dim('Recovery:')} agentxchain accept-turn --turn ${retainedTurnId} --normalize-staged-result`);
144
+ }
139
145
  console.log(` ${chalk.dim('Turn:')} retained`);
140
146
  if (result.validation?.errors?.length) {
141
147
  for (const err of result.validation.errors) {
@@ -544,6 +544,9 @@ 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\`.
549
+ 6. Every objection must include a non-empty \`statement\`; do not use \`summary\` or \`detail\` as a substitute.
547
550
 
548
551
  ## File Access
549
552
 
@@ -728,69 +728,84 @@ export function seedFromVision(root, visionPath, options = {}) {
728
728
  };
729
729
  }
730
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
+
731
803
  const result = deriveVisionCandidates(root, visionPath);
732
804
  if (!result.ok) {
733
805
  return { ok: false, error: result.error };
734
806
  }
735
807
 
736
808
  if (result.candidates.length === 0) {
737
- // BUG-77: Before declaring idle, check if roadmap is exhausted but VISION
738
- // has unplanned scope. If so, seed a PM roadmap-replenishment intent to
739
- // derive the next bounded milestone instead of idle-exiting.
740
- const exhaustion = detectRoadmapExhaustedVisionOpen(root, visionPath);
741
- if (exhaustion.open) {
742
- const sectionNames = exhaustion.unplanned_sections.join(', ');
743
- const replenishmentEvent = recordEvent(root, {
744
- source: 'vision_scan',
745
- category: 'roadmap_exhausted_vision_open',
746
- signal: {
747
- description: `Roadmap exhausted (${exhaustion.total_milestones} milestones checked through ${exhaustion.latest_milestone}). VISION.md has unplanned scope: ${sectionNames}`,
748
- unplanned_sections: exhaustion.unplanned_sections,
749
- latest_milestone: exhaustion.latest_milestone,
750
- derived: true,
751
- },
752
- evidence: [
753
- { type: 'text', value: `All ${exhaustion.total_milestones} roadmap milestones checked. VISION sections not yet planned: ${sectionNames}` },
754
- ],
755
- });
756
-
757
- if (replenishmentEvent.ok && !replenishmentEvent.deduplicated) {
758
- const replenishmentIntentId = replenishmentEvent.intent.intent_id;
759
- const replenishmentHints = getRoadmapReplenishmentTriageHints(root);
760
- const triageResult = triageIntent(root, replenishmentIntentId, {
761
- priority: 'p1',
762
- template: 'generic',
763
- ...(replenishmentHints.preferred_role ? { preferred_role: replenishmentHints.preferred_role } : {}),
764
- ...(replenishmentHints.phase_scope ? { phase_scope: replenishmentHints.phase_scope } : {}),
765
- 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.`,
766
- acceptance_contract: [
767
- `New unchecked milestone items added to .planning/ROADMAP.md`,
768
- `Milestone scope derived from VISION.md sections: ${sectionNames}`,
769
- `Milestone is bounded, testable, and does not duplicate existing checked milestones`,
770
- ],
771
- });
772
-
773
- if (triageResult.ok) {
774
- const triageApproval = options.triageApproval || 'auto';
775
- if (triageApproval === 'auto') {
776
- approveIntent(root, replenishmentIntentId, {
777
- approver: 'continuous_loop',
778
- reason: 'roadmap-replenishment auto-approval (BUG-77)',
779
- });
780
- }
781
-
782
- return {
783
- ok: true,
784
- idle: false,
785
- intentId: replenishmentIntentId,
786
- section: 'Roadmap replenishment',
787
- goal: `Derive next increment from: ${sectionNames}`,
788
- source: 'roadmap_replenishment',
789
- };
790
- }
791
- }
792
- // If deduplicated or triage failed, fall through to idle
793
- }
794
809
  return { ok: true, idle: true };
795
810
  }
796
811
 
@@ -500,6 +500,9 @@ 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`.');
505
+ lines.push('- Every `objections[]` item must include a non-empty `statement`; do not use `summary` or `detail` as a substitute.');
503
506
  lines.push('- `proposed_next_role`: must be in allowed_next_roles for current phase, or `human`');
504
507
  if (role.write_authority === 'review_only') {
505
508
  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,
@@ -4305,7 +4315,10 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4305
4315
  }
4306
4316
 
4307
4317
  if (!validation.ok) {
4308
- const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}`;
4318
+ const recoveryHint = validation.errors.some((error) => /objections\[\d+\]\.statement/.test(error))
4319
+ ? ` Recovery: agentxchain accept-turn --turn ${currentTurn.turn_id} --normalize-staged-result`
4320
+ : '';
4321
+ const failError = `Validation failed at stage ${validation.stage}: ${validation.errors.join('; ')}${recoveryHint}`;
4309
4322
  transitionToFailedAcceptance(root, state, currentTurn, failError, {
4310
4323
  error_code: 'validation_failed',
4311
4324
  stage: validation.stage,
@@ -4317,6 +4330,40 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4317
4330
  validation,
4318
4331
  };
4319
4332
  }
4333
+ const artifactTypeWasAutoNormalized = Array.isArray(validation.normalizations)
4334
+ && validation.normalizations.some((entry) => typeof entry === 'string'
4335
+ && entry.includes('artifact.type: auto-normalized empty workspace artifact to review'));
4336
+ if (artifactTypeWasAutoNormalized) {
4337
+ emitRunEvent(root, 'artifact_type_auto_normalized', {
4338
+ run_id: state.run_id,
4339
+ phase: state.phase,
4340
+ status: state.status,
4341
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4342
+ intent_id: currentTurn.intake_context?.intent_id || null,
4343
+ payload: {
4344
+ original_artifact_type: 'workspace',
4345
+ normalized_artifact_type: 'review',
4346
+ reason: 'empty_files_changed_no_repo_mutation_declared',
4347
+ staging_path: resolvedStagingPath,
4348
+ },
4349
+ });
4350
+ }
4351
+ for (const event of Array.isArray(validation.normalization_events) ? validation.normalization_events : []) {
4352
+ emitRunEvent(root, 'staged_result_auto_normalized', {
4353
+ run_id: state.run_id,
4354
+ phase: state.phase,
4355
+ status: state.status,
4356
+ turn: { turn_id: currentTurn.turn_id, role_id: currentTurn.assigned_role },
4357
+ intent_id: currentTurn.intake_context?.intent_id || null,
4358
+ payload: {
4359
+ field: event.field,
4360
+ original_value: event.original_value,
4361
+ normalized_value: event.normalized_value,
4362
+ rationale: event.rationale,
4363
+ staging_path: resolvedStagingPath,
4364
+ },
4365
+ });
4366
+ }
4320
4367
 
4321
4368
  const rawTurnResult = validation.turnResult;
4322
4369
  const verificationProducedFiles = normalizeVerificationProducedFiles(rawTurnResult.verification);
@@ -4696,7 +4743,8 @@ function _acceptGovernedTurnLocked(root, config, opts) {
4696
4743
  && (turnResult.files_changed || []).length === 0
4697
4744
  && (observation.files_changed || []).length === 0) {
4698
4745
  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.';
4746
+ + 'Either declare the files modified, or set artifact.type: "review" if no repo mutations were intended. '
4747
+ + `Recovery: agentxchain accept-turn --turn ${currentTurn.turn_id} --normalize-artifact-type=review`;
4700
4748
  transitionToFailedAcceptance(root, state, currentTurn, emptyWsError, {
4701
4749
  error_code: 'empty_workspace_artifact',
4702
4750
  stage: 'artifact_validation',
@@ -49,6 +49,8 @@ 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',
53
+ 'staged_result_auto_normalized',
52
54
  ];
53
55
 
54
56
  /**
@@ -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,7 +91,10 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
91
91
  }
92
92
  }
93
93
  }
94
- const { normalized, corrections } = normalizeTurnResult(turnResult, config, normContext);
94
+ if (opts.normalizeArtifactType === 'review') {
95
+ normContext.forceReviewArtifact = true;
96
+ }
97
+ const { normalized, corrections, normalizationEvents } = normalizeTurnResult(turnResult, config, normContext);
95
98
  turnResult = normalized;
96
99
  const sidecarResult = maybeAttachIdleExpansionSidecar(
97
100
  root,
@@ -155,6 +158,8 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
155
158
  errors: [],
156
159
  warnings: allWarnings,
157
160
  turnResult,
161
+ normalizations: corrections,
162
+ normalization_events: normalizationEvents,
158
163
  };
159
164
  }
160
165
 
@@ -991,8 +996,9 @@ function validateProtocol(tr, state, config) {
991
996
  */
992
997
  export function normalizeTurnResult(tr, config, context = {}) {
993
998
  const corrections = [];
999
+ const normalizationEvents = [];
994
1000
  if (tr === null || typeof tr !== 'object' || Array.isArray(tr)) {
995
- return { normalized: tr, corrections };
1001
+ return { normalized: tr, corrections, normalizationEvents };
996
1002
  }
997
1003
 
998
1004
  const normalized = { ...tr };
@@ -1008,6 +1014,64 @@ export function normalizeTurnResult(tr, config, context = {}) {
1008
1014
  const allowedNextRoles = isKnownPhase ? (routing?.[currentPhase]?.allowed_next_roles || []) : [];
1009
1015
  const assignedRole = context.assignedRole || normalized.role || null;
1010
1016
  const isReviewOnly = context.writeAuthority === 'review_only';
1017
+ const filesChangedIsEmpty = Array.isArray(normalized.files_changed) && normalized.files_changed.length === 0;
1018
+ const hasExplicitNoEditLifecycleSignal = normalized.run_completion_request === true
1019
+ || (typeof normalized.phase_transition_request === 'string' && normalized.phase_transition_request.trim().length > 0);
1020
+
1021
+ // ── Rule 0a: empty workspace artifact → no-edit review normalization ──
1022
+ if (
1023
+ normalized.artifact
1024
+ && typeof normalized.artifact === 'object'
1025
+ && !Array.isArray(normalized.artifact)
1026
+ && normalized.artifact.type === 'workspace'
1027
+ && filesChangedIsEmpty
1028
+ && (context.forceReviewArtifact || hasExplicitNoEditLifecycleSignal)
1029
+ ) {
1030
+ normalized.artifact = {
1031
+ ...normalized.artifact,
1032
+ type: 'review',
1033
+ ref: typeof normalized.turn_id === 'string' && normalized.turn_id.trim()
1034
+ ? `turn:${normalized.turn_id}`
1035
+ : normalized.artifact.ref ?? null,
1036
+ };
1037
+ corrections.push('artifact.type: auto-normalized empty workspace artifact to review because files_changed is empty and no repo mutation was declared');
1038
+ normalizationEvents.push({
1039
+ field: 'artifact.type',
1040
+ original_value: 'workspace',
1041
+ normalized_value: 'review',
1042
+ rationale: 'empty_files_changed_no_repo_mutation_declared',
1043
+ });
1044
+ }
1045
+
1046
+ if (Array.isArray(normalized.objections)) {
1047
+ normalized.objections = normalized.objections.map((objection, index) => {
1048
+ if (objection === null || typeof objection !== 'object' || Array.isArray(objection)) {
1049
+ return objection;
1050
+ }
1051
+ const statement = typeof objection.statement === 'string' ? objection.statement.trim() : '';
1052
+ if (statement) {
1053
+ return objection;
1054
+ }
1055
+ const summary = typeof objection.summary === 'string' ? objection.summary.trim() : '';
1056
+ const detail = typeof objection.detail === 'string' ? objection.detail.trim() : '';
1057
+ const sourceField = summary ? 'summary' : detail ? 'detail' : null;
1058
+ const sourceValue = summary || detail;
1059
+ if (!sourceField) {
1060
+ return objection;
1061
+ }
1062
+ corrections.push(`objections[${index}].statement: copied from ${sourceField}`);
1063
+ normalizationEvents.push({
1064
+ field: `objections[${index}].statement`,
1065
+ original_value: objection.statement ?? null,
1066
+ normalized_value: sourceValue,
1067
+ rationale: `copied_from_${sourceField}`,
1068
+ });
1069
+ return {
1070
+ ...objection,
1071
+ statement: sourceValue,
1072
+ };
1073
+ });
1074
+ }
1011
1075
 
1012
1076
  const pickAllowedRoleFallback = () => {
1013
1077
  if (allowedNextRoles.length === 0) return null;
@@ -1212,7 +1276,7 @@ export function normalizeTurnResult(tr, config, context = {}) {
1212
1276
  }
1213
1277
  }
1214
1278
 
1215
- return { normalized, corrections };
1279
+ return { normalized, corrections, normalizationEvents };
1216
1280
  }
1217
1281
 
1218
1282
  function normalizeIdleExpansionMutualExclusionSentinel(result) {