agentxchain 2.155.28 → 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.
- package/bin/agentxchain.js +1 -0
- package/package.json +1 -1
- package/src/commands/accept-turn.js +1 -0
- package/src/commands/init.js +2 -0
- package/src/lib/continuous-run.js +72 -57
- package/src/lib/dispatch-bundle.js +2 -0
- package/src/lib/governed-state.js +31 -2
- package/src/lib/run-events.js +1 -0
- package/src/lib/schemas/turn-result.schema.json +2 -2
- package/src/lib/turn-result-validator.js +26 -0
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
@@ -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') {
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
|
@@ -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,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, {
|
|
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',
|
package/src/lib/run-events.js
CHANGED
|
@@ -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.
|
|
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
|
|
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;
|