@planu/cli 5.3.4 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/engine/execution/validate-job-executor.js +25 -10
  3. package/dist/engine/planu-core.darwin-arm64.node.manifest.json +7 -7
  4. package/dist/engine/planu-core.darwin-arm64.node.sbom.json +4 -4
  5. package/dist/engine/planu-core.darwin-x64.node.manifest.json +7 -7
  6. package/dist/engine/planu-core.darwin-x64.node.sbom.json +4 -4
  7. package/dist/engine/planu-core.linux-arm64-gnu.node.manifest.json +7 -7
  8. package/dist/engine/planu-core.linux-arm64-gnu.node.sbom.json +4 -4
  9. package/dist/engine/planu-core.linux-arm64-musl.node.manifest.json +7 -7
  10. package/dist/engine/planu-core.linux-arm64-musl.node.sbom.json +4 -4
  11. package/dist/engine/planu-core.linux-x64-gnu.node.manifest.json +7 -7
  12. package/dist/engine/planu-core.linux-x64-gnu.node.sbom.json +4 -4
  13. package/dist/engine/planu-core.linux-x64-musl.node.manifest.json +7 -7
  14. package/dist/engine/planu-core.linux-x64-musl.node.sbom.json +4 -4
  15. package/dist/engine/planu-core.win32-arm64-msvc.node.manifest.json +7 -7
  16. package/dist/engine/planu-core.win32-arm64-msvc.node.sbom.json +4 -4
  17. package/dist/engine/planu-core.win32-x64-msvc.node.manifest.json +7 -7
  18. package/dist/engine/planu-core.win32-x64-msvc.node.sbom.json +4 -4
  19. package/dist/engine/reconcile/apply-changes.d.ts +0 -12
  20. package/dist/engine/reconcile/apply-changes.js +11 -33
  21. package/dist/engine/reconcile/verify-write.js +2 -3
  22. package/dist/engine/rules-reconciler.js +21 -2
  23. package/dist/engine/spec-format/acceptance-criteria.js +7 -1
  24. package/dist/engine/universal-rules/catalog.js +6 -0
  25. package/dist/engine/universal-rules/rules/agent-teams.js +0 -2
  26. package/dist/engine/universal-rules/rules/planu-approval-gates.js +0 -1
  27. package/dist/engine/universal-rules/rules/planu-bdd-criteria.js +0 -1
  28. package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.d.ts +3 -0
  29. package/dist/engine/universal-rules/rules/planu-clean-code-no-comments.js +30 -0
  30. package/dist/engine/universal-rules/rules/planu-debate-review.d.ts +3 -0
  31. package/dist/engine/universal-rules/rules/planu-debate-review.js +38 -0
  32. package/dist/engine/universal-rules/rules/planu-dogfood-bugs.js +0 -2
  33. package/dist/engine/universal-rules/rules/planu-english-specs.js +0 -1
  34. package/dist/engine/universal-rules/rules/planu-minimal-change.js +0 -2
  35. package/dist/engine/universal-rules/rules/planu-modes.js +0 -2
  36. package/dist/engine/universal-rules/rules/planu-release-policy.js +0 -1
  37. package/dist/engine/universal-rules/rules/planu-revert-proof-tests.d.ts +3 -0
  38. package/dist/engine/universal-rules/rules/planu-revert-proof-tests.js +34 -0
  39. package/dist/engine/universal-rules/rules/planu-sdd-model-routing.js +0 -1
  40. package/dist/engine/universal-rules/rules/planu-workflow.js +0 -2
  41. package/dist/engine/validator/validation-report-writer.js +1 -1
  42. package/dist/tools/reconcile-spec.d.ts +1 -1
  43. package/dist/tools/reconcile-spec.js +303 -178
  44. package/dist/tools/update-status/dod-gates.js +3 -3
  45. package/dist/types/reconcile.d.ts +5 -17
  46. package/dist/types/reconcile.js +1 -7
  47. package/package.json +9 -9
  48. package/planu-native.json +1 -1
  49. 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
- * Auto-detect changes by comparing spec metadata with current state.
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
- // Check if status should be updated
49
- if (spec.actuals && spec.status !== 'done') {
50
- changes.push({
51
- section: 'status',
52
- originalValue: spec.status,
53
- newValue: 'done',
54
- reason: 'Actuals are recorded but status is not done',
55
- approved: false,
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
- // Detect if spec files have been manually modified
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 changes;
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') || huContent.includes('UPDATED')) {
66
- changes.push({
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
- changes.push({
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
- changes.push({
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
- * Apply approved changes to the spec files.
100
- */
101
- async function applyChangesToSpecFiles(spec, changes) {
102
- const approvedChanges = changes.filter((c) => c.approved);
103
- if (approvedChanges.length === 0) {
104
- return;
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
- // Build reconciliation log entry
107
- const reconcileRows = approvedChanges.map((c) => `| ${c.section} | ${c.originalValue} | ${c.newValue} | ${c.reason} |`);
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
- // Write reconciliation log into the ## Technical section of spec.md
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
- let updatedTechnical;
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
- // spec.md unreadable — skip
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
- // spec.md unreadable — skip
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
- // Non-fatal: if PLAN.md can't be updated, reconcile still succeeds
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 { field, property } = buildEnumSchema('resolution', ['keep-mine', 'use-spec', 'merge-interactive', 'cancel'], [
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
- if (resolution === 'keep-mine') {
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 applyResult = await applyChangesToSpec(spec.specPath, approvedChanges);
272
- specBodyWritten = applyResult.fileWritten;
273
- bodySkippedChanges = applyResult.skipped;
274
- if (applyResult.fileWritten) {
275
- // SPEC-1011 Bug D — Post-write integrity check: re-read and verify each newValue
276
- // is present. If verification fails, the file was written but the content does not
277
- // reflect the requested changes (e.g., atomic-write race or replace silently no-oped).
278
- // In that case we surface a hard error and do NOT create a version, since reporting
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 post-write verification failed: ${verification.missingChanges.map((m) => `[${m.section}] ${m.reason}`).join('; ')}`,
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
- postWriteVerificationFailed: true,
301
- missingChanges: verification.missingChanges,
302
- // The file was already written before verification ran; "version-rolled-back"
303
- // means we refused to record a SpecVersion entry, not that disk was reverted.
304
- versionRolledBack: true,
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
- // applyResult.skipped (originalValue not found in spec.md body) is non-fatal:
311
- // many manual changes are conceptual (e.g., scope expansion notes) and only update
312
- // metadata/version, not the spec body. Surfaced via structuredContent below.
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
- // Update spec metadata if needed
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
- // Update PLAN.md if spec has one and there were approved changes
323
- if (spec.planPath && approvedChanges.length > 0) {
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 (approvedChanges.length > 0) {
445
+ if (anyApplied) {
330
446
  const currentVersion = await specStore.getLatestVersion(projectId, specId);
331
- const version = createVersion(specId, currentVersion, approvedChanges, `Reconcile: ${approvedChanges.length} change(s) applied`);
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: approvedChanges.length > 0,
343
- reason: approvedChanges.length > 0
344
- ? `Applied ${approvedChanges.length} change(s) to spec`
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 {