@open-agent-toolkit/cli 0.3.21 → 0.3.22

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 (114) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/reference/cli-reference.md +8 -3
  3. package/assets/docs/reference/configuration.md +3 -0
  4. package/assets/docs/reference/oat-directory-structure.md +1 -1
  5. package/assets/docs/reference/project-artifacts.md +141 -2
  6. package/assets/docs/reference/project-state-machine.md +2 -2
  7. package/assets/docs/workflows/advanced/autonomy.md +29 -12
  8. package/assets/docs/workflows/advanced/cursor-cloud.md +20 -6
  9. package/assets/docs/workflows/advanced/workflow-gates.md +9 -0
  10. package/assets/docs/workflows/projects/closeout/closeout-skills.md +24 -5
  11. package/assets/docs/workflows/projects/closeout/retro.md +6 -2
  12. package/assets/docs/workflows/projects/execution/implementation-execution.md +18 -4
  13. package/assets/docs/workflows/projects/execution/index.md +1 -1
  14. package/assets/docs/workflows/projects/execution/picking-up-projects.md +3 -2
  15. package/assets/docs/workflows/projects/execution/project-log.md +100 -11
  16. package/assets/docs/workflows/projects/lifecycle.md +3 -3
  17. package/assets/docs/workflows/projects/reviews/index.md +18 -0
  18. package/assets/public-package-versions.json +4 -4
  19. package/assets/skills/oat-cursor-cloud-projects/SKILL.md +12 -8
  20. package/assets/skills/oat-cursor-cloud-projects/references/cursor-cloud-mechanics.md +63 -2
  21. package/assets/skills/oat-project-autonomous/SKILL.md +88 -26
  22. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  23. package/assets/skills/oat-project-brief/SKILL.md +1 -1
  24. package/assets/skills/oat-project-brief/references/brief-contract.md +33 -27
  25. package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +1 -1
  26. package/assets/skills/oat-project-complete/SKILL.md +142 -25
  27. package/assets/skills/oat-project-complete-auto/SKILL.md +4 -4
  28. package/assets/skills/oat-project-dispatch-subagents/SKILL.md +10 -5
  29. package/assets/skills/oat-project-document/SKILL.md +1 -1
  30. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  31. package/assets/skills/oat-project-goal/SKILL.md +63 -15
  32. package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +1 -1
  33. package/assets/skills/oat-project-implement/SKILL.md +6 -6
  34. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +28 -7
  35. package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +10 -5
  36. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  37. package/assets/skills/oat-project-implement/references/phase-execution.md +23 -9
  38. package/assets/skills/oat-project-implement/references/plan-and-resume.md +4 -4
  39. package/assets/skills/oat-project-lite/SKILL.md +1 -1
  40. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
  41. package/assets/skills/oat-project-pr-final/SKILL.md +6 -1
  42. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  43. package/assets/skills/oat-project-pr-progress/SKILL.md +6 -1
  44. package/assets/skills/oat-project-quick-start/SKILL.md +1 -1
  45. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  46. package/assets/skills/oat-project-reconcile/SKILL.md +13 -19
  47. package/assets/skills/oat-project-retro/SKILL.md +5 -2
  48. package/assets/skills/oat-project-retro/references/evidence-and-lanes.md +9 -3
  49. package/assets/skills/oat-project-review-provide/SKILL.md +28 -3
  50. package/assets/skills/oat-project-review-receive/SKILL.md +32 -18
  51. package/assets/skills/oat-project-review-receive-remote/SKILL.md +10 -6
  52. package/assets/skills/oat-project-revise/SKILL.md +9 -2
  53. package/assets/skills/oat-project-summary/SKILL.md +166 -37
  54. package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +3 -3
  55. package/assets/templates/implementation-brief.md +5 -36
  56. package/assets/templates/implementation.md +11 -81
  57. package/assets/templates/project-log.md +2 -31
  58. package/assets/templates/project-retro.md +4 -1
  59. package/assets/templates/summary.md +13 -5
  60. package/dist/commands/config/index.d.ts.map +1 -1
  61. package/dist/commands/config/index.js +44 -1
  62. package/dist/commands/project/archive/archive-utils.d.ts +205 -8
  63. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  64. package/dist/commands/project/archive/archive-utils.js +708 -44
  65. package/dist/commands/project/archive/index.d.ts.map +1 -1
  66. package/dist/commands/project/archive/index.js +1 -0
  67. package/dist/commands/project/archive/push-runner.d.ts +9 -1
  68. package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
  69. package/dist/commands/project/archive/push-runner.js +56 -18
  70. package/dist/commands/project/archive/sync-runner.d.ts +28 -0
  71. package/dist/commands/project/archive/sync-runner.d.ts.map +1 -1
  72. package/dist/commands/project/archive/sync-runner.js +44 -22
  73. package/dist/commands/project/complete-state/index.d.ts.map +1 -1
  74. package/dist/commands/project/complete-state/index.js +45 -0
  75. package/dist/commands/project/dispatch/index.d.ts.map +1 -1
  76. package/dist/commands/project/dispatch/index.js +24 -2
  77. package/dist/commands/project/dispatch/record.d.ts +54 -1
  78. package/dist/commands/project/dispatch/record.d.ts.map +1 -1
  79. package/dist/commands/project/dispatch/record.js +229 -1
  80. package/dist/commands/project/index.d.ts.map +1 -1
  81. package/dist/commands/project/index.js +2 -0
  82. package/dist/commands/project/journal-check/evaluator.d.ts +17 -0
  83. package/dist/commands/project/journal-check/evaluator.d.ts.map +1 -0
  84. package/dist/commands/project/journal-check/evaluator.js +140 -0
  85. package/dist/commands/project/journal-check/index.d.ts +34 -0
  86. package/dist/commands/project/journal-check/index.d.ts.map +1 -0
  87. package/dist/commands/project/journal-check/index.js +163 -0
  88. package/dist/commands/project/journal-check/rules.d.ts +68 -0
  89. package/dist/commands/project/journal-check/rules.d.ts.map +1 -0
  90. package/dist/commands/project/journal-check/rules.js +994 -0
  91. package/dist/commands/project/journal-check/types.d.ts +63 -0
  92. package/dist/commands/project/journal-check/types.d.ts.map +1 -0
  93. package/dist/commands/project/journal-check/types.js +26 -0
  94. package/dist/commands/project/log/append.d.ts +6 -0
  95. package/dist/commands/project/log/append.d.ts.map +1 -1
  96. package/dist/commands/project/log/append.js +114 -6
  97. package/dist/commands/project/log/check.d.ts +23 -1
  98. package/dist/commands/project/log/check.d.ts.map +1 -1
  99. package/dist/commands/project/log/check.js +20 -3
  100. package/dist/commands/project/log/grammar.d.ts +37 -0
  101. package/dist/commands/project/log/grammar.d.ts.map +1 -1
  102. package/dist/commands/project/log/grammar.js +72 -0
  103. package/dist/commands/project/log/rollup.d.ts +87 -1
  104. package/dist/commands/project/log/rollup.d.ts.map +1 -1
  105. package/dist/commands/project/log/rollup.js +257 -5
  106. package/dist/config/oat-config.d.ts +14 -0
  107. package/dist/config/oat-config.d.ts.map +1 -1
  108. package/dist/config/oat-config.js +17 -0
  109. package/dist/config/resolve.d.ts.map +1 -1
  110. package/dist/config/resolve.js +5 -1
  111. package/dist/providers/identity/generic-dispatch-record.d.ts +33 -0
  112. package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -1
  113. package/dist/providers/identity/generic-dispatch-record.js +60 -1
  114. package/package.json +2 -2
