@planu/cli 5.3.3 → 5.3.5
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/CHANGELOG.md +35 -0
- package/dist/engine/autopilot/action-registry.js +20 -2
- package/dist/engine/constitution/sdd-rules-registry.js +8 -4
- package/dist/engine/drift/violation-resolver.js +30 -8
- package/dist/engine/evidence-gates/evidence-skeletons.js +9 -4
- package/dist/engine/execution/validate-job-executor.js +25 -10
- package/dist/engine/handoff-packager.js +10 -3
- package/dist/engine/implementation-contract/common.d.ts +13 -1
- package/dist/engine/implementation-contract/common.js +19 -3
- package/dist/engine/implementation-contract/evaluator.js +116 -35
- package/dist/engine/implementation-contract/renderer.js +62 -31
- package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
- package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
- package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
- package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
- package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
- package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
- package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
- package/dist/engine/reconcile/apply-changes.d.ts +0 -12
- package/dist/engine/reconcile/apply-changes.js +11 -33
- package/dist/engine/reconcile/verify-write.js +2 -3
- package/dist/engine/rules-reconciler.js +21 -2
- package/dist/engine/spec-format/acceptance-criteria.js +7 -1
- package/dist/engine/spec-format/unified-spec-builder.js +32 -8
- package/dist/engine/spec-generator/fallback-generator.js +5 -3
- package/dist/engine/spec-quality/generic-output-gate.d.ts +7 -1
- package/dist/engine/spec-quality/generic-output-gate.js +199 -8
- package/dist/engine/spec-quality-scorer.js +9 -5
- package/dist/engine/universal-rules/catalog.js +6 -0
- package/dist/engine/universal-rules/rules/agent-teams.js +0 -2
- package/dist/engine/universal-rules/rules/planu-approval-gates.js +0 -1
- package/dist/engine/universal-rules/rules/planu-bdd-criteria.js +0 -1
- package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.js +30 -0
- package/dist/engine/universal-rules/rules/planu-debate-review.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-debate-review.js +38 -0
- package/dist/engine/universal-rules/rules/planu-dogfood-bugs.js +0 -2
- package/dist/engine/universal-rules/rules/planu-english-specs.js +0 -1
- package/dist/engine/universal-rules/rules/planu-minimal-change.js +0 -2
- package/dist/engine/universal-rules/rules/planu-modes.js +0 -2
- package/dist/engine/universal-rules/rules/planu-release-policy.js +0 -1
- package/dist/engine/universal-rules/rules/planu-revert-proof-tests.d.ts +3 -0
- package/dist/engine/universal-rules/rules/planu-revert-proof-tests.js +34 -0
- package/dist/engine/universal-rules/rules/planu-sdd-model-routing.js +0 -1
- package/dist/engine/universal-rules/rules/planu-workflow.js +0 -2
- package/dist/engine/validator/validation-report-writer.js +1 -1
- package/dist/engine/workers/handlers/auto-drift.js +22 -8
- package/dist/tools/create-spec.js +5 -0
- package/dist/tools/reconcile-spec.d.ts +1 -1
- package/dist/tools/reconcile-spec.js +303 -178
- package/dist/tools/update-status/dod-gates.js +3 -3
- package/dist/tools/update-status/evidence-gate.js +10 -17
- package/dist/tools/update-status/transition-guard.js +26 -14
- package/dist/types/reconcile.d.ts +5 -17
- package/dist/types/reconcile.js +1 -7
- package/dist/types/spec-quality.d.ts +6 -1
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { SECTIONS_WITHOUT_LITERAL_BODY_TEXT, } from '../types/index.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
1
3
|
import { elicitOrFallback, buildEnumSchema } from '../engine/elicitation/elicit-helper.js';
|
|
2
4
|
import { ti, t } from '../i18n/index.js';
|
|
3
5
|
import { formatSuccess, addNextSteps } from './response-helpers.js';
|
|
@@ -10,101 +12,153 @@ import { extractSectionBody } from '../engine/spec-format/read-technical-section
|
|
|
10
12
|
import { stripFrontmatter } from '../engine/frontmatter-parser.js';
|
|
11
13
|
import { analyzeLivingSpec } from '../engine/living-spec-analyzer.js';
|
|
12
14
|
import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
|
|
13
|
-
// SPEC-1011 Bug D: Post-write verification + apply text changes
|
|
14
15
|
import { applyChangesToSpec } from '../engine/reconcile/apply-changes.js';
|
|
15
16
|
import { verifyWriteSucceeded } from '../engine/reconcile/verify-write.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
async function autoDetectChanges(spec, _knowledge) {
|
|
20
|
-
const changes = [];
|
|
21
|
-
// Check if estimation was significantly off (only if actuals exist)
|
|
22
|
-
if (spec.actuals) {
|
|
23
|
-
const estimatedTotal = spec.estimation.devHours + spec.estimation.reviewHours;
|
|
24
|
-
const actualTotal = spec.actuals.devHours + spec.actuals.reviewHours;
|
|
25
|
-
const ratio = actualTotal / estimatedTotal;
|
|
26
|
-
if (ratio > 1.3 || ratio < 0.7) {
|
|
27
|
-
changes.push({
|
|
28
|
-
section: 'estimation',
|
|
29
|
-
originalValue: `${estimatedTotal}h estimated`,
|
|
30
|
-
newValue: `${actualTotal}h actual (${Math.round(ratio * 100)}% of estimate)`,
|
|
31
|
-
reason: ratio > 1.3
|
|
32
|
-
? 'Implementation took significantly longer than estimated'
|
|
33
|
-
: 'Implementation was completed faster than estimated',
|
|
34
|
-
approved: true,
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
// Cost discrepancy
|
|
38
|
-
if (Math.abs(spec.estimation.totalCostUsd - spec.actuals.totalCostUsd) > 10) {
|
|
39
|
-
changes.push({
|
|
40
|
-
section: 'cost',
|
|
41
|
-
originalValue: `$${spec.estimation.totalCostUsd}`,
|
|
42
|
-
newValue: `$${spec.actuals.totalCostUsd}`,
|
|
43
|
-
reason: 'Actual cost differs from estimate',
|
|
44
|
-
approved: true,
|
|
45
|
-
});
|
|
46
|
-
}
|
|
17
|
+
function detectEstimationDrift(spec) {
|
|
18
|
+
if (!spec.actuals) {
|
|
19
|
+
return null;
|
|
47
20
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
21
|
+
const estimatedTotal = spec.estimation.devHours + spec.estimation.reviewHours;
|
|
22
|
+
const actualTotal = spec.actuals.devHours + spec.actuals.reviewHours;
|
|
23
|
+
const ratio = actualTotal / estimatedTotal;
|
|
24
|
+
if (ratio <= 1.3 && ratio >= 0.7) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
section: 'estimation',
|
|
29
|
+
originalValue: `${estimatedTotal}h estimated`,
|
|
30
|
+
newValue: `${actualTotal}h actual (${Math.round(ratio * 100)}% of estimate)`,
|
|
31
|
+
reason: ratio > 1.3
|
|
32
|
+
? 'Implementation took significantly longer than estimated'
|
|
33
|
+
: 'Implementation was completed faster than estimated',
|
|
34
|
+
approved: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function detectCostDiscrepancy(spec) {
|
|
38
|
+
if (!spec.actuals) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (Math.abs(spec.estimation.totalCostUsd - spec.actuals.totalCostUsd) <= 10) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
section: 'cost',
|
|
46
|
+
originalValue: `$${spec.estimation.totalCostUsd}`,
|
|
47
|
+
newValue: `$${spec.actuals.totalCostUsd}`,
|
|
48
|
+
reason: 'Actual cost differs from estimate',
|
|
49
|
+
approved: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function detectStatusNeedsUpdate(spec) {
|
|
53
|
+
if (!spec.actuals || spec.status === 'done') {
|
|
54
|
+
return null;
|
|
57
55
|
}
|
|
58
|
-
|
|
56
|
+
return {
|
|
57
|
+
section: 'status',
|
|
58
|
+
originalValue: spec.status,
|
|
59
|
+
newValue: 'done',
|
|
60
|
+
reason: 'Actuals are recorded but status is not done',
|
|
61
|
+
approved: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function detectManualSpecEdits(spec) {
|
|
59
65
|
if (!spec.specPath) {
|
|
60
|
-
return
|
|
66
|
+
return null;
|
|
61
67
|
}
|
|
62
68
|
try {
|
|
63
69
|
const rawHuContent = await readFile(spec.specPath, 'utf-8');
|
|
64
70
|
const huContent = stripFrontmatter(rawHuContent);
|
|
65
|
-
if (huContent.includes('MODIFIED')
|
|
66
|
-
|
|
67
|
-
section: 'hu-manual-edits',
|
|
68
|
-
originalValue: 'Original spec content',
|
|
69
|
-
newValue: 'Manual edits detected in spec.md',
|
|
70
|
-
reason: 'spec.md appears to have been manually modified after creation',
|
|
71
|
-
approved: true,
|
|
72
|
-
});
|
|
71
|
+
if (!huContent.includes('MODIFIED') && !huContent.includes('UPDATED')) {
|
|
72
|
+
return null;
|
|
73
73
|
}
|
|
74
|
+
return {
|
|
75
|
+
section: 'hu-manual-edits',
|
|
76
|
+
originalValue: 'Original spec content',
|
|
77
|
+
newValue: 'Manual edits detected in spec.md',
|
|
78
|
+
reason: 'spec.md appears to have been manually modified after creation',
|
|
79
|
+
approved: true,
|
|
80
|
+
};
|
|
74
81
|
}
|
|
75
82
|
catch {
|
|
76
|
-
|
|
83
|
+
return {
|
|
77
84
|
section: 'hu-file',
|
|
78
85
|
originalValue: spec.specPath,
|
|
79
86
|
newValue: 'File not found',
|
|
80
87
|
reason: 'spec.md file is missing or was moved',
|
|
81
88
|
approved: false,
|
|
82
|
-
}
|
|
89
|
+
};
|
|
83
90
|
}
|
|
91
|
+
}
|
|
92
|
+
async function detectMissingTechnicalSection(spec) {
|
|
84
93
|
try {
|
|
85
94
|
await readFile(spec.technicalPath, 'utf-8');
|
|
95
|
+
return null;
|
|
86
96
|
}
|
|
87
97
|
catch {
|
|
88
|
-
|
|
98
|
+
return {
|
|
89
99
|
section: 'ficha-file',
|
|
90
100
|
originalValue: spec.technicalPath,
|
|
91
101
|
newValue: 'File not found',
|
|
92
102
|
reason: 'spec.md inline Technical/Files section is missing or incomplete',
|
|
93
103
|
approved: false,
|
|
94
|
-
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function autoDetectChanges(spec, _knowledge) {
|
|
108
|
+
const changes = [];
|
|
109
|
+
const estimationDrift = detectEstimationDrift(spec);
|
|
110
|
+
if (estimationDrift) {
|
|
111
|
+
changes.push(estimationDrift);
|
|
112
|
+
}
|
|
113
|
+
const costDiscrepancy = detectCostDiscrepancy(spec);
|
|
114
|
+
if (costDiscrepancy) {
|
|
115
|
+
changes.push(costDiscrepancy);
|
|
116
|
+
}
|
|
117
|
+
const statusNeedsUpdate = detectStatusNeedsUpdate(spec);
|
|
118
|
+
if (statusNeedsUpdate) {
|
|
119
|
+
changes.push(statusNeedsUpdate);
|
|
120
|
+
}
|
|
121
|
+
if (!spec.specPath) {
|
|
122
|
+
return changes;
|
|
123
|
+
}
|
|
124
|
+
const manualEditsChange = await detectManualSpecEdits(spec);
|
|
125
|
+
if (manualEditsChange) {
|
|
126
|
+
changes.push(manualEditsChange);
|
|
127
|
+
}
|
|
128
|
+
const missingTechnicalChange = await detectMissingTechnicalSection(spec);
|
|
129
|
+
if (missingTechnicalChange) {
|
|
130
|
+
changes.push(missingTechnicalChange);
|
|
95
131
|
}
|
|
96
132
|
return changes;
|
|
97
133
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
const MAX_AUDIT_CELL_LENGTH = 120;
|
|
135
|
+
function escapeAuditCell(value) {
|
|
136
|
+
const backslashesEscaped = value.replace(/\\/g, '\\\\');
|
|
137
|
+
// U+2028/U+2029 are ECMAScript LineTerminators, so `^`/`$` with the `m` flag match after them like \n.
|
|
138
|
+
const lineBreaksEscaped = backslashesEscaped.replace(/\r\n|\r|\n|\u2028|\u2029/g, '\\n');
|
|
139
|
+
const pipesEscaped = lineBreaksEscaped.replace(/\|/g, '\\|');
|
|
140
|
+
const escaped = pipesEscaped.replace(/`/g, '\\`');
|
|
141
|
+
if (escaped.length <= MAX_AUDIT_CELL_LENGTH) {
|
|
142
|
+
return escaped;
|
|
143
|
+
}
|
|
144
|
+
const hash = createHash('sha256').update(value).digest('hex').slice(0, 8);
|
|
145
|
+
const suffix = `-${hash}`;
|
|
146
|
+
return `${escaped.slice(0, MAX_AUDIT_CELL_LENGTH - suffix.length)}${suffix}`;
|
|
147
|
+
}
|
|
148
|
+
function insertReconciliationLogEntry(existingTechnical, reconcileSection) {
|
|
149
|
+
if (existingTechnical.includes('### Reconciliation Log')) {
|
|
150
|
+
return existingTechnical.replace(/(### Reconciliation Log[\s\S]*?)(\n### |\n## |\n$|$)/, `$1\n${reconcileSection}\n$2`);
|
|
105
151
|
}
|
|
106
|
-
|
|
107
|
-
|
|
152
|
+
return existingTechnical.length > 0
|
|
153
|
+
? `${existingTechnical}\n\n### Reconciliation Log\n\n${reconcileSection}`
|
|
154
|
+
: `### Reconciliation Log\n\n${reconcileSection}`;
|
|
155
|
+
}
|
|
156
|
+
async function writeAuditLogForAppliedChanges(spec, changes) {
|
|
157
|
+
const appliedChanges = changes.filter((c) => c.approved);
|
|
158
|
+
if (appliedChanges.length === 0) {
|
|
159
|
+
return { auditWritten: false };
|
|
160
|
+
}
|
|
161
|
+
const reconcileRows = appliedChanges.map((c) => `| ${escapeAuditCell(c.section)} | ${escapeAuditCell(c.originalValue)} | ${escapeAuditCell(c.newValue)} | ${escapeAuditCell(c.reason)} |`);
|
|
108
162
|
const reconcileSection = [
|
|
109
163
|
`> Reconciled at: ${new Date().toISOString()}`,
|
|
110
164
|
'',
|
|
@@ -112,33 +166,19 @@ async function applyChangesToSpecFiles(spec, changes) {
|
|
|
112
166
|
'|---------|----------|---------|--------|',
|
|
113
167
|
...reconcileRows,
|
|
114
168
|
].join('\n');
|
|
115
|
-
|
|
169
|
+
let auditWritten = false;
|
|
116
170
|
if (spec.specPath) {
|
|
117
171
|
try {
|
|
118
|
-
// Read current Technical section body and append reconciliation log to it
|
|
119
172
|
const specContent = await readFile(spec.specPath, 'utf-8');
|
|
120
173
|
const existingTechnical = extractSectionBody(specContent, 'Technical');
|
|
121
|
-
|
|
122
|
-
if (existingTechnical.includes('### Reconciliation Log')) {
|
|
123
|
-
// Append inside existing Reconciliation Log subsection. The Log uses
|
|
124
|
-
// h3 (### ) so it nests inside ## Technical without the body parser
|
|
125
|
-
// truncating the parent section. Terminate the inner block at the
|
|
126
|
-
// next h3, h2, hr, or EOF. (SPEC-1010 PR-C dual-Opus review.)
|
|
127
|
-
updatedTechnical = existingTechnical.replace(/(### Reconciliation Log[\s\S]*?)(\n### |\n## |\n$|$)/, `$1\n${reconcileSection}\n$2`);
|
|
128
|
-
}
|
|
129
|
-
else {
|
|
130
|
-
updatedTechnical =
|
|
131
|
-
existingTechnical.length > 0
|
|
132
|
-
? `${existingTechnical}\n\n### Reconciliation Log\n\n${reconcileSection}`
|
|
133
|
-
: `### Reconciliation Log\n\n${reconcileSection}`;
|
|
134
|
-
}
|
|
174
|
+
const updatedTechnical = insertReconciliationLogEntry(existingTechnical, reconcileSection);
|
|
135
175
|
await replaceSectionInSpec(spec.specPath, 'Technical', updatedTechnical);
|
|
176
|
+
auditWritten = true;
|
|
136
177
|
}
|
|
137
|
-
catch {
|
|
138
|
-
|
|
178
|
+
catch (err) {
|
|
179
|
+
void err;
|
|
139
180
|
}
|
|
140
181
|
}
|
|
141
|
-
// Update post-implementation actuals inside the ## Technical section
|
|
142
182
|
if (spec.actuals && spec.specPath) {
|
|
143
183
|
try {
|
|
144
184
|
const specContent = await readFile(spec.specPath, 'utf-8');
|
|
@@ -149,14 +189,84 @@ async function applyChangesToSpecFiles(spec, changes) {
|
|
|
149
189
|
fichaContent = fichaContent.replace(/(\| Total Cost \| [^|]+ \|) — \|/, `$1 $${spec.actuals.totalCostUsd} |`);
|
|
150
190
|
await replaceSectionInSpec(spec.specPath, 'Technical', fichaContent);
|
|
151
191
|
}
|
|
152
|
-
catch {
|
|
153
|
-
|
|
192
|
+
catch (err) {
|
|
193
|
+
void err;
|
|
154
194
|
}
|
|
155
195
|
}
|
|
196
|
+
return { auditWritten };
|
|
197
|
+
}
|
|
198
|
+
function partitionAppliedTextChanges(approvedTextChanges, skipped) {
|
|
199
|
+
const skippedKey = (s) => `${s.section}::${s.originalValue}`;
|
|
200
|
+
const totalKeyCounts = new Map();
|
|
201
|
+
for (const c of approvedTextChanges) {
|
|
202
|
+
const key = skippedKey(c);
|
|
203
|
+
totalKeyCounts.set(key, (totalKeyCounts.get(key) ?? 0) + 1);
|
|
204
|
+
}
|
|
205
|
+
const skippedKeyCounts = new Map();
|
|
206
|
+
for (const s of skipped) {
|
|
207
|
+
const key = skippedKey(s);
|
|
208
|
+
skippedKeyCounts.set(key, (skippedKeyCounts.get(key) ?? 0) + 1);
|
|
209
|
+
}
|
|
210
|
+
const appliedRemaining = new Map();
|
|
211
|
+
for (const [key, total] of totalKeyCounts) {
|
|
212
|
+
appliedRemaining.set(key, total - (skippedKeyCounts.get(key) ?? 0));
|
|
213
|
+
}
|
|
214
|
+
return approvedTextChanges.filter((c) => {
|
|
215
|
+
const key = skippedKey(c);
|
|
216
|
+
const remaining = appliedRemaining.get(key) ?? 0;
|
|
217
|
+
if (remaining > 0) {
|
|
218
|
+
appliedRemaining.set(key, remaining - 1);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async function restoreAfterVerificationFailure(specPath, preWriteSpecContent) {
|
|
225
|
+
if (preWriteSpecContent === null) {
|
|
226
|
+
return { restoreFailed: false, note: '' };
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await atomicWriteFile(specPath, preWriteSpecContent);
|
|
230
|
+
return { restoreFailed: false, note: ' (spec.md has been restored to its pre-write snapshot)' };
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
234
|
+
return {
|
|
235
|
+
restoreFailed: true,
|
|
236
|
+
note: ` (restore to pre-write snapshot ALSO failed: ${reason} — spec.md may be left in a corrupted, partially-written state)`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function verifyWriteOrBuildFailureResult(specPath, appliedTextChanges, approvedChanges, preWriteSpecContent, preWriteVersion) {
|
|
241
|
+
const verification = await verifyWriteSucceeded(specPath, appliedTextChanges);
|
|
242
|
+
if (verification.verified) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const { restoreFailed, note: restoreNote } = await restoreAfterVerificationFailure(specPath, preWriteSpecContent);
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: 'text',
|
|
250
|
+
text: `reconcile_spec post-write verification failed: ${verification.missingChanges.map((m) => `[${m.section}] ${m.reason}`).join('; ')}${restoreNote}`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
isError: true,
|
|
254
|
+
structuredContent: {
|
|
255
|
+
specUpdated: false,
|
|
256
|
+
versionCreated: null,
|
|
257
|
+
postWriteVerificationFailed: true,
|
|
258
|
+
missingChanges: verification.missingChanges,
|
|
259
|
+
appliedChanges: [],
|
|
260
|
+
skippedChanges: approvedChanges.map((c) => ({
|
|
261
|
+
section: c.section,
|
|
262
|
+
originalValue: c.originalValue,
|
|
263
|
+
reason: 'verification-failed',
|
|
264
|
+
})),
|
|
265
|
+
versionRolledBack: !restoreFailed,
|
|
266
|
+
preWriteVersion,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
156
269
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Append a reconciliation note to PLAN.md when changes are applied.
|
|
159
|
-
*/
|
|
160
270
|
async function appendPlanReconcileNote(planPath, changes) {
|
|
161
271
|
try {
|
|
162
272
|
const existing = await readFile(planPath, 'utf-8');
|
|
@@ -173,14 +283,48 @@ async function appendPlanReconcileNote(planPath, changes) {
|
|
|
173
283
|
].join('\n');
|
|
174
284
|
await atomicWriteFile(planPath, existing + note);
|
|
175
285
|
}
|
|
176
|
-
catch {
|
|
177
|
-
|
|
286
|
+
catch (err) {
|
|
287
|
+
void err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function buildConflictResolutionQuestion(pendingChanges) {
|
|
291
|
+
const { field, property } = buildEnumSchema('resolution', ['keep-mine', 'use-spec', 'merge-interactive', 'cancel'], [
|
|
292
|
+
'Keep mine — discard spec suggestions',
|
|
293
|
+
'Use spec — apply all suggested changes',
|
|
294
|
+
'Merge interactively — review each change',
|
|
295
|
+
'Cancel — do not apply anything',
|
|
296
|
+
], `${String(pendingChanges.length)} conflict(s) detected. How to resolve?`, 'keep-mine');
|
|
297
|
+
const schema = { type: 'object', properties: { [field]: property } };
|
|
298
|
+
const fallback = {
|
|
299
|
+
header: 'Conflict Resolution',
|
|
300
|
+
question: `${String(pendingChanges.length)} pending change(s) detected. How do you want to resolve conflicts?`,
|
|
301
|
+
multiSelect: false,
|
|
302
|
+
options: [
|
|
303
|
+
{
|
|
304
|
+
label: 'keep-mine',
|
|
305
|
+
description: 'Keep current state — discard spec suggestions (safe default)',
|
|
306
|
+
},
|
|
307
|
+
{ label: 'use-spec', description: 'Apply all suggested changes from spec' },
|
|
308
|
+
{ label: 'merge-interactive', description: 'Review each change interactively' },
|
|
309
|
+
{ label: 'cancel', description: 'Cancel — do not apply anything' },
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
return { schema, fallback };
|
|
313
|
+
}
|
|
314
|
+
function applyConflictResolution(resolution, allChanges, pendingChanges) {
|
|
315
|
+
if (resolution === 'keep-mine') {
|
|
316
|
+
allChanges.splice(0, allChanges.length, ...allChanges.filter((c) => c.approved));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (resolution === 'use-spec') {
|
|
320
|
+
for (const change of pendingChanges) {
|
|
321
|
+
change.approved = true;
|
|
322
|
+
}
|
|
178
323
|
}
|
|
179
324
|
}
|
|
180
325
|
export async function handleReconcileSpec(params, server) {
|
|
181
326
|
const { specId, projectId, autoDetect = true, livingSpec = false, changes: manualChanges, } = params;
|
|
182
327
|
try {
|
|
183
|
-
// Get the spec
|
|
184
328
|
const spec = await specStore.getSpec(projectId, specId);
|
|
185
329
|
if (!spec) {
|
|
186
330
|
return {
|
|
@@ -188,43 +332,18 @@ export async function handleReconcileSpec(params, server) {
|
|
|
188
332
|
isError: true,
|
|
189
333
|
};
|
|
190
334
|
}
|
|
191
|
-
// Load project knowledge
|
|
192
335
|
const knowledge = await knowledgeStore.getKnowledge(projectId);
|
|
193
|
-
// Collect all changes
|
|
194
336
|
const allChanges = [];
|
|
195
|
-
// Auto-detect changes
|
|
196
337
|
if (autoDetect) {
|
|
197
338
|
const autoChanges = await autoDetectChanges(spec, knowledge);
|
|
198
339
|
allChanges.push(...autoChanges);
|
|
199
340
|
}
|
|
200
|
-
// Add manual changes
|
|
201
341
|
if (manualChanges && manualChanges.length > 0) {
|
|
202
342
|
allChanges.push(...manualChanges);
|
|
203
343
|
}
|
|
204
|
-
// SPEC-595: Elicit conflict resolution strategy when there are pending (non-approved) changes
|
|
205
344
|
const pendingChanges = allChanges.filter((c) => !c.approved);
|
|
206
345
|
if (server !== undefined && pendingChanges.length > 0) {
|
|
207
|
-
const {
|
|
208
|
-
'Keep mine — discard spec suggestions',
|
|
209
|
-
'Use spec — apply all suggested changes',
|
|
210
|
-
'Merge interactively — review each change',
|
|
211
|
-
'Cancel — do not apply anything',
|
|
212
|
-
], `${String(pendingChanges.length)} conflict(s) detected. How to resolve?`, 'keep-mine');
|
|
213
|
-
const schema = { type: 'object', properties: { [field]: property } };
|
|
214
|
-
const fallback = {
|
|
215
|
-
header: 'Conflict Resolution',
|
|
216
|
-
question: `${String(pendingChanges.length)} pending change(s) detected. How do you want to resolve conflicts?`,
|
|
217
|
-
multiSelect: false,
|
|
218
|
-
options: [
|
|
219
|
-
{
|
|
220
|
-
label: 'keep-mine',
|
|
221
|
-
description: 'Keep current state — discard spec suggestions (safe default)',
|
|
222
|
-
},
|
|
223
|
-
{ label: 'use-spec', description: 'Apply all suggested changes from spec' },
|
|
224
|
-
{ label: 'merge-interactive', description: 'Review each change interactively' },
|
|
225
|
-
{ label: 'cancel', description: 'Cancel — do not apply anything' },
|
|
226
|
-
],
|
|
227
|
-
};
|
|
346
|
+
const { schema, fallback } = buildConflictResolutionQuestion(pendingChanges);
|
|
228
347
|
const outcome = await elicitOrFallback(server, `${String(pendingChanges.length)} conflict(s) detected. How to resolve?`, schema, [fallback]);
|
|
229
348
|
if (outcome.mode === 'fallback') {
|
|
230
349
|
return {
|
|
@@ -247,105 +366,100 @@ export async function handleReconcileSpec(params, server) {
|
|
|
247
366
|
if (resolution === 'cancel') {
|
|
248
367
|
return { content: [{ type: 'text', text: `Reconcile ${specId} cancelled.` }] };
|
|
249
368
|
}
|
|
250
|
-
|
|
251
|
-
// Remove pending changes — only keep already-approved ones
|
|
252
|
-
allChanges.splice(0, allChanges.length, ...allChanges.filter((c) => c.approved));
|
|
253
|
-
}
|
|
254
|
-
else if (resolution === 'use-spec') {
|
|
255
|
-
// Approve all pending changes
|
|
256
|
-
for (const change of pendingChanges) {
|
|
257
|
-
change.approved = true;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
// 'merge-interactive': proceed with whatever is currently approved (LLM handles the rest)
|
|
369
|
+
applyConflictResolution(resolution, allChanges, pendingChanges);
|
|
261
370
|
}
|
|
262
|
-
// Apply approved changes to spec files
|
|
263
|
-
await applyChangesToSpecFiles(spec, allChanges);
|
|
264
|
-
// SPEC-1011 Bug D: Apply text changes to spec.md body and run post-write verification.
|
|
265
|
-
// This is the actual write path for manual changes with section content updates.
|
|
266
371
|
const approvedChanges = allChanges.filter((c) => c.approved);
|
|
372
|
+
const metadataChanges = approvedChanges.filter((c) => SECTIONS_WITHOUT_LITERAL_BODY_TEXT.has(c.section));
|
|
373
|
+
const approvedTextChanges = approvedChanges.filter((c) => !SECTIONS_WITHOUT_LITERAL_BODY_TEXT.has(c.section));
|
|
267
374
|
const preWriteVersion = approvedChanges.length > 0 ? await specStore.getLatestVersion(projectId, specId) : null;
|
|
268
375
|
let specBodyWritten = false;
|
|
269
376
|
let bodySkippedChanges = [];
|
|
377
|
+
let appliedTextChanges = [];
|
|
270
378
|
if (spec.specPath && approvedChanges.length > 0) {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// specUpdated:true with versionCreated:N would lie about disk state — exactly the bug.
|
|
280
|
-
//
|
|
281
|
-
// Verify only changes that applyChangesToSpec actually applied. Skipped changes
|
|
282
|
-
// (originalValue not found in section, ambiguous, etc.) are documented as
|
|
283
|
-
// non-fatal further down — including them here would falsely fail verification on
|
|
284
|
-
// legitimate conceptual/metadata-only changes. (SPEC-1010 PR-C dual-Opus review.)
|
|
285
|
-
const skippedKey = (s) => `${s.section}::${s.originalValue}`;
|
|
286
|
-
const skippedKeys = new Set(applyResult.skipped.map(skippedKey));
|
|
287
|
-
const appliedChanges = approvedChanges.filter((c) => !skippedKeys.has(skippedKey(c)));
|
|
288
|
-
const verification = await verifyWriteSucceeded(spec.specPath, appliedChanges);
|
|
289
|
-
if (!verification.verified) {
|
|
379
|
+
const needsPreWriteSnapshot = approvedTextChanges.length > 0;
|
|
380
|
+
let preWriteSpecContent = null;
|
|
381
|
+
if (needsPreWriteSnapshot) {
|
|
382
|
+
try {
|
|
383
|
+
preWriteSpecContent = await readFile(spec.specPath, 'utf-8');
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
290
387
|
return {
|
|
291
388
|
content: [
|
|
292
389
|
{
|
|
293
390
|
type: 'text',
|
|
294
|
-
text: `reconcile_spec
|
|
391
|
+
text: `reconcile_spec refused to apply changes: could not read spec.md to take a pre-write snapshot (${reason})`,
|
|
295
392
|
},
|
|
296
393
|
],
|
|
297
394
|
isError: true,
|
|
298
395
|
structuredContent: {
|
|
299
396
|
specUpdated: false,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
397
|
+
versionCreated: null,
|
|
398
|
+
postWriteVerificationFailed: false,
|
|
399
|
+
missingChanges: [],
|
|
400
|
+
appliedChanges: [],
|
|
401
|
+
skippedChanges: approvedChanges.map((c) => ({
|
|
402
|
+
section: c.section,
|
|
403
|
+
originalValue: c.originalValue,
|
|
404
|
+
reason: 'pre-write-snapshot-failed',
|
|
405
|
+
})),
|
|
406
|
+
versionRolledBack: false,
|
|
305
407
|
preWriteVersion,
|
|
306
408
|
},
|
|
307
409
|
};
|
|
308
410
|
}
|
|
309
411
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
412
|
+
const applyResult = await applyChangesToSpec(spec.specPath, approvedChanges);
|
|
413
|
+
specBodyWritten = applyResult.fileWritten;
|
|
414
|
+
bodySkippedChanges = applyResult.skipped;
|
|
415
|
+
appliedTextChanges = partitionAppliedTextChanges(approvedTextChanges, applyResult.skipped);
|
|
416
|
+
if (applyResult.fileWritten) {
|
|
417
|
+
const verificationFailure = await verifyWriteOrBuildFailureResult(spec.specPath, appliedTextChanges, approvedChanges, preWriteSpecContent, preWriteVersion);
|
|
418
|
+
if (verificationFailure) {
|
|
419
|
+
return verificationFailure;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (!spec.specPath && approvedTextChanges.length > 0) {
|
|
424
|
+
bodySkippedChanges = approvedTextChanges.map((c) => ({
|
|
425
|
+
section: c.section,
|
|
426
|
+
originalValue: c.originalValue,
|
|
427
|
+
reason: 'spec.md path is missing — cannot apply text change',
|
|
428
|
+
reasonCategory: 'skipped',
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
const appliedChanges = [...metadataChanges, ...appliedTextChanges];
|
|
432
|
+
const anyApplied = appliedChanges.length > 0;
|
|
433
|
+
let auditWriteResult = { auditWritten: false };
|
|
434
|
+
if (anyApplied) {
|
|
435
|
+
auditWriteResult = await writeAuditLogForAppliedChanges(spec, appliedChanges);
|
|
313
436
|
}
|
|
314
|
-
|
|
315
|
-
const hasStatusChange = approvedChanges.find((c) => c.section === 'status');
|
|
437
|
+
const hasStatusChange = metadataChanges.find((c) => c.section === 'status');
|
|
316
438
|
if (hasStatusChange) {
|
|
317
|
-
// SPEC-720: Use __internalSetStatus for reconcile (tool-layer maintenance write,
|
|
318
|
-
// not a user-initiated transition — no gates needed here as this is driven by an
|
|
319
|
-
// approved reconcile review with explicit change approval).
|
|
320
439
|
await specStore.__internalSetStatus(projectId, specId, hasStatusChange.newValue);
|
|
321
440
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
await appendPlanReconcileNote(spec.planPath, approvedChanges);
|
|
441
|
+
if (spec.planPath && anyApplied) {
|
|
442
|
+
await appendPlanReconcileNote(spec.planPath, appliedChanges);
|
|
325
443
|
}
|
|
326
|
-
// Create a new SpecVersion only when there were approved changes that actually mutated state.
|
|
327
|
-
// (specBodyWritten OR metadata-only changes that are still valid)
|
|
328
444
|
let createdVersion = null;
|
|
329
|
-
if (
|
|
445
|
+
if (anyApplied) {
|
|
330
446
|
const currentVersion = await specStore.getLatestVersion(projectId, specId);
|
|
331
|
-
const version = createVersion(specId, currentVersion,
|
|
447
|
+
const version = createVersion(specId, currentVersion, appliedChanges, `Reconcile: ${appliedChanges.length} change(s) applied`);
|
|
332
448
|
await specStore.saveVersion(projectId, specId, version);
|
|
333
449
|
createdVersion = version.version;
|
|
334
450
|
}
|
|
335
|
-
// Mark spec as reconciled by updating the timestamp
|
|
336
451
|
await specStore.updateSpec(projectId, specId, {
|
|
337
452
|
updatedAt: new Date().toISOString(),
|
|
338
453
|
});
|
|
339
454
|
const report = {
|
|
340
455
|
specId,
|
|
341
456
|
changes: allChanges,
|
|
342
|
-
specUpdated:
|
|
343
|
-
reason:
|
|
344
|
-
? `Applied ${
|
|
457
|
+
specUpdated: anyApplied,
|
|
458
|
+
reason: anyApplied
|
|
459
|
+
? `Applied ${appliedChanges.length} change(s) to spec`
|
|
345
460
|
: 'No changes to apply',
|
|
346
461
|
reconciledAt: new Date().toISOString(),
|
|
347
462
|
};
|
|
348
|
-
// Living spec analysis: compare criteria against codebase
|
|
349
463
|
let livingSpecReport = null;
|
|
350
464
|
if (livingSpec) {
|
|
351
465
|
const projectKnowledge = await knowledgeStore.getKnowledge(projectId);
|
|
@@ -353,10 +467,22 @@ export async function handleReconcileSpec(params, server) {
|
|
|
353
467
|
livingSpecReport = await analyzeLivingSpec(spec, projectKnowledge.projectPath);
|
|
354
468
|
}
|
|
355
469
|
}
|
|
470
|
+
const appliedChangesReport = appliedChanges.map((c) => ({
|
|
471
|
+
section: c.section,
|
|
472
|
+
newValue: c.newValue,
|
|
473
|
+
}));
|
|
474
|
+
const skippedChangesReport = bodySkippedChanges.map((s) => ({
|
|
475
|
+
section: s.section,
|
|
476
|
+
originalValue: s.originalValue,
|
|
477
|
+
reason: s.reasonCategory,
|
|
478
|
+
}));
|
|
356
479
|
const result = {
|
|
357
480
|
...report,
|
|
358
481
|
versionCreated: createdVersion,
|
|
359
482
|
specBodyWritten,
|
|
483
|
+
auditWritten: auditWriteResult.auditWritten,
|
|
484
|
+
appliedChanges: appliedChangesReport,
|
|
485
|
+
skippedChanges: skippedChangesReport,
|
|
360
486
|
...(bodySkippedChanges.length > 0 ? { skippedTextChanges: bodySkippedChanges } : {}),
|
|
361
487
|
...(livingSpecReport ? { livingSpec: livingSpecReport } : {}),
|
|
362
488
|
summary: {
|
|
@@ -373,7 +499,6 @@ export async function handleReconcileSpec(params, server) {
|
|
|
373
499
|
count: String(allChanges.length),
|
|
374
500
|
}),
|
|
375
501
|
};
|
|
376
|
-
// Notify portal regeneration (fire-and-forget)
|
|
377
502
|
if (knowledge?.projectPath) {
|
|
378
503
|
notifyStoreChange(knowledge.projectPath, 'specs');
|
|
379
504
|
}
|
|
@@ -285,7 +285,7 @@ function validationReceiptGateError(specId, reason) {
|
|
|
285
285
|
},
|
|
286
286
|
};
|
|
287
287
|
}
|
|
288
|
-
function doneReviewDigestError(specId, code, message) {
|
|
288
|
+
function doneReviewDigestError(specId, code, message, digests) {
|
|
289
289
|
const artifactPath = `external Planu project data: handoffs/${specId}/validation-report.json`;
|
|
290
290
|
const fixHint = 'Run validate, then recompute the sha256 digest from the exact report bytes.';
|
|
291
291
|
return {
|
|
@@ -294,7 +294,7 @@ function doneReviewDigestError(specId, code, message) {
|
|
|
294
294
|
structuredContent: {
|
|
295
295
|
error: code,
|
|
296
296
|
code: 422,
|
|
297
|
-
context: { specId, artifactPath },
|
|
297
|
+
context: { specId, artifactPath, ...digests },
|
|
298
298
|
fixHint,
|
|
299
299
|
},
|
|
300
300
|
};
|
|
@@ -430,7 +430,7 @@ async function checkDoneReviewDigest(specId, projectId, implementationReviewDige
|
|
|
430
430
|
}
|
|
431
431
|
const observed = `sha256:${createHash('sha256').update(reportResult).digest('hex')}`;
|
|
432
432
|
if (observed !== implementationReviewDigest) {
|
|
433
|
-
return doneReviewDigestError(specId, 'DONE_REVIEW_DIGEST_MISMATCH', 'The implementation review digest does not match the persisted validation report.');
|
|
433
|
+
return doneReviewDigestError(specId, 'DONE_REVIEW_DIGEST_MISMATCH', 'The implementation review digest does not match the persisted validation report.', { received: implementationReviewDigest, expected: observed });
|
|
434
434
|
}
|
|
435
435
|
let decoded;
|
|
436
436
|
try {
|