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.
- package/bin/agentxchain.js +2 -0
- package/package.json +1 -1
- package/src/commands/accept-turn.js +6 -0
- package/src/commands/init.js +3 -0
- package/src/lib/continuous-run.js +72 -57
- package/src/lib/dispatch-bundle.js +3 -0
- package/src/lib/governed-state.js +51 -3
- package/src/lib/run-events.js +2 -0
- package/src/lib/schemas/turn-result.schema.json +2 -2
- package/src/lib/turn-result-validator.js +67 -3
package/bin/agentxchain.js
CHANGED
|
@@ -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
|
@@ -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) {
|
package/src/commands/init.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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',
|
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,7 +91,10 @@ export function validateStagedTurnResult(root, state, config, opts = {}) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
-
|
|
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) {
|