dotmd-cli 0.74.5 → 0.74.6
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/package.json +1 -1
- package/src/baton.mjs +21 -1
- package/src/glossary.mjs +1 -1
- package/src/prompts.mjs +53 -5
- package/src/section.mjs +106 -8
- package/src/validate.mjs +33 -18
package/package.json
CHANGED
package/src/baton.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn } from './util.mjs';
|
|
@@ -206,6 +206,26 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
206
206
|
}
|
|
207
207
|
if (!createdSlug) die(`Could not find a free prompt slug for ${slugBase} (tried ${slugBase}-2 … ${slugBase}-9).`);
|
|
208
208
|
|
|
209
|
+
// A release status can FILE the plan into a bucket (`lifecycle.filedStatuses`,
|
|
210
|
+
// e.g. paused → docs/plans/held/). The prompt is created inside the same
|
|
211
|
+
// transaction as that move, so its `plan:` link necessarily holds the
|
|
212
|
+
// pre-move path and is stale the instant it lands. The move's own reference
|
|
213
|
+
// rewrite does not cover it: `plan` is deliberately not a `referenceFields`
|
|
214
|
+
// entry, so nothing validates or repoints it. Retarget it here.
|
|
215
|
+
if (!dryRun && promptRepoPath && archiveResult?.newRepoPath && archiveResult.newRepoPath !== repoPath) {
|
|
216
|
+
const promptPath = path.join(config.repoRoot, promptRepoPath);
|
|
217
|
+
try {
|
|
218
|
+
const { frontmatter, body } = extractFrontmatter(readFileSync(promptPath, 'utf8'));
|
|
219
|
+
// Rewritten in place rather than through replaceFrontmatterField, which
|
|
220
|
+
// always emits a folded block scalar — right for prose fields, wrong for
|
|
221
|
+
// a path every other prompt carries on one line. Baton wrote this line
|
|
222
|
+
// itself moments ago, so the single-line form is guaranteed.
|
|
223
|
+
const rewritten = frontmatter.replace(/^plan:[ \t]*\S.*$/m, `plan: ${archiveResult.newRepoPath}`);
|
|
224
|
+
if (rewritten !== frontmatter) writeFileSync(promptPath, `---\n${rewritten}\n---\n${body}`, 'utf8');
|
|
225
|
+
}
|
|
226
|
+
catch (err) { warn(`Saved the prompt, but could not repoint its plan link to ${archiveResult.newRepoPath}: ${err.message}`); }
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
const normalizeRepoPath = candidate => {
|
|
210
230
|
if (!candidate) return null;
|
|
211
231
|
return path.isAbsolute(candidate) ? toRepoPath(candidate, config.repoRoot) : candidate;
|
package/src/glossary.mjs
CHANGED
|
@@ -136,7 +136,7 @@ function renderEntry(entry, index, allEntries) {
|
|
|
136
136
|
if (relatedDocs.length > 0) {
|
|
137
137
|
lines.push('');
|
|
138
138
|
|
|
139
|
-
// Module entry point (the main module doc, e.g.
|
|
139
|
+
// Module entry point (the main module doc, e.g. ledger.md)
|
|
140
140
|
const entryPoint = relatedDocs.find(d =>
|
|
141
141
|
d.root?.includes('modules') && path.basename(d.path, '.md') === entry.term.toLowerCase()
|
|
142
142
|
);
|
package/src/prompts.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { buildIndex, resolveDocArg } from './index.mjs';
|
|
|
6
6
|
import { runQuery } from './query.mjs';
|
|
7
7
|
import { completePlanClaim, regenIndex, renderLifecycleMutation, runArchive, runStatus } from './lifecycle.mjs';
|
|
8
8
|
import { runNew } from './new.mjs';
|
|
9
|
-
import { green, dim } from './color.mjs';
|
|
9
|
+
import { green, dim, yellow } from './color.mjs';
|
|
10
10
|
import { authorizeManagedSource } from './managed-path.mjs';
|
|
11
11
|
import {
|
|
12
12
|
authoritativeSessionId,
|
|
@@ -259,11 +259,17 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
259
259
|
|
|
260
260
|
const planRef = asString(parsed.plan);
|
|
261
261
|
let linkedClaim = null;
|
|
262
|
-
|
|
262
|
+
let claimSkipReason = null;
|
|
263
|
+
if (planRef) {
|
|
264
|
+
const outcome = prepareLinkedPromptClaim(planRef, config, path.dirname(filePath));
|
|
265
|
+
if (outcome?.skipped) claimSkipReason = outcome.reason;
|
|
266
|
+
else linkedClaim = outcome;
|
|
267
|
+
}
|
|
263
268
|
|
|
264
269
|
if (dryRun) {
|
|
265
270
|
const prefix = dim('[dry-run]');
|
|
266
271
|
process.stderr.write(`${prefix} Would emit body and archive: ${repoPath} (${status ?? 'unknown'} → archived)\n`);
|
|
272
|
+
if (claimSkipReason) process.stderr.write(`${prefix} Would NOT claim the linked plan: ${claimSkipReason}\n`);
|
|
267
273
|
const bytes = Buffer.byteLength(body, 'utf8');
|
|
268
274
|
const lines = body.split('\n').length;
|
|
269
275
|
process.stderr.write(`${prefix} body preview (${bytes}B, ${lines} lines):\n`);
|
|
@@ -315,6 +321,7 @@ export async function consumePrompt(filePath, config, opts) {
|
|
|
315
321
|
}
|
|
316
322
|
process.stderr.write(`${green('→ Claimed')}: ${linkedClaim.repoPath} (in-session)\n`);
|
|
317
323
|
}
|
|
324
|
+
if (claimSkipReason) process.stderr.write(`${yellow('→ Not claimed')}: ${claimSkipReason}\n`);
|
|
318
325
|
process.stderr.write(`${green('✓ Consumed')}: ${consumedPath}\n`);
|
|
319
326
|
const ownershipRecordPath = linkedClaim?.prepared?.recordPath ?? (linkedClaim ? readPlanOwnership(linkedClaim.repoPath, config)?.recordPath : null);
|
|
320
327
|
const ownershipPath = ownershipRecordPath ? toRepoPath(ownershipRecordPath, config.repoRoot) : null;
|
|
@@ -364,16 +371,54 @@ export async function writeConsumedBody(body, archivedPath, write = null, linked
|
|
|
364
371
|
// with `resolveDocPath` alone read only the repo-root form, so a doc-relative
|
|
365
372
|
// link — the form nothing validates, since `plan` is not a `referenceFields`
|
|
366
373
|
// entry — died as "missing" while pointing at a file that was plainly there.
|
|
374
|
+
// Why a linked plan could not be claimed, phrased so the reader knows what to
|
|
375
|
+
// do next. Each of these used to abort consumption entirely (see the skip
|
|
376
|
+
// contract on prepareLinkedPromptClaim).
|
|
377
|
+
// `startable` comes from the repo's own lifecycle config rather than the
|
|
378
|
+
// built-in default: a repo that configures its own startable statuses would
|
|
379
|
+
// otherwise be told to run `dotmd set active`, which its own validation
|
|
380
|
+
// rejects.
|
|
381
|
+
function explainUnclaimablePlan(disposition, repoPath, status, startable) {
|
|
382
|
+
switch (disposition.kind) {
|
|
383
|
+
case 'parked':
|
|
384
|
+
return `${repoPath} is ${status} — \`dotmd set ${startable} ${repoPath}\` to unpark it, then \`dotmd use ${repoPath}\``;
|
|
385
|
+
case 'busy':
|
|
386
|
+
return `${repoPath} is claimed by another session (${disposition.owner})`;
|
|
387
|
+
case 'terminal':
|
|
388
|
+
case 'physical-archive':
|
|
389
|
+
return `${repoPath} is already closed (${status})`;
|
|
390
|
+
case 'wrong-type':
|
|
391
|
+
return `${repoPath} is not a plan`;
|
|
392
|
+
case 'unconfigured-status':
|
|
393
|
+
return `${repoPath} has a status this repo does not configure (${status ?? 'none'})`;
|
|
394
|
+
case 'ownership-corrupt':
|
|
395
|
+
return `${repoPath} has an unreadable ownership record — \`dotmd doctor --claims\``;
|
|
396
|
+
default:
|
|
397
|
+
return `${repoPath} cannot be claimed (${disposition.kind})`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Returns a claim, or `{ skipped, reason }` when the linked plan cannot be
|
|
402
|
+
// claimed. It never refuses the consumption itself.
|
|
403
|
+
//
|
|
404
|
+
// It used to `die` on all three of these paths, which deadlocked the handoff
|
|
405
|
+
// loop the feature exists to close: `dotmd baton` stamps this link and parks
|
|
406
|
+
// the plan in the same breath, and five of the seven statuses it parks with are
|
|
407
|
+
// not startable — so baton routinely produced a prompt that `dotmd use` would
|
|
408
|
+
// refuse forever, while the SessionStart hud kept telling every new session to
|
|
409
|
+
// run exactly that command. The body is the whole point of a saved prompt, and
|
|
410
|
+
// no reason to skip the claim is a reason to withhold it.
|
|
367
411
|
function prepareLinkedPromptClaim(planRef, config, promptDir) {
|
|
412
|
+
const skip = reason => ({ skipped: true, reason });
|
|
368
413
|
let planPath = resolveRefPath(planRef, promptDir, config.repoRoot)
|
|
369
414
|
?? resolveDocPath(planRef, config)
|
|
370
415
|
?? resolveDocArg(planRef, config, { dieOnMiss: false });
|
|
371
|
-
if (!planPath || !existsSync(planPath))
|
|
416
|
+
if (!planPath || !existsSync(planPath)) return skip(`linked plan is missing (moved or renamed): ${planRef}`);
|
|
372
417
|
planPath = authorizeManagedSource(planPath, config, { kind: 'Prompt linked plan source' }).path;
|
|
373
418
|
const raw = readFileSync(planPath, 'utf8');
|
|
374
419
|
let parsed;
|
|
375
420
|
try { parsed = parseSimpleFrontmatter(extractFrontmatter(raw).frontmatter); }
|
|
376
|
-
catch {
|
|
421
|
+
catch { return skip(`linked plan has malformed frontmatter: ${toRepoPath(planPath, config.repoRoot)}`); }
|
|
377
422
|
const repoPath = toRepoPath(planPath, config.repoRoot);
|
|
378
423
|
const sessionId = authoritativeSessionId();
|
|
379
424
|
const ownership = readPlanOwnership(repoPath, config);
|
|
@@ -391,7 +436,10 @@ function prepareLinkedPromptClaim(planRef, config, promptDir) {
|
|
|
391
436
|
sessionId,
|
|
392
437
|
malformed: false,
|
|
393
438
|
});
|
|
394
|
-
if (!disposition.pickupable)
|
|
439
|
+
if (!disposition.pickupable) {
|
|
440
|
+
const startable = [...(config.lifecycle.startableStatuses ?? [])][0] ?? 'active';
|
|
441
|
+
return skip(explainUnclaimablePlan(disposition, repoPath, oldStatus, startable));
|
|
442
|
+
}
|
|
395
443
|
if (disposition.kind === 'resume') return { planPath, repoPath, prepared: null, planChanged: false, disposition: disposition.kind };
|
|
396
444
|
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
397
445
|
const rendered = disposition.kind === 'start'
|
package/src/section.mjs
CHANGED
|
@@ -53,23 +53,121 @@ export function findSection(sections, name) {
|
|
|
53
53
|
|
|
54
54
|
// Status marker detection for phase headings. Returns one of:
|
|
55
55
|
// 'shipped' | 'skipped' | 'in-progress' | 'blocked' | 'todo' | null
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
// Glyphs are checked BEFORE prose, not interleaved with it. A glyph is a
|
|
57
|
+
// deliberate mark; a prose word is often about something else in the sentence:
|
|
58
|
+
//
|
|
59
|
+
// ⬜ Phase 4: Retry budget rework (scoping COMPLETE) ← the SCOPING completed
|
|
60
|
+
// Phase 2 — schema migrated ⬜ todo (column rename half DONE) ← half a RENAME is done
|
|
61
|
+
// Phase 3 (original scope) — ⏭ superseded by the shipped Phase 3 ← a DIFFERENT phase shipped
|
|
62
|
+
//
|
|
63
|
+
// Interleaved, first-match-wins order called all three shipped, so
|
|
64
|
+
// findActivePhase skipped a phase the author had explicitly marked unstarted.
|
|
65
|
+
// Within each tier the order is still priority order, which decides a heading
|
|
66
|
+
// carrying more than one mark.
|
|
67
|
+
const GLYPH_PATTERNS = [
|
|
68
|
+
{ kind: 'shipped', re: /(✅|☑|✔)/ },
|
|
69
|
+
{ kind: 'skipped', re: /(⏭)/ },
|
|
70
|
+
{ kind: 'in-progress', re: /(🟡|🔄)/ },
|
|
71
|
+
{ kind: 'blocked', re: /(🚧|🔴)/ },
|
|
72
|
+
{ kind: 'todo', re: /(⬜|⬛|◻|☐)/ },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const PROSE_PATTERNS = [
|
|
76
|
+
// A qualifier inverts the word it modifies, and these run BEFORE the plain
|
|
77
|
+
// `shipped` pattern so the qualifier wins. Otherwise `mostly done` and
|
|
78
|
+
// `(partially complete)` both read as shipped, and findActivePhase skips a
|
|
79
|
+
// phase whose own checklist still has open boxes.
|
|
80
|
+
{ kind: 'todo', re: /\b(?:not|never)[\s-]+(?:complete(?:d)?|done|shipped|started)\b/i },
|
|
81
|
+
{ kind: 'in-progress', re: /\b(?:partially|partly|mostly|nearly|almost|half)[\s-]*(?:complete(?:d)?|done|shipped)\b/i },
|
|
82
|
+
{ kind: 'shipped', re: /(\bshipped\b|\bdone\b|\bcomplete\b)/i },
|
|
83
|
+
{ kind: 'skipped', re: /(\bskip(?:ped)?\b)/i },
|
|
84
|
+
{ kind: 'in-progress', re: /(\bin[-_ ]?(?:progress|flight)\b|\bwip\b)/i },
|
|
85
|
+
{ kind: 'blocked', re: /(\bblocked\b)/i },
|
|
86
|
+
{ kind: 'todo', re: /(\btodo\b|\bnot[-_ ]?started\b)/i },
|
|
62
87
|
];
|
|
63
88
|
|
|
64
89
|
export function detectMarker(heading) {
|
|
65
|
-
for (const { kind, re } of
|
|
90
|
+
for (const { kind, re } of GLYPH_PATTERNS) {
|
|
91
|
+
if (re.test(heading)) return kind;
|
|
92
|
+
}
|
|
93
|
+
for (const { kind, re } of PROSE_PATTERNS) {
|
|
66
94
|
if (re.test(heading)) return kind;
|
|
67
95
|
}
|
|
68
96
|
return null;
|
|
69
97
|
}
|
|
70
98
|
|
|
99
|
+
// Leading decoration a phase heading may carry before the word "Phase":
|
|
100
|
+
// emphasis punctuation and any run of status markers. `detectMarker` already
|
|
101
|
+
// reads a marker wherever it sits, but this test was anchored at `^phase`, so
|
|
102
|
+
// a plan that writes `### ⬜ Phase 2 — …` had NO phases at all — not a
|
|
103
|
+
// miscount, an empty phase set, which drops the pickup card to its
|
|
104
|
+
// "no ## Phases section" fallback. 93 headings in one 482-plan corpus.
|
|
105
|
+
// Several of these glyphs are commonly typed in emoji-presentation form
|
|
106
|
+
// (the base codepoint plus a variation selector): the skip mark, the ballot
|
|
107
|
+
// box, the check mark. `detectMarker` is a substring test so it never
|
|
108
|
+
// noticed. This is a character CLASS, and the selector sits between the glyph
|
|
109
|
+
// and the space, is not \s, and ends the run — so a heading led by the
|
|
110
|
+
// emoji-presentation form was not a phase heading at all, while the bare
|
|
111
|
+
// codepoint was. Reported by the owner, 2026-08-16.
|
|
112
|
+
const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡⏭☑✔◻☐⬛🔴🔄][︎️]?\s*)*`;
|
|
113
|
+
const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
|
|
114
|
+
|
|
115
|
+
// "Phase 3 outcome" is commentary ABOUT a phase, not a phase. Counting it
|
|
116
|
+
// inflates the phase set, and because `findActivePhase` ranks blocked above
|
|
117
|
+
// todo, a heading like "Phase 3 smoke findings — BLOCKER" gets picked as the
|
|
118
|
+
// plan's active phase over a real unstarted one. 43 in the same corpus.
|
|
119
|
+
//
|
|
120
|
+
// The noun list is deliberately tight. Wrongly excluding a real phase hides
|
|
121
|
+
// work; wrongly including commentary only miscounts — so this errs toward
|
|
122
|
+
// counting, and a word is added here only once the corpus shows it standing
|
|
123
|
+
// for a retrospective rather than a phase.
|
|
124
|
+
const PHASE_COMMENTARY = new RegExp(
|
|
125
|
+
`^${PHASE_DECORATION}phase\\s+\\S+\\s+(outcome|progress|notes?|findings?|smoke|retro|review|recap|summary)\\b`,
|
|
126
|
+
'i',
|
|
127
|
+
);
|
|
128
|
+
|
|
71
129
|
export function isPhaseHeading(section) {
|
|
72
|
-
|
|
130
|
+
if (section.level !== 3) return false;
|
|
131
|
+
return PHASE_LEAD.test(section.heading) && !PHASE_COMMENTARY.test(section.heading);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// A phase's OWN checklist — the boxes directly under its heading, stopping at
|
|
135
|
+
// the next heading of any level so a sub-section's checklist is never counted
|
|
136
|
+
// as the parent phase's evidence.
|
|
137
|
+
export function phaseTally(section) {
|
|
138
|
+
let checked = 0, unchecked = 0;
|
|
139
|
+
for (const line of String(section?.body ?? '').split('\n')) {
|
|
140
|
+
if (/^#{1,6}\s/.test(line)) break;
|
|
141
|
+
if (/^\s*[-*]\s*\[x\]/i.test(line)) checked++;
|
|
142
|
+
else if (/^\s*[-*]\s*\[ \]/.test(line)) unchecked++;
|
|
143
|
+
}
|
|
144
|
+
return { checked, unchecked, total: checked + unchecked };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A phase whose declared marker its own checklist contradicts.
|
|
149
|
+
*
|
|
150
|
+
* Only two disagreements are reported, because only two are unambiguous:
|
|
151
|
+
*
|
|
152
|
+
* shipped + an open box — the plan's own checklist says otherwise
|
|
153
|
+
* todo + every box checked — the work is done, the marker says unstarted
|
|
154
|
+
*
|
|
155
|
+
* `blocked` and `skipped` are judgements a tally cannot refute (blocked at 0/7
|
|
156
|
+
* is perfectly coherent), and `in-progress` with everything checked usually
|
|
157
|
+
* means work the checklist does not enumerate. Including those three took the
|
|
158
|
+
* count from 13 to 19 on a 482-plan corpus, every extra one arguable — so they
|
|
159
|
+
* are excluded rather than reported and explained away.
|
|
160
|
+
*
|
|
161
|
+
* Returns null when there is no conflict, no marker, or no checklist.
|
|
162
|
+
*/
|
|
163
|
+
export function phaseMarkerConflict(section) {
|
|
164
|
+
const declared = detectMarker(section?.heading ?? '');
|
|
165
|
+
if (declared !== 'shipped' && declared !== 'todo') return null;
|
|
166
|
+
const tally = phaseTally(section);
|
|
167
|
+
if (tally.total === 0) return null;
|
|
168
|
+
if (declared === 'shipped' && tally.unchecked > 0) return { declared, implied: 'in progress', ...tally };
|
|
169
|
+
if (declared === 'todo' && tally.unchecked === 0) return { declared, implied: 'shipped', ...tally };
|
|
170
|
+
return null;
|
|
73
171
|
}
|
|
74
172
|
|
|
75
173
|
// Summarize a phase set: { 'shipped': 2, 'in-progress': 1, 'todo': 2 }
|
package/src/validate.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
3
3
|
import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
|
|
4
4
|
import { toRepoPath } from './util.mjs';
|
|
5
|
+
import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
|
|
5
6
|
|
|
6
7
|
const NOW = new Date();
|
|
7
8
|
|
|
@@ -635,26 +636,40 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
635
636
|
}
|
|
636
637
|
}
|
|
637
638
|
|
|
638
|
-
// 5
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
639
|
+
// 5 & 6 read phases through section.mjs rather than re-parsing them here.
|
|
640
|
+
// This check used to carry its own heading scan and its own copy of the
|
|
641
|
+
// marker vocabulary, which drifted from the real reader in both directions:
|
|
642
|
+
// it counted `### Phase 3 outcome` as a phase needing a marker, and it never
|
|
643
|
+
// learned the fixes section.mjs got. Two parsers for one concept disagreeing
|
|
644
|
+
// is the same defect class as `reference-planner` vs `resolveRefPath`.
|
|
645
|
+
const phases = walkSections(body).filter(isPhaseHeading);
|
|
646
|
+
|
|
647
|
+
// 5. Phase headings that carry no status marker at all.
|
|
648
|
+
if (phases.length > 0) {
|
|
649
|
+
const unmarked = phases.filter(p => detectMarker(p.heading) === null);
|
|
650
|
+
if (unmarked.length > 0) {
|
|
651
|
+
doc.warnings.push({
|
|
652
|
+
path: doc.path,
|
|
653
|
+
level: 'warning',
|
|
654
|
+
message: `${unmarked.length} of ${phases.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
|
|
655
|
+
});
|
|
656
656
|
}
|
|
657
657
|
}
|
|
658
|
+
|
|
659
|
+
// 6. A phase whose own checklist contradicts its marker. Reported, never
|
|
660
|
+
// fixed: dotmd cannot know whether the marker or the boxes are the stale
|
|
661
|
+
// half, and guessing would either mark work done that is not, or reopen work
|
|
662
|
+
// that is. Both halves are the author's own writing — this only says they
|
|
663
|
+
// disagree.
|
|
664
|
+
for (const phase of phases) {
|
|
665
|
+
const conflict = phaseMarkerConflict(phase);
|
|
666
|
+
if (!conflict) continue;
|
|
667
|
+
doc.warnings.push({
|
|
668
|
+
path: doc.path,
|
|
669
|
+
level: 'warning',
|
|
670
|
+
message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
658
673
|
}
|
|
659
674
|
|
|
660
675
|
// Doc-shape lint: soft warnings on convention drift. Doc-only.
|