@@ -0,0 +1,994 @@
1
+ import { parseDispatchRunLine } from '../../project/dispatch/record.js';
2
+ import { normalizePhaseId, normalizeTaskId, } from '@open-agent-toolkit/control-plane';
3
+ import { classifyDispatchOutcome, REQUEST_ID_PATTERN, } from '../../../providers/identity/generic-dispatch-record.js';
4
+ import { JOURNAL_OVERRIDE_CODES, } from './types.js';
5
+ const IMPLEMENT = 'oat-project-implement';
6
+ const RECEIVE = 'oat-project-review-receive';
7
+ const COMPLETE = 'oat-project-complete';
8
+ const RECONCILE = 'oat-project-reconcile';
9
+ const PLAN = 'oat-project-plan';
10
+ const SHA_PATTERN = /^[0-9a-f]{7,64}$/;
11
+ /** Skills a completed journal may still point at through `oat_ready_for`. */
12
+ const CLOSEOUT_SKILLS = new Set([
13
+ 'oat-project-summary',
14
+ 'oat-project-document',
15
+ 'oat-project-pr-progress',
16
+ 'oat-project-pr-final',
17
+ 'oat-project-retro',
18
+ 'oat-project-complete',
19
+ 'oat-project-complete-auto',
20
+ ]);
21
+ const TERMINAL_PHASE_STATUSES = new Set(['complete', 'completed']);
22
+ const EXECUTION_RECORD_STATUSES = new Set([
23
+ 'completed',
24
+ 'failed',
25
+ 'cancelled',
26
+ 'no-change',
27
+ 'in_progress',
28
+ ]);
29
+ const TERMINAL_EXECUTION_STATUSES = new Set([
30
+ 'completed',
31
+ 'failed',
32
+ 'cancelled',
33
+ 'no-change',
34
+ ]);
35
+ export function isBriefMode(workflowMode) {
36
+ return workflowMode === 'brief' || workflowMode === 'goal';
37
+ }
38
+ function inScope(context, phaseId) {
39
+ return context.scope === null || context.scope.has(phaseId);
40
+ }
41
+ function terminalBoundary(context) {
42
+ return context.boundary !== 'review';
43
+ }
44
+ function push(context, finding) {
45
+ context.findings.push({ severity: 'refuse', ...finding });
46
+ }
47
+ function firstCommitToken(value) {
48
+ return (value.trim().split(/\s+/)[0] ?? '').replace(/^`|`$/g, '');
49
+ }
50
+ function sectionAt(records, line) {
51
+ let owner = null;
52
+ for (const section of records.sections) {
53
+ if (section.line <= line) {
54
+ owner = section;
55
+ }
56
+ else {
57
+ break;
58
+ }
59
+ }
60
+ if (owner !== null && line > owner.endLine) {
61
+ return null;
62
+ }
63
+ return owner;
64
+ }
65
+ function frontmatterKeyLine(lines, key) {
66
+ if (lines[0]?.trim() !== '---') {
67
+ return null;
68
+ }
69
+ for (let index = 1; index < lines.length; index += 1) {
70
+ const line = lines[index] ?? '';
71
+ if (line.trim() === '---') {
72
+ return null;
73
+ }
74
+ if (line.startsWith(`${key}:`)) {
75
+ return index + 1;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Metadata and status agreement
82
+ // ---------------------------------------------------------------------------
83
+ export function checkMetadata(context, stateLines) {
84
+ const { journalMeta, stateMeta } = context;
85
+ if (journalMeta.state === 'malformed') {
86
+ push(context, {
87
+ code: 'journal-frontmatter-malformed',
88
+ path: 'implementation.md',
89
+ line: 1,
90
+ owner: RECONCILE,
91
+ message: `The journal frontmatter is not valid YAML: ${journalMeta.reason}`,
92
+ });
93
+ }
94
+ if (stateMeta.state === 'malformed') {
95
+ push(context, {
96
+ code: 'state-frontmatter-malformed',
97
+ path: 'state.md',
98
+ line: 1,
99
+ owner: RECONCILE,
100
+ message: `The state frontmatter is not valid YAML: ${stateMeta.reason}`,
101
+ });
102
+ }
103
+ if (context.boundary !== 'completion') {
104
+ return;
105
+ }
106
+ if (journalMeta.state === 'missing') {
107
+ push(context, {
108
+ code: 'journal-frontmatter-missing',
109
+ path: 'implementation.md',
110
+ line: null,
111
+ owner: IMPLEMENT,
112
+ message: 'The journal has no frontmatter; completion needs oat_status: complete.',
113
+ });
114
+ }
115
+ if (journalMeta.state === 'ok') {
116
+ const lines = context.journal.lines;
117
+ if (journalMeta.record.oat_status !== 'complete') {
118
+ push(context, {
119
+ code: 'journal-status-not-terminal',
120
+ path: 'implementation.md',
121
+ line: frontmatterKeyLine(lines, 'oat_status'),
122
+ owner: IMPLEMENT,
123
+ message: `Journal oat_status is ${String(journalMeta.record.oat_status ?? 'missing')}; completion requires complete.`,
124
+ });
125
+ }
126
+ const readyFor = journalMeta.record.oat_ready_for;
127
+ if (readyFor !== null &&
128
+ readyFor !== undefined &&
129
+ readyFor !== 'null' &&
130
+ !CLOSEOUT_SKILLS.has(String(readyFor))) {
131
+ push(context, {
132
+ code: 'state-status-disagrees',
133
+ path: 'implementation.md',
134
+ line: frontmatterKeyLine(lines, 'oat_ready_for'),
135
+ owner: IMPLEMENT,
136
+ message: `Journal oat_ready_for is ${String(readyFor)}; with oat_status: complete it must be null or a closeout skill.`,
137
+ });
138
+ }
139
+ const pointer = journalMeta.record.oat_current_task_id;
140
+ if (pointer !== null && pointer !== undefined && pointer !== 'null') {
141
+ push(context, {
142
+ code: 'journal-pointer-not-cleared',
143
+ path: 'implementation.md',
144
+ line: frontmatterKeyLine(lines, 'oat_current_task_id'),
145
+ owner: IMPLEMENT,
146
+ message: `Journal oat_current_task_id still points at ${String(pointer)}; completion requires null.`,
147
+ });
148
+ }
149
+ }
150
+ if (stateMeta.state === 'ok') {
151
+ const phaseStatus = stateMeta.record.oat_phase_status;
152
+ if (phaseStatus !== 'complete' && phaseStatus !== 'pr_open') {
153
+ push(context, {
154
+ code: 'state-status-disagrees',
155
+ path: 'state.md',
156
+ line: frontmatterKeyLine(stateLines, 'oat_phase_status'),
157
+ owner: IMPLEMENT,
158
+ message: `state.md oat_phase_status is ${String(phaseStatus ?? 'missing')}; completion requires complete or pr_open.`,
159
+ });
160
+ }
161
+ const pointer = stateMeta.record.oat_current_task;
162
+ if (pointer !== null && pointer !== undefined && pointer !== 'null') {
163
+ push(context, {
164
+ code: 'state-pointer-not-cleared',
165
+ path: 'state.md',
166
+ line: frontmatterKeyLine(stateLines, 'oat_current_task'),
167
+ owner: IMPLEMENT,
168
+ message: `state.md oat_current_task still points at ${String(pointer)}; completion requires null.`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Task modes: plan task ids ↔ unique journal blocks
175
+ // ---------------------------------------------------------------------------
176
+ function checklistHint(context, rawTaskId) {
177
+ const pattern = new RegExp(`^\\s*[-*] \\[[ xX]\\] .*\\b${rawTaskId}\\b`);
178
+ return context.journal.lines.some((line, index) => context.journal.recordMask[index] === true && pattern.test(line))
179
+ ? ' A checklist line names it, but a checklist is a display, never completion evidence.'
180
+ : '';
181
+ }
182
+ function groupTasks(tasks) {
183
+ const grouped = new Map();
184
+ for (const task of tasks) {
185
+ const group = grouped.get(task.taskId) ?? [];
186
+ group.push(task);
187
+ grouped.set(task.taskId, group);
188
+ }
189
+ return grouped;
190
+ }
191
+ function checkDuplicates(context, blocks) {
192
+ for (const duplicate of blocks.slice(1)) {
193
+ push(context, {
194
+ code: 'task-duplicate-block',
195
+ path: 'implementation.md',
196
+ line: duplicate.line,
197
+ owner: IMPLEMENT,
198
+ message: `Task ${duplicate.taskId} has more than one journal block (also line ${blocks[0]?.line ?? '?'}); spelled ${duplicate.rawTaskId} here.`,
199
+ });
200
+ }
201
+ }
202
+ function checkOwnedStatus(context, block) {
203
+ if (block.statuses.length === 0) {
204
+ push(context, {
205
+ code: 'task-status-missing',
206
+ path: 'implementation.md',
207
+ line: block.line,
208
+ owner: IMPLEMENT,
209
+ message: `Task ${block.taskId} owns no **Status:** line before the next heading of equal or higher level.`,
210
+ });
211
+ return null;
212
+ }
213
+ if (block.statuses.length > 1) {
214
+ push(context, {
215
+ code: 'task-status-ambiguous',
216
+ path: 'implementation.md',
217
+ line: block.statuses[1]?.line ?? block.line,
218
+ owner: IMPLEMENT,
219
+ message: `Task ${block.taskId} owns ${block.statuses.length} **Status:** lines; exactly one is required.`,
220
+ });
221
+ return null;
222
+ }
223
+ return block.status;
224
+ }
225
+ function checkCommitEvidence(context, block) {
226
+ const token = block.commit === null ? '' : firstCommitToken(block.commit);
227
+ if (SHA_PATTERN.test(token)) {
228
+ return;
229
+ }
230
+ push(context, {
231
+ code: 'task-commit-missing',
232
+ path: 'implementation.md',
233
+ line: block.commits[0]?.line ?? block.statuses[0]?.line ?? block.line,
234
+ owner: IMPLEMENT,
235
+ message: block.commits.length > 1
236
+ ? `Completed task ${block.taskId} owns ${block.commits.length} **Commit:** lines; exactly one commit SHA is required.`
237
+ : `Completed task ${block.taskId} owns no **Commit:** SHA.`,
238
+ subject: { taskId: block.taskId, phaseId: block.phaseId },
239
+ });
240
+ }
241
+ function checkPlacement(context, block) {
242
+ if (block.inOwningPhase) {
243
+ return;
244
+ }
245
+ push(context, {
246
+ code: 'task-misplaced',
247
+ path: 'implementation.md',
248
+ line: block.line,
249
+ owner: IMPLEMENT,
250
+ message: `Task ${block.taskId} sits under "${block.section?.text ?? 'no section'}", not its phase ${block.phaseId}.`,
251
+ });
252
+ }
253
+ /**
254
+ * Plan record integrity, checked before any journal evidence is matched: every
255
+ * normalized plan task id is declared once, and under its own phase. Both are
256
+ * plan authoring defects owned by the plan, never override-eligible gaps.
257
+ */
258
+ function checkPlanRecords(context, taskId, planBlocks) {
259
+ const [first, ...duplicates] = planBlocks;
260
+ for (const duplicate of duplicates) {
261
+ push(context, {
262
+ code: 'plan-task-duplicate',
263
+ path: 'plan.md',
264
+ line: duplicate.line,
265
+ owner: PLAN,
266
+ message: `Plan task ${duplicate.rawTaskId} repeats ${taskId}, already declared at plan.md line ${first?.line ?? '?'}; each plan task id is declared once.`,
267
+ });
268
+ }
269
+ for (const block of planBlocks) {
270
+ if (!block.inOwningPhase) {
271
+ push(context, {
272
+ code: 'plan-task-misplaced',
273
+ path: 'plan.md',
274
+ line: block.line,
275
+ owner: PLAN,
276
+ message: `Plan task ${block.rawTaskId} sits under "${block.section?.text ?? 'no section'}", not its phase ${block.phaseId}.`,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ export function checkTaskModeRecords(context) {
282
+ const planTasks = groupTasks(context.plan.tasks);
283
+ const journalTasks = groupTasks(context.journal.tasks);
284
+ for (const [taskId, planBlocks] of planTasks) {
285
+ const planTask = planBlocks[0];
286
+ if (planTask === undefined || !inScope(context, planTask.phaseId)) {
287
+ continue;
288
+ }
289
+ checkPlanRecords(context, taskId, planBlocks);
290
+ const blocks = journalTasks.get(taskId) ?? [];
291
+ if (blocks.length === 0) {
292
+ push(context, {
293
+ code: 'task-block-missing',
294
+ path: 'implementation.md',
295
+ line: null,
296
+ owner: IMPLEMENT,
297
+ message: `Plan task ${taskId} (plan.md line ${planTask.line}) has no journal block.${checklistHint(context, planTask.rawTaskId)}`,
298
+ subject: { taskId, phaseId: planTask.phaseId },
299
+ });
300
+ continue;
301
+ }
302
+ checkDuplicates(context, blocks);
303
+ const [block] = blocks;
304
+ if (block === undefined) {
305
+ continue;
306
+ }
307
+ checkPlacement(context, block);
308
+ const status = checkOwnedStatus(context, block);
309
+ if (status === null) {
310
+ continue;
311
+ }
312
+ if (status !== 'completed') {
313
+ push(context, {
314
+ code: 'task-not-completed',
315
+ path: 'implementation.md',
316
+ line: block.statuses[0]?.line ?? block.line,
317
+ owner: IMPLEMENT,
318
+ message: `Task ${taskId} is ${status}; the boundary requires completed or an approved scope disposition.`,
319
+ subject: { taskId, phaseId: block.phaseId },
320
+ });
321
+ continue;
322
+ }
323
+ checkCommitEvidence(context, block);
324
+ }
325
+ for (const [taskId, blocks] of journalTasks) {
326
+ if (planTasks.has(taskId)) {
327
+ continue;
328
+ }
329
+ for (const block of blocks) {
330
+ if (!inScope(context, block.phaseId)) {
331
+ continue;
332
+ }
333
+ push(context, {
334
+ code: 'task-unplanned',
335
+ path: 'implementation.md',
336
+ line: block.line,
337
+ owner: IMPLEMENT,
338
+ message: `Journal block ${block.rawTaskId} names no plan task.`,
339
+ });
340
+ }
341
+ }
342
+ }
343
+ /**
344
+ * Phase status agreement in task modes: once every plan task of a phase is
345
+ * completed, its own `**Status:**` line must say so. A `blocked` or
346
+ * `in_progress` header under a completed phase is refused at its line.
347
+ */
348
+ export function checkTaskModePhaseStatus(context) {
349
+ if (!terminalBoundary(context)) {
350
+ return;
351
+ }
352
+ const completedByPhase = new Map(context.progress.phases.map((phase) => [
353
+ phase.phaseId,
354
+ phase.total > 0 && phase.completed === phase.total,
355
+ ]));
356
+ for (const section of context.journal.sections) {
357
+ const phaseId = section.phase?.declaredPhaseId;
358
+ if (phaseId === undefined || !inScope(context, phaseId)) {
359
+ continue;
360
+ }
361
+ if (section.statuses.length > 1) {
362
+ push(context, {
363
+ code: 'phase-status-ambiguous',
364
+ path: 'implementation.md',
365
+ line: section.statuses[1]?.line ?? section.line,
366
+ owner: IMPLEMENT,
367
+ message: `Phase ${phaseId} carries ${section.statuses.length} **Status:** lines.`,
368
+ });
369
+ continue;
370
+ }
371
+ const status = section.status;
372
+ if (status !== null &&
373
+ completedByPhase.get(phaseId) === true &&
374
+ !TERMINAL_PHASE_STATUSES.has(status)) {
375
+ push(context, {
376
+ code: 'phase-status-disagrees',
377
+ path: 'implementation.md',
378
+ line: section.statuses[0]?.line ?? section.line,
379
+ owner: IMPLEMENT,
380
+ message: `Phase ${phaseId} says ${status} but every one of its plan tasks is completed.`,
381
+ });
382
+ }
383
+ }
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // Brief and goal: phase units and execution records
387
+ // ---------------------------------------------------------------------------
388
+ export function checkPhaseUnitRecords(context) {
389
+ const seen = new Map();
390
+ for (const section of context.journal.sections) {
391
+ const phaseId = section.phase?.declaredPhaseId;
392
+ if (phaseId === undefined) {
393
+ continue;
394
+ }
395
+ const first = seen.get(phaseId);
396
+ if (first !== undefined) {
397
+ push(context, {
398
+ code: 'phase-duplicate-section',
399
+ path: 'implementation.md',
400
+ line: section.line,
401
+ owner: IMPLEMENT,
402
+ message: `Phase ${phaseId} has a second journal section (first at line ${first.line}).`,
403
+ });
404
+ continue;
405
+ }
406
+ seen.set(phaseId, section);
407
+ if (section.statuses.length > 1 && inScope(context, phaseId)) {
408
+ push(context, {
409
+ code: 'phase-status-ambiguous',
410
+ path: 'implementation.md',
411
+ line: section.statuses[1]?.line ?? section.line,
412
+ owner: IMPLEMENT,
413
+ message: `Phase ${phaseId} carries ${section.statuses.length} **Status:** lines.`,
414
+ });
415
+ }
416
+ }
417
+ for (const phase of context.progress.phases) {
418
+ if (!inScope(context, phase.phaseId)) {
419
+ continue;
420
+ }
421
+ const section = seen.get(phase.phaseId);
422
+ const accepted = phase.total === 1 && phase.completed === 1;
423
+ if (accepted) {
424
+ const hasHead = section !== undefined && headMarker(context, section);
425
+ if (!hasHead) {
426
+ push(context, {
427
+ code: 'phase-head-missing',
428
+ path: 'implementation.md',
429
+ line: section?.line ?? null,
430
+ owner: IMPLEMENT,
431
+ message: `Accepted phase ${phase.phaseId} has no **Head:** line.`,
432
+ });
433
+ }
434
+ continue;
435
+ }
436
+ if (phase.extracted !== undefined) {
437
+ continue;
438
+ }
439
+ if (!terminalBoundary(context)) {
440
+ continue;
441
+ }
442
+ if (context.approvedDeferredPhases.has(phase.phaseId)) {
443
+ // An approved deferral or retirement stays incomplete in progress; it
444
+ // is valid history at completion and is never counted as accepted.
445
+ continue;
446
+ }
447
+ push(context, {
448
+ code: 'phase-not-accepted',
449
+ path: 'implementation.md',
450
+ line: section?.statuses[0]?.line ?? section?.line ?? null,
451
+ owner: IMPLEMENT,
452
+ message: `Phase ${phase.phaseId} is not accepted (journal status ${section?.status ?? 'missing'}${phase.provisional === true ? ', provisional in plan.md' : ''}).`,
453
+ subject: { taskId: null, phaseId: phase.phaseId },
454
+ });
455
+ }
456
+ const records = groupTasks(context.journal.tasks);
457
+ for (const blocks of records.values()) {
458
+ const [block] = blocks;
459
+ if (block === undefined || !inScope(context, block.phaseId)) {
460
+ continue;
461
+ }
462
+ checkDuplicates(context, blocks);
463
+ checkPlacement(context, block);
464
+ const status = checkOwnedStatus(context, block);
465
+ if (status === null) {
466
+ continue;
467
+ }
468
+ if (!EXECUTION_RECORD_STATUSES.has(status)) {
469
+ push(context, {
470
+ code: 'task-status-invalid',
471
+ path: 'implementation.md',
472
+ line: block.statuses[0]?.line ?? block.line,
473
+ owner: IMPLEMENT,
474
+ message: `Execution record ${block.taskId} has status ${status}; expected completed, failed, cancelled, no-change or in_progress.`,
475
+ });
476
+ continue;
477
+ }
478
+ if (!TERMINAL_EXECUTION_STATUSES.has(status)) {
479
+ push(context, {
480
+ code: 'execution-record-open',
481
+ path: 'implementation.md',
482
+ line: block.statuses[0]?.line ?? block.line,
483
+ owner: IMPLEMENT,
484
+ message: `Execution record ${block.taskId} is still ${status}; close it with a commit or a terminal status.`,
485
+ });
486
+ continue;
487
+ }
488
+ // Failed, cancelled and no-change records are valid history without a
489
+ // SHA; only a completed record owes commit evidence.
490
+ if (status === 'completed') {
491
+ checkCommitEvidence(context, block);
492
+ }
493
+ }
494
+ }
495
+ function headMarker(context, section) {
496
+ for (let index = section.line; index < section.endLine; index += 1) {
497
+ if (context.journal.recordMask[index] !== true) {
498
+ continue;
499
+ }
500
+ const line = context.journal.lines[index] ?? '';
501
+ if (/^#/.test(line)) {
502
+ return false;
503
+ }
504
+ if (/^\*\*Head:\*\*\s+([0-9a-f]{40}|[0-9a-f]{64})$/.test(line.trim())) {
505
+ return true;
506
+ }
507
+ }
508
+ return false;
509
+ }
510
+ // ---------------------------------------------------------------------------
511
+ // Overview totals
512
+ // ---------------------------------------------------------------------------
513
+ const OVERVIEW_ROW_PATTERN = /^\|\s*(Phase\s+)?(p-rev\d+|p?\d+)\s*\|[^|]*\|\s*(\d+)\s*\|\s*(\d+)\s*\/\s*(\d+)\s*\|\s*$/;
514
+ const TOTAL_LINE_PATTERN = /^\*\*Total:\*\*\s*(\d+)\s*\/\s*(\d+)\b/;
515
+ export function checkOverviewTotals(context) {
516
+ if (!terminalBoundary(context)) {
517
+ return;
518
+ }
519
+ const overview = context.journal.sections.find((section) => section.text === 'Progress Overview');
520
+ if (overview === undefined) {
521
+ return;
522
+ }
523
+ const byPhase = new Map(context.progress.phases.map((phase) => [phase.phaseId, phase]));
524
+ for (let index = overview.line; index < overview.endLine; index += 1) {
525
+ if (context.journal.recordMask[index] !== true) {
526
+ continue;
527
+ }
528
+ const line = (context.journal.lines[index] ?? '').trim();
529
+ const row = line.match(OVERVIEW_ROW_PATTERN);
530
+ if (row?.[2] !== undefined) {
531
+ const token = row[2].startsWith('p') ? row[2] : `p${row[2]}`;
532
+ const phaseId = normalizePhaseId(token);
533
+ const phase = phaseId === null ? undefined : byPhase.get(phaseId);
534
+ if (phase === undefined) {
535
+ continue;
536
+ }
537
+ const completed = Number(row[4]);
538
+ const total = Number(row[5]);
539
+ if (completed !== phase.completed || total !== phase.total) {
540
+ push(context, {
541
+ code: 'overview-total-mismatch',
542
+ path: 'implementation.md',
543
+ line: index + 1,
544
+ owner: IMPLEMENT,
545
+ message: `Progress Overview says ${phase.phaseId} is ${completed}/${total}; the journal records ${phase.completed}/${phase.total}.`,
546
+ });
547
+ }
548
+ continue;
549
+ }
550
+ const totalLine = line.match(TOTAL_LINE_PATTERN);
551
+ if (totalLine?.[1] !== undefined && totalLine[2] !== undefined) {
552
+ const completed = Number(totalLine[1]);
553
+ const total = Number(totalLine[2]);
554
+ if (completed !== context.progress.completed ||
555
+ total !== context.progress.total) {
556
+ push(context, {
557
+ code: 'overview-total-mismatch',
558
+ path: 'implementation.md',
559
+ line: index + 1,
560
+ owner: IMPLEMENT,
561
+ message: `Overview total says ${completed}/${total}; the journal records ${context.progress.completed}/${context.progress.total}.`,
562
+ });
563
+ }
564
+ }
565
+ }
566
+ }
567
+ // ---------------------------------------------------------------------------
568
+ // Placement and operational records (whole journal)
569
+ // ---------------------------------------------------------------------------
570
+ export function checkReferencesLast(context) {
571
+ const sections = context.journal.sections;
572
+ const index = sections.findIndex((section) => section.text === 'References');
573
+ const next = index === -1 ? undefined : sections[index + 1];
574
+ if (next !== undefined) {
575
+ push(context, {
576
+ code: 'references-not-last',
577
+ path: 'implementation.md',
578
+ line: next.line,
579
+ owner: IMPLEMENT,
580
+ message: `"## ${next.text}" follows ## References; References must be the last section.`,
581
+ });
582
+ }
583
+ }
584
+ const RECEIPT_HEADING_PATTERN = /^(?:Remote )?Review Received\b(?::\s*(.*))?/i;
585
+ const GATE_LINE_PATTERN = /^\*\*Gate:\*\*/;
586
+ const DEFERRAL_HEADING_PATTERN = /^Deferred\b/i;
587
+ export function checkReceiptsAndDeferrals(context) {
588
+ for (const heading of context.journal.headings) {
589
+ if (heading.level < 3) {
590
+ continue;
591
+ }
592
+ const section = sectionAt(context.journal, heading.line);
593
+ const receipt = heading.text.match(RECEIPT_HEADING_PATTERN);
594
+ if (receipt !== null) {
595
+ const scopeToken = (receipt[1] ?? '').trim().split(/\s+/)[0] ?? '';
596
+ // A phase scope owns its receipt; a task scope (`pNN-tNN`,
597
+ // `prevN-tNN`) belongs to the task's phase.
598
+ const phaseId = normalizePhaseId(scopeToken) ??
599
+ normalizeTaskId(scopeToken)?.phaseId ??
600
+ null;
601
+ const inSlot = section?.text === 'Reviews and Gates';
602
+ const inOwningPhase = phaseId !== null && section?.phase?.declaredPhaseId === phaseId;
603
+ if (!inSlot && !inOwningPhase) {
604
+ push(context, {
605
+ code: 'receipt-misplaced',
606
+ path: 'implementation.md',
607
+ line: heading.line,
608
+ owner: RECEIVE,
609
+ message: `"${heading.text}" belongs in ${phaseId === null ? '' : `its phase ${phaseId} or `}## Reviews and Gates, not under "${section?.text ?? 'no section'}".`,
610
+ });
611
+ }
612
+ continue;
613
+ }
614
+ if (DEFERRAL_HEADING_PATTERN.test(heading.text) &&
615
+ section?.text !== 'Deferred Findings') {
616
+ push(context, {
617
+ code: 'deferral-misplaced',
618
+ path: 'implementation.md',
619
+ line: heading.line,
620
+ owner: RECEIVE,
621
+ message: `"${heading.text}" belongs under ## Deferred Findings, not "${section?.text ?? 'no section'}".`,
622
+ });
623
+ }
624
+ }
625
+ context.journal.lines.forEach((line, index) => {
626
+ if (context.journal.recordMask[index] !== true) {
627
+ return;
628
+ }
629
+ if (!GATE_LINE_PATTERN.test(line.trim())) {
630
+ return;
631
+ }
632
+ const section = sectionAt(context.journal, index + 1);
633
+ if (section?.phase == null && section?.text !== 'Reviews and Gates') {
634
+ push(context, {
635
+ code: 'receipt-misplaced',
636
+ path: 'implementation.md',
637
+ line: index + 1,
638
+ owner: IMPLEMENT,
639
+ message: `A gate receipt belongs in its phase or ## Reviews and Gates, not under "${section?.text ?? 'no section'}".`,
640
+ });
641
+ }
642
+ });
643
+ }
644
+ const FENCE_OPEN_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
645
+ function frontmatterEndIndex(lines) {
646
+ if (lines[0]?.trim() !== '---') {
647
+ return 0;
648
+ }
649
+ for (let index = 1; index < lines.length; index += 1) {
650
+ if (lines[index]?.trim() === '---') {
651
+ return index + 1;
652
+ }
653
+ }
654
+ return 0;
655
+ }
656
+ /**
657
+ * Fenced code blocks of the journal body, parsed fence by fence: each block
658
+ * has its own opener (up to three spaces of indent, three or more backticks or
659
+ * tildes), info string, and closer (the same character, at least as long, with
660
+ * nothing else on the line); an unclosed fence runs to the end of the
661
+ * document. `preceding` is the nearest non-blank line above the opener, so a
662
+ * marker or a neighbouring fence never decides another block's status, and a
663
+ * line that merely sits next to a fence (an indented code line, another block)
664
+ * never hides it.
665
+ */
666
+ function fencedBlocks(records) {
667
+ const blocks = [];
668
+ const { lines } = records;
669
+ const bodyStart = frontmatterEndIndex(lines);
670
+ let index = bodyStart;
671
+ while (index < lines.length) {
672
+ const open = (lines[index] ?? '').match(FENCE_OPEN_PATTERN);
673
+ const fence = open?.[1] ?? '';
674
+ const info = (open?.[2] ?? '').trim();
675
+ if (open === null || (fence.startsWith('`') && info.includes('`'))) {
676
+ index += 1;
677
+ continue;
678
+ }
679
+ const closer = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`);
680
+ const content = [];
681
+ let cursor = index + 1;
682
+ while (cursor < lines.length && !closer.test(lines[cursor] ?? '')) {
683
+ content.push(lines[cursor] ?? '');
684
+ cursor += 1;
685
+ }
686
+ let preceding = '';
687
+ for (let back = index - 1; back >= bodyStart; back -= 1) {
688
+ const candidate = (lines[back] ?? '').trim();
689
+ if (candidate !== '') {
690
+ preceding = candidate;
691
+ break;
692
+ }
693
+ }
694
+ blocks.push({ line: index + 1, info, content, preceding });
695
+ index = cursor + 1;
696
+ }
697
+ return blocks;
698
+ }
699
+ const PAYLOAD_KEYS = ['request_id', 'caller', 'role_name'];
700
+ const QUOTED_EVIDENCE_MARKER = /^<!--\s*(?:oat:)?quoted-evidence\b.*-->$/;
701
+ function isDispatchPayload(content) {
702
+ const text = content.join('\n');
703
+ return PAYLOAD_KEYS.every((key) => new RegExp(`["']?${key}["']?\\s*:`).test(text));
704
+ }
705
+ export function checkDispatchPayloads(context) {
706
+ for (const block of fencedBlocks(context.journal)) {
707
+ if (!isDispatchPayload(block.content)) {
708
+ continue;
709
+ }
710
+ const marked = block.info.split(/\s+/).includes('quoted-evidence') ||
711
+ QUOTED_EVIDENCE_MARKER.test(block.preceding);
712
+ if (marked) {
713
+ continue;
714
+ }
715
+ const section = sectionAt(context.journal, block.line);
716
+ push(context, {
717
+ code: 'dispatch-payload',
718
+ path: 'implementation.md',
719
+ line: block.line,
720
+ owner: IMPLEMENT,
721
+ message: `A dispatch payload (request_id, caller, role_name) is pasted under "${section?.text ?? 'no section'}"; record one run line under ## Orchestration Runs instead (mark a quoted example with quoted-evidence).`,
722
+ });
723
+ }
724
+ }
725
+ const RUN_LINE_PATTERN = /^(?:[-*][ \t]+)?run\/v\d+\b/;
726
+ export function checkRunLines(context) {
727
+ const requests = new Map();
728
+ context.journal.lines.forEach((line, index) => {
729
+ if (context.journal.recordMask[index] !== true) {
730
+ return;
731
+ }
732
+ const trimmed = line.trim();
733
+ if (!RUN_LINE_PATTERN.test(trimmed)) {
734
+ return;
735
+ }
736
+ const lineNumber = index + 1;
737
+ const section = sectionAt(context.journal, lineNumber);
738
+ if (section?.text !== 'Orchestration Runs') {
739
+ push(context, {
740
+ code: 'run-line-misplaced',
741
+ path: 'implementation.md',
742
+ line: lineNumber,
743
+ owner: IMPLEMENT,
744
+ message: `A run line sits under "${section?.text ?? 'no section'}"; run lines belong under ## Orchestration Runs.`,
745
+ });
746
+ }
747
+ const parsed = parseDispatchRunLine(trimmed);
748
+ if (!parsed.ok) {
749
+ push(context, {
750
+ code: 'run-line-malformed',
751
+ path: 'implementation.md',
752
+ line: lineNumber,
753
+ owner: IMPLEMENT,
754
+ message: `Run line is malformed: ${parsed.reason}.`,
755
+ });
756
+ return;
757
+ }
758
+ const { fields } = parsed;
759
+ // The request identity keys deduplication and names the run, so it must
760
+ // satisfy the producer contract; `~` (null) or any other value the
761
+ // producer would reject is malformed, never an empty dedup key.
762
+ const request = fields.request;
763
+ if (request == null || !REQUEST_ID_PATTERN.test(request)) {
764
+ push(context, {
765
+ code: 'run-line-malformed',
766
+ path: 'implementation.md',
767
+ line: lineNumber,
768
+ owner: IMPLEMENT,
769
+ message: `Run line has no valid request identity (request=${request ?? '~'}); the producer requires [a-zA-Z0-9] followed by up to 127 of [a-zA-Z0-9._-].`,
770
+ });
771
+ return;
772
+ }
773
+ const first = requests.get(request);
774
+ if (first !== undefined) {
775
+ push(context, {
776
+ code: 'run-line-duplicate',
777
+ path: 'implementation.md',
778
+ line: lineNumber,
779
+ owner: IMPLEMENT,
780
+ message: `Request ${request} already has a run line at line ${first}; update it in place.`,
781
+ });
782
+ }
783
+ else {
784
+ requests.set(request, lineNumber);
785
+ }
786
+ const classification = classifyDispatchOutcome(fields.launch ?? '', fields.outcome);
787
+ if (fields.class !== classification.classification ||
788
+ fields.terminal !== (classification.terminal ? 'yes' : 'no')) {
789
+ push(context, {
790
+ code: 'run-line-malformed',
791
+ path: 'implementation.md',
792
+ line: lineNumber,
793
+ owner: IMPLEMENT,
794
+ message: `Run line ${request} records terminal=${fields.terminal ?? '~'} class=${fields.class ?? '~'}, but launch ${fields.launch ?? '~'} with outcome ${fields.outcome ?? '~'} maps to ${classification.terminal ? 'yes' : 'no'}/${classification.classification}.`,
795
+ });
796
+ return;
797
+ }
798
+ if (!classification.terminal) {
799
+ push(context, {
800
+ code: 'run-line-unresolved',
801
+ path: 'implementation.md',
802
+ line: lineNumber,
803
+ owner: IMPLEMENT,
804
+ message: `Run ${request} is not terminal (launch ${fields.launch ?? '~'}, outcome ${fields.outcome ?? '~'}).`,
805
+ severity: terminalBoundary(context) ? 'refuse' : 'warn',
806
+ });
807
+ }
808
+ });
809
+ }
810
+ /**
811
+ * The templates' own brace placeholder vocabulary, as alternatives inside
812
+ * one pair of braces.
813
+ */
814
+ const BRACE_VOCABULARY = '(?:Project Name|Phase Name|Task Name|N|path|why|purpose|bullet|sha|time|capability \\d+|command\\(s\\)[^{}\\n]*|2-5 bullets[^{}\\n]*|gotchas[^{}\\n]*|trade-offs[^{}\\n]*|tests\\/lint[^{}\\n]*|what (?:changed|materially)[^{}\\n]*|pass\\/fail[^{}\\n]*|Notes will be added[^{}\\n]*|Continue log[^{}\\n]*)';
815
+ /**
816
+ * Brace residue is the templates' own placeholder vocabulary anywhere, or any
817
+ * brace token that starts a list item or follows a `**Label:**` marker (the
818
+ * slots the templates fill). Ordinary prose such as `returns {status,
819
+ * reasons}` is not residue.
820
+ */
821
+ const BRACE_PLACEHOLDER = new RegExp(`\\{${BRACE_VOCABULARY}\\}|^\\s*(?:[-*]|\\d+\\.)\\s+\\{[^{}\\n]+\\}|\\*\\*[^*\\n]+:\\*\\*\\s*\\{[^{}\\n]+\\}`);
822
+ /** A whole inline-code span that is exactly one vocabulary placeholder. */
823
+ const BRACE_VOCABULARY_TOKEN = new RegExp(`^\\{${BRACE_VOCABULARY}\\}$`);
824
+ /** Text before a slot: a list-item start or a `**Label:**` marker. */
825
+ const SLOT_PREFIX = /(?:^\s*(?:[-*]|\d+\.)\s+|\*\*[^*\n]+:\*\*\s*)$/;
826
+ const INLINE_CODE_SPAN = /(`+)([^`]*?)\1/g;
827
+ const QUOTED_EVIDENCE_COMMENT = /<!--\s*(?:oat:)?quoted-evidence\b[^>]*-->/;
828
+ const DATE_PLACEHOLDER = /\bYYYY-MM-DD\b/;
829
+ const BRACKET_PLACEHOLDER = /\[(?:Phase Name|Project Name|Execution record name|N|capability \d+|bullet|path|purpose|time|full (?:base |head |commit )?SHA[^\]]*|Summary prose[^\]]*|root \| phase-implementer[^\]]*|tests\/lint[^\]]*|observed evidence[^\]]*|what changed vs[^\]]*|short bullets[^\]]*|Decision made[^\]]*|anything discovered[^\]]*|Blocker description[^\]]*)\]/;
830
+ function stripInlineCode(line) {
831
+ return line.replace(INLINE_CODE_SPAN, '');
832
+ }
833
+ /**
834
+ * A template slot left unfilled inside inline code: a backtick span whose
835
+ * whole content is one vocabulary placeholder (`{path}`), at a slot position
836
+ * (list-item start or after a `**Label:**` marker, read with every other code
837
+ * span masked). Quoted prose mid-line, other brace text in code
838
+ * (`{ sourceId, locator }`, `packages/{cli,control-plane}`) and lines marked
839
+ * quoted-evidence are not slots.
840
+ */
841
+ function inlineCodeSlotPlaceholder(line, previousLine) {
842
+ if (QUOTED_EVIDENCE_COMMENT.test(line) ||
843
+ QUOTED_EVIDENCE_MARKER.test(previousLine.trim())) {
844
+ return null;
845
+ }
846
+ const masked = line.replace(INLINE_CODE_SPAN, (span) => '`'.repeat(span.length));
847
+ for (const span of line.matchAll(INLINE_CODE_SPAN)) {
848
+ const content = (span[2] ?? '').trim();
849
+ if (BRACE_VOCABULARY_TOKEN.test(content) &&
850
+ SLOT_PREFIX.test(masked.slice(0, span.index))) {
851
+ return span[0];
852
+ }
853
+ }
854
+ return null;
855
+ }
856
+ /** The nearest non-blank line above `index`, or `''`. */
857
+ function previousRecordLine(context, index) {
858
+ for (let back = index - 1; back >= 0; back -= 1) {
859
+ const candidate = context.journal.lines[back] ?? '';
860
+ if (candidate.trim() !== '') {
861
+ return candidate;
862
+ }
863
+ }
864
+ return '';
865
+ }
866
+ export function checkTemplateResidue(context) {
867
+ if (context.journalMeta.state === 'ok' &&
868
+ context.journalMeta.record.oat_template === true &&
869
+ context.boundary !== 'review') {
870
+ push(context, {
871
+ code: 'template-residue',
872
+ path: 'implementation.md',
873
+ line: frontmatterKeyLine(context.journal.lines, 'oat_template'),
874
+ owner: IMPLEMENT,
875
+ message: 'The journal still declares oat_template: true.',
876
+ });
877
+ }
878
+ context.journal.lines.forEach((line, index) => {
879
+ if (context.journal.recordMask[index] !== true) {
880
+ return;
881
+ }
882
+ const lineNumber = index + 1;
883
+ if (context.scope !== null) {
884
+ const phaseId = sectionAt(context.journal, lineNumber)?.phase
885
+ ?.declaredPhaseId;
886
+ if (phaseId === undefined || !context.scope.has(phaseId)) {
887
+ return;
888
+ }
889
+ }
890
+ const text = stripInlineCode(line);
891
+ const placeholder = text.match(BRACE_PLACEHOLDER)?.[0] ??
892
+ text.match(BRACKET_PLACEHOLDER)?.[0] ??
893
+ text.match(DATE_PLACEHOLDER)?.[0] ??
894
+ inlineCodeSlotPlaceholder(line, previousRecordLine(context, index));
895
+ if (placeholder != null) {
896
+ push(context, {
897
+ code: 'template-residue',
898
+ path: 'implementation.md',
899
+ line: lineNumber,
900
+ owner: IMPLEMENT,
901
+ message: `Template placeholder ${placeholder} survives.`,
902
+ });
903
+ }
904
+ });
905
+ }
906
+ // ---------------------------------------------------------------------------
907
+ // Overrides
908
+ // ---------------------------------------------------------------------------
909
+ const OVERRIDE_PREFIX = /^\*\*Override:\*\*/;
910
+ const OVERRIDE_LINE_PATTERN = /^\*\*Override:\*\*[ \t]+(\S+)[ \t]+·[ \t]+\*\*Authorized by:\*\*[ \t]+([A-Za-z0-9][A-Za-z0-9._:/-]*)[ \t]+·[ \t]+\*\*Scope:\*\*[ \t]+(\S+)[ \t]+·[ \t]+(\S.*)$/;
911
+ /** Which gaps each override code may cover. Nothing else is overridable. */
912
+ export const OVERRIDE_ELIGIBILITY = {
913
+ 'scope-disposition-approved': new Set([
914
+ 'task-block-missing',
915
+ 'task-not-completed',
916
+ 'task-commit-missing',
917
+ 'phase-not-accepted',
918
+ ]),
919
+ 'evidence-lost': new Set(['task-commit-missing']),
920
+ 'operator-accepted-gap': new Set([
921
+ 'task-block-missing',
922
+ 'task-not-completed',
923
+ 'task-commit-missing',
924
+ ]),
925
+ };
926
+ function normalizeOverrideScope(token) {
927
+ const task = normalizeTaskId(token);
928
+ if (task !== null) {
929
+ return { scope: task.taskId, kind: 'task' };
930
+ }
931
+ const phase = normalizePhaseId(token);
932
+ return phase === null ? null : { scope: phase, kind: 'phase' };
933
+ }
934
+ export function readOverrides(context) {
935
+ const overrides = [];
936
+ context.journal.lines.forEach((line, index) => {
937
+ if (context.journal.recordMask[index] !== true) {
938
+ return;
939
+ }
940
+ const trimmed = line.trim();
941
+ if (!OVERRIDE_PREFIX.test(trimmed)) {
942
+ return;
943
+ }
944
+ const lineNumber = index + 1;
945
+ const match = trimmed.match(OVERRIDE_LINE_PATTERN);
946
+ const code = match?.[1];
947
+ const scope = match?.[3] === undefined ? null : normalizeOverrideScope(match[3]);
948
+ if (match === null ||
949
+ code === undefined ||
950
+ !JOURNAL_OVERRIDE_CODES.includes(code) ||
951
+ scope === null ||
952
+ match[2] === undefined ||
953
+ match[4] === undefined) {
954
+ push(context, {
955
+ code: 'override-malformed',
956
+ path: 'implementation.md',
957
+ line: lineNumber,
958
+ owner: COMPLETE,
959
+ message: 'An override must read: **Override:** <scope-disposition-approved|evidence-lost|operator-accepted-gap> · **Authorized by:** <decision id> · **Scope:** <pNN-tNN|pNN> · <reason>.',
960
+ });
961
+ return;
962
+ }
963
+ const section = sectionAt(context.journal, lineNumber);
964
+ if (section?.text !== 'Deviations from Plan / Design') {
965
+ push(context, {
966
+ code: 'override-misplaced',
967
+ path: 'implementation.md',
968
+ line: lineNumber,
969
+ owner: COMPLETE,
970
+ message: `An override belongs under ## Deviations from Plan / Design, not "${section?.text ?? 'no section'}".`,
971
+ });
972
+ return;
973
+ }
974
+ overrides.push({
975
+ code: code,
976
+ authorizedBy: match[2],
977
+ scope: scope.scope,
978
+ scopeKind: scope.kind,
979
+ reason: match[4].trim(),
980
+ line: lineNumber,
981
+ });
982
+ });
983
+ return overrides;
984
+ }
985
+ /**
986
+ * Whether an override's scope covers a finding's subject. A task scope covers
987
+ * exactly that task; a phase scope covers the phase and every task in it.
988
+ */
989
+ export function overrideCoversSubject(override, subject) {
990
+ if (override.scopeKind === 'task') {
991
+ return subject.taskId !== null && subject.taskId === override.scope;
992
+ }
993
+ return subject.phaseId === override.scope;
994
+ }