universal-dev-standards 6.14.0-beta.1 → 6.14.0-beta.3

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 (37) hide show
  1. package/bin/uds.js +37 -0
  2. package/bundled/ai/standards/open-work-tracking.ai.yaml +7 -2
  3. package/bundled/core/open-work-tracking.md +1 -1
  4. package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
  5. package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
  6. package/bundled/locales/zh-CN/CHANGELOG.md +38 -3
  7. package/bundled/locales/zh-CN/README.md +2 -2
  8. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  9. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
  10. package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
  11. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
  12. package/bundled/locales/zh-TW/CHANGELOG.md +38 -3
  13. package/bundled/locales/zh-TW/README.md +2 -2
  14. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  15. package/bundled/locales/zh-TW/core/open-work-tracking.md +4 -4
  16. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
  17. package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
  18. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
  19. package/package.json +1 -1
  20. package/src/commands/check.js +9 -0
  21. package/src/commands/init.js +73 -18
  22. package/src/commands/open-work.js +60 -0
  23. package/src/commands/uninstall.js +144 -30
  24. package/src/commands/update.js +149 -0
  25. package/src/core/install-records.js +191 -0
  26. package/src/i18n/messages.js +42 -6
  27. package/src/installers/hooks-installer.js +236 -27
  28. package/src/installers/integration-installer.js +5 -1
  29. package/src/uninstallers/hook-uninstaller.js +216 -32
  30. package/src/uninstallers/integration-uninstaller.js +35 -5
  31. package/src/utils/detector.js +46 -1
  32. package/src/utils/git-hooks.js +135 -7
  33. package/src/utils/hasher.js +36 -0
  34. package/src/utils/integration-generator.js +16 -6
  35. package/src/utils/legacy-hook-migration.js +112 -0
  36. package/src/utils/open-work-tracking.mjs +794 -0
  37. package/standards-registry.json +7 -7
@@ -0,0 +1,794 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Open-work-tracking reference checks for OWT-017 / OWT-018 / OWT-019.
4
+ * open-work-tracking 1.1.0 參考判定程序(OWT-017/018/019)。
5
+ * // implements DEC-122-L1
6
+ *
7
+ * ── Where this lives, and why there is exactly one copy ─────────────────────
8
+ * This file is the ONE body of the rules. Two front doors call it and neither
9
+ * holds a copy:
10
+ * uds open-work <next-action|revision|separation|self-test> ...
11
+ * cli/src/commands/open-work.js, shipped in the npm package (`cli/src`
12
+ * is in package.json `files`), so an adopter can run it without a clone.
13
+ * node scripts/check-open-work-tracking.mjs ...
14
+ * a three-line shim in the UDS repository, kept so the documented repo
15
+ * invocation keeps working.
16
+ * It used to live only in `scripts/`, which the npm package does not contain,
17
+ * and 6.14.0-beta.1 therefore shipped a reference check no adopter could run.
18
+ * A second copy under `cli/` would have fixed that and started a second set of
19
+ * rules that drift from the first; a bundling step (prepack copies) would have
20
+ * left the source tree unable to run it. Living under `cli/src/` costs neither.
21
+ *
22
+ * ── What this is, and what it is not ────────────────────────────────────────
23
+ * core/open-work-tracking.md says UDS ships no gate. This script is NOT a gate
24
+ * and is not wired into pre-release-check.sh: UDS has no open-work carrier of
25
+ * its own for it to check. It is the reference decision procedure the standard
26
+ * offers as OWT-015 evidence — an adopter can run it against their own
27
+ * carriers, or reimplement it. It has been observed to fail against violating
28
+ * samples (see cli/tests/unit/scripts/open-work-tracking-intent-and-next-
29
+ * action.test.js, which mutates this file's source to prove it).
30
+ *
31
+ * Self-contained on purpose (node builtins only): the mutation tests copy this
32
+ * file to a temp directory and edit its text, which only works if it imports
33
+ * nothing relative. The `.mjs` extension is part of that: a copy in a bare temp
34
+ * directory has no package.json, and only `.mjs` is read as an ES module there.
35
+ *
36
+ * ── Three checks ────────────────────────────────────────────────────────────
37
+ * next-action OWT-019 every "next action" field names a file path, test
38
+ * name, command or requirement identifier.
39
+ * Three outcomes, never one green:
40
+ * named-resolved an object was found (a path exists)
41
+ * named-unresolved an object is named but not found
42
+ * (legitimate when the next action
43
+ * is to create it), or resolution
44
+ * could not apply (command, test
45
+ * name, identifier)
46
+ * unnamed VIOLATION — the only failure
47
+ * A field is read in three shapes, all through the one
48
+ * VOCAB.nextAction list: a heading section, an inline
49
+ * "Next action: ..." label, and EVERY ROW of a table column whose
50
+ * header is in that list (each report carries the line and the
51
+ * row's first cell, so it can be found).
52
+ * Not evaluated, counted: an empty cell, `-`, `—`, `n/a`, `none`,
53
+ * `done`. OWT-019 is NOT violated by them: it judges a next action
54
+ * that was written; "no next action written" is another failure
55
+ * this check does not decide (a finished item has none either).
56
+ * Undecidable, listed: a table row whose cell count differs from its
57
+ * header (a stray or missing `|`). It is neither judged nor read as
58
+ * empty. A violation elsewhere still exits 1; otherwise the exit is 2,
59
+ * because "no violation found" would then cover only part of the field.
60
+ * revision OWT-018 the acceptance/goal/constraint sections changed
61
+ * between two versions of a carrier; a NEW revision
62
+ * record (change, approver, reason) must exist. No
63
+ * record, or an incomplete one -> violation. A record
64
+ * whose approver is empty, and an edit with no record
65
+ * at all, are LISTED for the hand-back (OWT-007). The
66
+ * listing never changes the exit code (OWT-008).
67
+ * separation OWT-017 no single carrier holds both an intent section and
68
+ * a progress / next-action section.
69
+ *
70
+ * ── What none of them can decide (stated, not implied) ──────────────────────
71
+ * - Whether a revision record honestly describes the diff (OWT-014: that is
72
+ * a claim about meaning, not a relation over artefacts). The check decides
73
+ * that the intent changed, that a new record exists, that it is complete,
74
+ * and whether its approver is filled in — nothing more.
75
+ * - Whether a named object is the RIGHT object.
76
+ * - Recognising "this string is a path / command / test name / identifier"
77
+ * is pattern matching, so its coverage is UNKNOWN (OWT-011). An unrecognised
78
+ * format is reported as unnamed, and a clean pass never means "every next
79
+ * action is specific".
80
+ * - Prose revision notes ("2026-09-29 amended R1") are not structural records.
81
+ * Only table rows and list items inside a section whose heading is a
82
+ * revision heading are read.
83
+ *
84
+ * ── UNCALIBRATED (OWT-016) ──────────────────────────────────────────────────
85
+ * Everything in VOCAB, COMMANDS, EXTENSIONS, DEFAULT_ID_PATTERN and
86
+ * ID_PREFIX_DENYLIST is an initial judgment. None of it was measured against
87
+ * real usage. Adopters should pass their own identifier pattern
88
+ * (--id-pattern) and read the heading vocabulary as a starting point.
89
+ *
90
+ * Exit codes: 0 no violation · 1 violation · 2 cannot decide (no structure
91
+ * found, a next-action table row could not be read, git failed, or this
92
+ * script's own self-test arms failed). 2 is NOT a pass.
93
+ *
94
+ * Usage (`uds` from the npm package, or the repo shim; same arguments):
95
+ * uds open-work next-action <file...> [--root DIR] [--id-pattern RE]
96
+ * uds open-work revision --file PATH --base GIT_REV
97
+ * uds open-work revision --before FILE --after FILE
98
+ * uds open-work separation <file...>
99
+ * uds open-work self-test
100
+ * node scripts/check-open-work-tracking.mjs next-action <file...> ... (repo clone)
101
+ * node scripts/check-open-work-tracking.mjs --self-test
102
+ */
103
+
104
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
105
+ import { execFileSync } from 'node:child_process';
106
+ import { resolve, dirname, join, isAbsolute, basename } from 'node:path';
107
+ import { fileURLToPath } from 'node:url';
108
+
109
+ // ── Vocabulary (UNCALIBRATED, OWT-016) ───────────────────────────────────────
110
+
111
+ export const VOCAB = {
112
+ revision: /revision|change[\s-]?log|amendment|history|修訂|變更紀錄|變更記錄|修改紀錄|修改記錄|異動/i,
113
+ // ONE list of "next action" words. It is read three ways and there is no second copy:
114
+ // a heading (classifyHeading), a table column header (extractNextActions) and an inline
115
+ // label ("Next action: ...", built from .source below). Keep it free of capture groups:
116
+ // the label regex embeds it and reads its own group 1.
117
+ // 回來要做什麼 ("what to do when you come back") is the header the dev-platform worklog's
118
+ // main table actually uses (DEC-122 H2 baseline carrier); that file describes the column as
119
+ // 下一動 in prose but the header text drifted. Added because a header outside this list is
120
+ // invisible to the check, which is the failure DEC-122 measured. Adopter-specific and
121
+ // UNCALIBRATED (OWT-016); an adopter whose header differs adds its own word here.
122
+ nextAction: /next[\s-]?(?:action|step)s?|下一步|下一動|回來要做什麼/i,
123
+ progress: /\bprogress\b|\bstate\b|\bblockers?\b|\bblocked\b|進度|現況|卡在/i,
124
+ intent: /acceptance|criteria|\brequirements?\b|\bgoals?\b|objectives?|constraints?|驗收|需求|目標|限制/i,
125
+ // revision-table columns / labelled list fields
126
+ colChange: /what|change|改了什麼|修改|變更|內容/i,
127
+ colApprover: /approv|核可|核准|批准|同意|確認者/i,
128
+ colReason: /reason|why|理由|原因|為什麼/i,
129
+ };
130
+
131
+ const LABELS = [
132
+ ['change', /(?:what(?:\s+changed)?|changed?|改了什麼|修改|變更)\s*[::]/gi],
133
+ ['approver', /(?:approved\s+by|approver|approval|核可者|核可|核准|批准)\s*[::]/gi],
134
+ ['reason', /(?:reason|why|理由|原因|為什麼)\s*[::]/gi],
135
+ ];
136
+
137
+ const NO_APPROVER = /^(?:|-+|—|–|n\/?a|none|tbd|pending|unknown|\?+|無|未核可|待定|待核可|尚未)$/i;
138
+ const EMPTY_FIELD = /^(?:|-+|—|–|n\/?a|none|無|done|完成|已完成|✅)$/i;
139
+
140
+ // Words that make `word arg` read as a command in prose. Deliberately excludes
141
+ // common English words (go, make, sh): "go through the list" is not a command.
142
+ export const COMMANDS = [
143
+ 'npm', 'npx', 'pnpm', 'yarn', 'node', 'tsx', 'git', 'gh', 'bash', 'python', 'python3',
144
+ 'pytest', 'vitest', 'docker', 'cargo', 'uds', 'egr',
145
+ ];
146
+
147
+ export const EXTENSIONS = [
148
+ 'md', 'mdx', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'py', 'sh', 'bash', 'zsh', 'yaml', 'yml',
149
+ 'json', 'jsonc', 'tsv', 'csv', 'toml', 'sql', 'css', 'scss', 'html', 'txt', 'rb', 'go', 'rs',
150
+ 'java', 'kt', 'swift', 'lock', 'env', 'conf', 'ini', 'xml', 'svg', 'plist',
151
+ ];
152
+
153
+ export const DEFAULT_ID_PATTERN = '\\b[A-Z][A-Z0-9]{1,9}-\\d{1,6}\\b|\\b(?:R|OQ|AC)\\d{1,3}\\b';
154
+ export const ID_PREFIX_DENYLIST = ['UTF', 'SHA', 'ISO', 'GPT', 'RFC', 'CRC', 'AES', 'RSA', 'TLS', 'MD', 'HTTP'];
155
+
156
+ // ── Markdown structure walk ──────────────────────────────────────────────────
157
+
158
+ /** @returns {string|null} */
159
+ function classifyHeading(h) {
160
+ if (VOCAB.revision.test(h)) return 'revision';
161
+ if (VOCAB.nextAction.test(h)) return 'nextAction';
162
+ if (VOCAB.progress.test(h)) return 'progress';
163
+ if (VOCAB.intent.test(h)) return 'intent';
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Annotate every line with the class of its innermost classified ancestor
169
+ * heading. Front matter and fenced blocks keep their text but are flagged.
170
+ */
171
+ export function parseDoc(md) {
172
+ const raw = md.split(/\r?\n/);
173
+ const lines = [];
174
+ const stack = [];
175
+ let inFence = false;
176
+ let fenceMark = '';
177
+ let i = 0;
178
+ let inFront = false;
179
+ if (raw[0] && raw[0].trim() === '---') {
180
+ inFront = true;
181
+ i = 1;
182
+ lines.push({ text: raw[0], cls: null, owner: null, front: true });
183
+ }
184
+ for (; i < raw.length; i++) {
185
+ const text = raw[i];
186
+ if (inFront) {
187
+ lines.push({ text, cls: null, owner: null, front: true });
188
+ if (text.trim() === '---') inFront = false;
189
+ continue;
190
+ }
191
+ const fence = /^\s*(```|~~~)/.exec(text);
192
+ if (fence) {
193
+ if (!inFence) { inFence = true; fenceMark = fence[1]; } else if (fence[1] === fenceMark) { inFence = false; }
194
+ const own = innermost(stack);
195
+ lines.push({ text, cls: own ? own.cls : null, owner: own, fence: true });
196
+ continue;
197
+ }
198
+ if (!inFence) {
199
+ const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(text);
200
+ if (h) {
201
+ const level = h[1].length;
202
+ while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
203
+ const entry = { level, heading: h[2], cls: classifyHeading(h[2]) };
204
+ stack.push(entry);
205
+ const own = innermost(stack);
206
+ lines.push({ text, cls: own ? own.cls : null, owner: own, heading: entry });
207
+ continue;
208
+ }
209
+ }
210
+ const own = innermost(stack);
211
+ lines.push({ text, cls: own ? own.cls : null, owner: own, inFence });
212
+ }
213
+ return lines;
214
+ }
215
+
216
+ function innermost(stack) {
217
+ for (let k = stack.length - 1; k >= 0; k--) if (stack[k].cls) return stack[k];
218
+ return null;
219
+ }
220
+
221
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
222
+
223
+ /** A table line may sit inside a Markdown blockquote ("> | a | b |"): drop the quote marker. */
224
+ const unquote = (line) => line.replace(/^\s*(?:>\s?)+/, '');
225
+
226
+ /** Index of the closing backtick run of exactly `n` backticks at or after `from`, or -1. */
227
+ function closingRun(t, from, n) {
228
+ let j = from;
229
+ while (j < t.length) {
230
+ if (t[j] !== '`') { j++; continue; }
231
+ let m = 0;
232
+ while (t[j + m] === '`') m++;
233
+ if (m === n) return j;
234
+ j += m;
235
+ }
236
+ return -1;
237
+ }
238
+
239
+ /**
240
+ * Split one table row into cells. A `|` does NOT split a cell when it is escaped (`\|`) or
241
+ * sits inside a code span that closes on the same line. The second rule is deliberately
242
+ * looser than GFM (which splits inside code unless escaped): a hand-written note such as
243
+ * `tar -tzf \| grep` or `a|b` in backticks means one cell, and splitting it would shift
244
+ * every later column and misread the next-action cell. An unclosed backtick is literal.
245
+ */
246
+ function splitRow(line) {
247
+ let t = unquote(line).trim();
248
+ if (t.startsWith('|')) t = t.slice(1);
249
+ const cells = [];
250
+ let cur = '';
251
+ let lastDelim = false;
252
+ let i = 0;
253
+ while (i < t.length) {
254
+ const ch = t[i];
255
+ lastDelim = false;
256
+ if (ch === '\\' && t[i + 1] === '|') { cur += '|'; i += 2; continue; }
257
+ if (ch === '`') {
258
+ let n = 0;
259
+ while (t[i + n] === '`') n++;
260
+ const close = closingRun(t, i + n, n);
261
+ if (close !== -1) { cur += t.slice(i, close + n).replace(/\\\|/g, '|'); i = close + n; continue; }
262
+ cur += '`'.repeat(n);
263
+ i += n;
264
+ continue;
265
+ }
266
+ if (ch === '|') { cells.push(cur); cur = ''; lastDelim = true; i++; continue; }
267
+ cur += ch;
268
+ i++;
269
+ }
270
+ if (!lastDelim) cells.push(cur); // a trailing unescaped `|` closes the row; it opens no cell
271
+ return cells.map(norm);
272
+ }
273
+
274
+ const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(unquote(line)) && /-/.test(line);
275
+
276
+ /** Tables among annotated lines: [{header, rows:[{cells, idx}]}] */
277
+ function findTables(lines) {
278
+ const tables = [];
279
+ let k = 0;
280
+ while (k < lines.length) {
281
+ const l = lines[k];
282
+ if (!l.inFence && !l.fence && !l.front && unquote(l.text).trim().startsWith('|') && k + 1 < lines.length
283
+ && isSepRow(lines[k + 1].text) && unquote(lines[k + 1].text).trim().startsWith('|')) {
284
+ const header = splitRow(l.text);
285
+ const rows = [];
286
+ let j = k + 2;
287
+ while (j < lines.length && unquote(lines[j].text).trim().startsWith('|')) {
288
+ rows.push({ cells: splitRow(lines[j].text), idx: j });
289
+ j++;
290
+ }
291
+ tables.push({ header, rows, headerIdx: k });
292
+ k = j;
293
+ } else {
294
+ k++;
295
+ }
296
+ }
297
+ return tables;
298
+ }
299
+
300
+ // ── OWT-018: intent sections and revision records ────────────────────────────
301
+
302
+ /** Map "<heading>#<n>" -> normalised body, for sections classed as intent. */
303
+ export function intentSections(md) {
304
+ const map = new Map();
305
+ const seen = new Map();
306
+ const bodies = new Map();
307
+ for (const l of parseDoc(md)) {
308
+ if (l.cls !== 'intent' || !l.owner || l.heading === l.owner) continue;
309
+ if (!bodies.has(l.owner)) {
310
+ const n = (seen.get(l.owner.heading) || 0) + 1;
311
+ seen.set(l.owner.heading, n);
312
+ bodies.set(l.owner, `${l.owner.heading}#${n}`);
313
+ map.set(bodies.get(l.owner), []);
314
+ }
315
+ const t = norm(l.text);
316
+ if (t) map.get(bodies.get(l.owner)).push(t);
317
+ }
318
+ // An intent heading with no body lines still exists as a (empty) section.
319
+ for (const l of parseDoc(md)) {
320
+ if (l.heading && l.heading.cls === 'intent' && !bodies.has(l.heading)) {
321
+ const n = (seen.get(l.heading.heading) || 0) + 1;
322
+ seen.set(l.heading.heading, n);
323
+ bodies.set(l.heading, `${l.heading.heading}#${n}`);
324
+ map.set(bodies.get(l.heading), []);
325
+ }
326
+ }
327
+ const out = new Map();
328
+ for (const [k, v] of map) out.set(k, v.join('\n'));
329
+ return out;
330
+ }
331
+
332
+ function parseLabelled(text) {
333
+ const t = text.replace(/\*\*|__/g, '');
334
+ const hits = [];
335
+ for (const [kind, re] of LABELS) {
336
+ re.lastIndex = 0;
337
+ let m;
338
+ while ((m = re.exec(t)) !== null) hits.push({ kind, start: m.index, end: m.index + m[0].length });
339
+ }
340
+ hits.sort((a, b) => a.start - b.start);
341
+ const out = { change: '', approver: '', reason: '' };
342
+ if (!hits.length) { out.change = norm(t); return out; }
343
+ for (let h = 0; h < hits.length; h++) {
344
+ const next = hits[h + 1] ? hits[h + 1].start : t.length;
345
+ const val = norm(t.slice(hits[h].end, next)).replace(/[;;,,|]+$/, '').trim();
346
+ if (!out[hits[h].kind]) out[hits[h].kind] = val;
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Revision records: table rows and list items inside revision-class sections. */
352
+ export function revisionEntries(md) {
353
+ const lines = parseDoc(md);
354
+ const entries = [];
355
+ const revLines = lines.map((l) => l.cls === 'revision' && !l.heading && !l.front);
356
+ // tables
357
+ const tableLineIdx = new Set();
358
+ for (const t of findTables(lines)) {
359
+ if (!revLines[t.headerIdx]) continue;
360
+ const ci = t.header.findIndex((c) => VOCAB.colChange.test(c));
361
+ const ai = t.header.findIndex((c) => VOCAB.colApprover.test(c));
362
+ const ri = t.header.findIndex((c) => VOCAB.colReason.test(c));
363
+ tableLineIdx.add(t.headerIdx); tableLineIdx.add(t.headerIdx + 1);
364
+ for (const r of t.rows) {
365
+ tableLineIdx.add(r.idx);
366
+ const others = r.cells.filter((_, n) => n !== ai && n !== ri);
367
+ entries.push({
368
+ key: norm(r.cells.join('|')).toLowerCase(),
369
+ change: ci >= 0 ? (r.cells[ci] || '') : norm(others.join(' ')),
370
+ approver: ai >= 0 ? (r.cells[ai] || '') : '',
371
+ reason: ri >= 0 ? (r.cells[ri] || '') : '',
372
+ });
373
+ }
374
+ }
375
+ // list items
376
+ let cur = null;
377
+ const flush = () => {
378
+ if (cur !== null) {
379
+ const f = parseLabelled(cur);
380
+ entries.push({ key: norm(cur).toLowerCase(), ...f });
381
+ }
382
+ cur = null;
383
+ };
384
+ lines.forEach((l, idx) => {
385
+ if (!revLines[idx] || l.inFence || l.fence || tableLineIdx.has(idx)) { if (!revLines[idx]) flush(); return; }
386
+ const li = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l.text);
387
+ if (li) { flush(); cur = li[1]; } else if (cur !== null && /^\s+\S/.test(l.text)) { cur += ' ' + l.text.trim(); } else { flush(); }
388
+ });
389
+ flush();
390
+ return entries;
391
+ }
392
+
393
+ /**
394
+ * OWT-018. before === null means the carrier did not exist in the baseline.
395
+ * @returns {{status:string, changedSections:string[], newEntries:object[], violations:object[], handBack:object[]}}
396
+ */
397
+ export function checkIntentRevision({ before, after, path = '(carrier)' }) {
398
+ const res = { status: '', path, changedSections: [], newEntries: [], violations: [], handBack: [] };
399
+ const a = intentSections(after);
400
+ if (before === null) { res.status = 'no-baseline'; return res; }
401
+ const b = intentSections(before);
402
+ if (a.size === 0 && b.size === 0) { res.status = 'undecidable'; return res; }
403
+ const keys = new Set([...a.keys(), ...b.keys()]);
404
+ for (const k of keys) if (a.get(k) !== b.get(k)) res.changedSections.push(k.replace(/#\d+$/, ''));
405
+ res.changedSections = [...new Set(res.changedSections)];
406
+ const intentChanged = res.changedSections.length > 0; // [mutation-anchor:intent-changed]
407
+ if (!intentChanged) { res.status = 'unchanged'; return res; }
408
+ const beforeKeys = new Set(revisionEntries(before).map((e) => e.key));
409
+ res.newEntries = revisionEntries(after).filter((e) => !beforeKeys.has(e.key));
410
+ if (res.newEntries.length === 0) {
411
+ res.status = 'changed-no-record';
412
+ res.violations.push({ rule: 'OWT-018', why: 'intent changed and no new revision record exists', sections: res.changedSections });
413
+ res.handBack.push({ rule: 'OWT-018', why: 'no approver: there is no revision record at all', sections: res.changedSections });
414
+ return res;
415
+ }
416
+ res.status = 'changed-recorded';
417
+ for (const e of res.newEntries) {
418
+ if (!e.change || !e.reason) {
419
+ res.violations.push({ rule: 'OWT-018', why: 'revision record is incomplete (needs what changed and why)', entry: e.key });
420
+ }
421
+ const hasApprover = !NO_APPROVER.test(e.approver.trim()); // [mutation-anchor:has-approver]
422
+ if (!hasApprover) {
423
+ res.handBack.push({ rule: 'OWT-018', why: 'revision record has no approver', sections: res.changedSections, entry: e.key });
424
+ }
425
+ }
426
+ return res;
427
+ }
428
+
429
+ // ── OWT-017: carrier separation ──────────────────────────────────────────────
430
+
431
+ export function checkSeparation(carriers) {
432
+ const res = { walked: carriers.length, recognised: 0, violations: [] };
433
+ for (const c of carriers) {
434
+ const lines = parseDoc(c.content);
435
+ const intent = new Set();
436
+ const progress = new Set();
437
+ for (const l of lines) {
438
+ if (!l.heading) continue;
439
+ if (l.heading.cls === 'intent') intent.add(l.heading.heading);
440
+ if (l.heading.cls === 'progress' || l.heading.cls === 'nextAction') progress.add(l.heading.heading);
441
+ }
442
+ if (intent.size || progress.size) res.recognised++;
443
+ const both = intent.size > 0 && progress.size > 0; // [mutation-anchor:separation-both]
444
+ if (both) {
445
+ res.violations.push({ rule: 'OWT-017', path: c.path, intent: [...intent], progress: [...progress] });
446
+ }
447
+ }
448
+ return res;
449
+ }
450
+
451
+ // ── OWT-019: next-action naming ──────────────────────────────────────────────
452
+
453
+ const stripPunct = (s) => s.replace(/^[("'「『“]+|[)"'」』”.,;:!?、。,;:)]+$/g, '');
454
+
455
+ function pathLike(tok, inCode) {
456
+ const t = stripPunct(tok).replace(/:\d+(?::\d+)?$/, '');
457
+ if (!t || /\s/.test(t) || /^[a-z][a-z0-9+.-]*:\/\//i.test(t)) return null;
458
+ const segs = t.split('/');
459
+ const last = segs[segs.length - 1];
460
+ const ext = /\.([A-Za-z0-9]+)$/.exec(last);
461
+ const hasExt = !!ext && EXTENSIONS.includes(ext[1].toLowerCase()) && last.length > ext[0].length;
462
+ const slashes = segs.length - 1;
463
+ const leading = /^(?:\.{1,2}\/|~\/|\/)./.test(t);
464
+ if (inCode) return (slashes >= 1 || hasExt) && /^[\w@./~-]+$/.test(t) ? t : null;
465
+ if (/^[\w@./~-]+$/.test(t) && (hasExt || leading || slashes >= 2)) return t;
466
+ return null;
467
+ }
468
+
469
+ /**
470
+ * @returns {{status:string, kinds:{kind:string,value:string}[], resolution:string}}
471
+ */
472
+ export function classifyNextAction(text, opts = {}) {
473
+ const idRe = new RegExp(opts.idPattern || DEFAULT_ID_PATTERN, 'g');
474
+ const commands = [...COMMANDS, ...(opts.extraCommands || [])];
475
+ const kinds = [];
476
+ const push = (kind, value) => { if (!kinds.some((k) => k.kind === kind && k.value === value)) kinds.push({ kind, value }); };
477
+ const idOk = (v) => !ID_PREFIX_DENYLIST.includes(v.split('-')[0]);
478
+
479
+ // 1) inline code spans: the author marked these as literal names
480
+ const prose = text.replace(/`([^`]+)`/g, (_, c) => {
481
+ const code = c.trim();
482
+ const first = code.split(/\s+/)[0].replace(/^\$\s*/, '');
483
+ if (pathLike(code, true)) push('path', pathLike(code, true)); // [mutation-anchor:kind-path]
484
+ else if (commands.includes(first) || /^\.{1,2}\//.test(first)) push('command', code); // [mutation-anchor:kind-command]
485
+ else if (/\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|[\s/])test_\w+|::/.test(code)) push('test', code); // [mutation-anchor:kind-test]
486
+ else {
487
+ idRe.lastIndex = 0;
488
+ const m = idRe.exec(code);
489
+ if (m && idOk(m[0])) push('id', m[0]);
490
+ }
491
+ return ' ';
492
+ });
493
+
494
+ // 2) prose
495
+ for (const tok of prose.split(/\s+/)) {
496
+ const p = pathLike(tok, false);
497
+ if (p) push('path', p);
498
+ }
499
+ const cmdRe = new RegExp(`(?:^|[^\\w-])(${commands.join('|')})\\s+([-@\\w./:]+)`, 'g');
500
+ let m;
501
+ while ((m = cmdRe.exec(prose)) !== null) push('command', `${m[1]} ${m[2]}`);
502
+ const testRe = /(?:test|測試)\s*[「"“']([^」"”']{6,})[」"”']/gi;
503
+ while ((m = testRe.exec(prose)) !== null) push('test', m[1]);
504
+ idRe.lastIndex = 0;
505
+ while ((m = idRe.exec(prose)) !== null) if (idOk(m[0])) push('id', m[0]); // [mutation-anchor:kind-id]
506
+
507
+ const named = kinds.length > 0; // [mutation-anchor:named]
508
+ if (!named) return { status: 'unnamed', kinds, resolution: 'n/a' };
509
+ const root = opts.root;
510
+ let resolved = false;
511
+ if (root) {
512
+ for (const k of kinds) {
513
+ if (k.kind !== 'path') continue;
514
+ const rel = k.value.replace(/^\.\//, '');
515
+ if (existsSync(isAbsolute(rel) ? rel : join(root, rel))) resolved = true;
516
+ }
517
+ }
518
+ return {
519
+ status: resolved ? 'named-resolved' : 'named-unresolved',
520
+ kinds,
521
+ resolution: root ? 'attempted (paths only)' : 'not attempted (no --root)',
522
+ };
523
+ }
524
+
525
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
526
+
527
+ /**
528
+ * Find "next action" fields in a carrier. Three shapes are read, all through the ONE
529
+ * vocabulary VOCAB.nextAction: a heading section, an inline label, and a table column whose
530
+ * HEADER is in that vocabulary (every row of that column is one field).
531
+ *
532
+ * A table row whose cell count differs from the header's is returned in `ragged`, not in
533
+ * `items`: after a stray or missing `|` the next-action cell cannot be told from its
534
+ * neighbour, and reading an absent cell as "empty" would turn a broken row into a quiet
535
+ * "no next action written". It is neither judged nor dropped: the caller counts and lists it.
536
+ * @returns {{fields:number, items:{text:string,where:string}[], ragged:{where:string,cells:number,expected:number}[]}}
537
+ */
538
+ export function extractNextActions(md) {
539
+ const lines = parseDoc(md);
540
+ const items = [];
541
+ const ragged = [];
542
+ let fields = 0;
543
+ const tableIdx = new Set();
544
+ const headerIdx = new Set();
545
+ const tables = findTables(lines);
546
+
547
+ for (const t of tables) {
548
+ // every header cell in the shared vocabulary is a next-action column (usually exactly one)
549
+ const cols = t.header.map((c, n) => (VOCAB.nextAction.test(c) ? n : -1)).filter((n) => n >= 0); // [mutation-anchor:table-header]
550
+ const hasCol = cols.length > 0;
551
+ tableIdx.add(t.headerIdx); tableIdx.add(t.headerIdx + 1); headerIdx.add(t.headerIdx);
552
+ t.rows.forEach((r) => tableIdx.add(r.idx));
553
+ if (hasCol) {
554
+ for (const r of t.rows) {
555
+ const who = `row "${clip(r.cells[0].replace(/\*\*|__/g, ''), 40)}"`;
556
+ if (r.cells.length !== t.header.length) {
557
+ ragged.push({ idx: r.idx, where: `table row (line ${r.idx + 1}, ${who})`, cells: r.cells.length, expected: t.header.length });
558
+ continue;
559
+ }
560
+ for (const col of cols) {
561
+ fields++;
562
+ items.push({ text: r.cells[col], where: `table column "${t.header[col]}" (line ${r.idx + 1}, ${who})` });
563
+ }
564
+ }
565
+ }
566
+ // rows inside a nextAction-class section are handled by the section walk below
567
+ t.hasNextActionColumn = hasCol;
568
+ }
569
+
570
+ // inline labelled "**Next action**: ..." (outside tables that have their own column)
571
+ const labelRe = new RegExp(`(?:\\*\\*|__)?(?:${VOCAB.nextAction.source})(?:\\*\\*|__)?\\s*[::]\\s*(.+)$`, 'i');
572
+ const colTableRows = new Set();
573
+ // Only rows read through the column are exempt from the label scan. A ragged row was not
574
+ // read, so an inline "Next action: ..." inside it must still be seen (else it goes dark).
575
+ const raggedIdx = new Set(ragged.map((g) => g.idx));
576
+ for (const t of tables) if (t.hasNextActionColumn) t.rows.forEach((r) => { if (!raggedIdx.has(r.idx)) colTableRows.add(r.idx); });
577
+ lines.forEach((l, idx) => {
578
+ if (l.front || l.fence || l.inFence || l.heading || l.cls === 'nextAction' || colTableRows.has(idx)) return;
579
+ // a label inside a table row reads to the end of the row, not into its closing pipe
580
+ const m = labelRe.exec(unquote(l.text).trim().startsWith('|') ? l.text.replace(/\s*\|\s*$/, '') : l.text);
581
+ if (m && !/^\s*$/.test(m[1])) { fields++; items.push({ text: m[1], where: `label (line ${idx + 1})` }); }
582
+ });
583
+
584
+ // heading sections classed nextAction
585
+ const byOwner = new Map();
586
+ lines.forEach((l, idx) => {
587
+ if (l.front || l.cls !== 'nextAction' || !l.owner || l.owner.cls !== 'nextAction') return;
588
+ if (l.heading) { if (!byOwner.has(l.owner)) byOwner.set(l.owner, []); return; }
589
+ if (!byOwner.has(l.owner)) byOwner.set(l.owner, []);
590
+ byOwner.get(l.owner).push({ l, idx });
591
+ });
592
+ for (const [owner, body] of byOwner) {
593
+ const sec = [];
594
+ let cur = null;
595
+ let fenceBuf = false;
596
+ const flush = () => { if (cur !== null && norm(cur)) sec.push(norm(cur)); cur = null; };
597
+ for (const { l, idx } of body) {
598
+ if (l.fence) { fenceBuf = !fenceBuf; continue; }
599
+ if (l.inFence) { if (norm(l.text)) cur = (cur ?? '') + ` \`${norm(l.text)}\``; continue; }
600
+ if (tableIdx.has(idx)) {
601
+ if (isSepRow(l.text) || headerIdx.has(idx) || colTableRows.has(idx)) continue;
602
+ sec.push(norm(splitRow(l.text).join(' ')));
603
+ continue;
604
+ }
605
+ const li = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l.text);
606
+ if (li) { flush(); cur = li[1]; } else if (!norm(l.text)) { flush(); } else { cur = (cur === null ? '' : cur + ' ') + l.text.trim(); }
607
+ }
608
+ flush();
609
+ // a section whose only content is a table that has its own next-action column
610
+ // was already counted through that column; it is not an empty field
611
+ if (sec.length === 0 && body.some(({ idx }) => colTableRows.has(idx))) continue;
612
+ fields++;
613
+ if (sec.length === 0) items.push({ text: '', where: `section "${owner.heading}" (empty)` });
614
+ for (const s of sec) items.push({ text: s, where: `section "${owner.heading}"` });
615
+ }
616
+ return { fields, items, ragged };
617
+ }
618
+
619
+ export function checkNextActions(carriers, opts = {}) {
620
+ const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [], undecidable: [] };
621
+ for (const c of carriers) {
622
+ const { fields, items, ragged } = extractNextActions(c.content);
623
+ for (const g of ragged) res.undecidable.push({ path: c.path, ...g });
624
+ if (fields === 0 && ragged.length === 0) { res.noFieldCarriers.push(c.path); continue; }
625
+ for (const it of items) {
626
+ const text = it.text.replace(/\*\*|__/g, '').trim();
627
+ if (EMPTY_FIELD.test(text)) { res.empty++; continue; }
628
+ res.walked++;
629
+ const r = classifyNextAction(text, opts);
630
+ res.counts[r.status]++;
631
+ res.detail.push({ path: c.path, where: it.where, text, ...r });
632
+ if (r.status === 'unnamed') res.violations.push({ rule: 'OWT-019', path: c.path, where: it.where, text });
633
+ }
634
+ }
635
+ return res;
636
+ }
637
+
638
+ // ── Self-test arms (the main path runs these first; a broken checker is exit 2) ─
639
+
640
+ export function runSelfTest() {
641
+ const failures = [];
642
+ const expect = (name, cond) => { if (!cond) failures.push(name); };
643
+
644
+ // OWT-019
645
+ expect('D2 violating: verb-only is unnamed', classifyNextAction('Continue implementation', {}).status === 'unnamed');
646
+ expect('D2 violating: vague sentence is unnamed', classifyNextAction('handle the rest of the items and clean up', {}).status === 'unnamed');
647
+ expect('D2 clean: path', classifyNextAction('edit scripts/foo.mjs', {}).status !== 'unnamed');
648
+ expect('D2 clean: command', classifyNextAction('run `npm test` again', {}).status !== 'unnamed');
649
+ expect('D2 clean: identifier', classifyNextAction('finish XSPEC-436 R1', {}).status !== 'unnamed');
650
+ expect('D2 clean: test name', classifyNextAction('make the test "a next action that names nothing is reported" pass', {}).status !== 'unnamed');
651
+
652
+ // OWT-019, table columns: the header decides which column is read, never the position
653
+ const tbl = extractNextActions('| item | state | Next action |\n|---|---|---|\n| a | open | keep going |\n');
654
+ expect('D2 clean: only the header-named column is a next-action field', tbl.fields === 1 && tbl.items[0].text === 'keep going');
655
+ expect('D2 clean: a table without such a header has no next-action field', extractNextActions('| item | state |\n|---|---|\n| a | open |\n').fields === 0);
656
+
657
+ // OWT-018
658
+ const spec = (ac, rev) => `# S\n\n## Acceptance criteria\n\n${ac}\n\n## Revisions\n\n${rev}\n`;
659
+ const table = (rows) => `| Change | Approver | Reason |\n|---|---|---|\n${rows}`;
660
+ const r0 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('')) });
661
+ expect('D1 violating: changed, no record -> violation', r0.violations.length === 1 && r0.handBack.length === 1);
662
+ const r1 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('| added B | | scope grew |\n')) });
663
+ expect('D1 violating: record without approver -> hand-back only', r1.violations.length === 0 && r1.handBack.length === 1);
664
+ const r2 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A\n- B', table('| added B | albert | scope grew |\n')) });
665
+ expect('D1 clean: recorded and approved', r2.violations.length === 0 && r2.handBack.length === 0 && r2.status === 'changed-recorded');
666
+ const r3 = checkIntentRevision({ before: spec('- A', table('')), after: spec('- A', table('')) });
667
+ expect('D1 clean: unchanged', r3.status === 'unchanged');
668
+
669
+ // OWT-017
670
+ const both = checkSeparation([{ path: 'x.md', content: '# X\n## Acceptance criteria\n- a\n## Next action\n- b\n' }]);
671
+ expect('D1 violating: one carrier holds both', both.violations.length === 1);
672
+ const apart = checkSeparation([
673
+ { path: 'spec.md', content: '# X\n## Acceptance criteria\n- a\n' },
674
+ { path: 'log.md', content: '# L\n## Next action\n- b\n' },
675
+ ]);
676
+ expect('D1 clean: carriers apart', apart.violations.length === 0 && apart.recognised === 2);
677
+
678
+ return { ok: failures.length === 0, failures };
679
+ }
680
+
681
+ // ── CLI ──────────────────────────────────────────────────────────────────────
682
+
683
+ const COVERAGE_NOTE = 'COVERAGE UNKNOWN (OWT-011): recognising a path/command/test name/identifier is pattern matching; an unrecognised format is reported as unnamed. A clean pass does not mean every next action is specific.';
684
+ const UNCALIBRATED_NOTE = 'UNCALIBRATED (OWT-016): heading vocabulary, command list, extension list and identifier pattern are initial judgments.';
685
+
686
+ function readCarrier(p) {
687
+ return { path: p, content: readFileSync(p, 'utf8') };
688
+ }
689
+
690
+ function gitShowBase(file, rev) {
691
+ const abs = resolve(file);
692
+ const git = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
693
+ const top = git(['rev-parse', '--show-toplevel'], dirname(abs)).trim();
694
+ git(['rev-parse', '--verify', `${rev}^{commit}`], top);
695
+ // Ask git for the path, never compute it from two filesystem paths: on Windows the
696
+ // temp directory can carry an 8.3 short name (RUNNER~1) that realpathSync does not
697
+ // expand while git reports the long name, so relative() walks out of the repo and
698
+ // `git show` fails, which this tool reports as "undecidable" (exit 2) rather than a
699
+ // verdict. git computes the prefix from the same cwd it will read from.
700
+ const prefix = git(['rev-parse', '--show-prefix'], dirname(abs)).trim();
701
+ const rel = `${prefix}${basename(abs)}`;
702
+ const listed = git(['ls-tree', rev, '--', rel], top).trim();
703
+ if (!listed) return null; // absent at that revision: a new file, not an error
704
+ return git(['show', `${rev}:${rel}`], top);
705
+ }
706
+
707
+ export function main(argv, io = { log: console.log, err: console.error }) {
708
+ const out = [];
709
+ const say = (s) => out.push(s);
710
+ const args = [...argv];
711
+ const flag = (name) => {
712
+ const i = args.indexOf(name);
713
+ if (i === -1) return undefined;
714
+ const v = args[i + 1];
715
+ args.splice(i, 2);
716
+ return v;
717
+ };
718
+ const finish = (code) => { for (const l of out) (code === 2 ? io.err : io.log)(l); return code; };
719
+
720
+ const selfTest = runSelfTest();
721
+ if (args[0] === '--self-test') {
722
+ say(selfTest.ok ? '[owt] self-test: OK' : `[owt] self-test FAILED: ${selfTest.failures.join('; ')}`);
723
+ return finish(selfTest.ok ? 0 : 2);
724
+ }
725
+ if (!selfTest.ok) {
726
+ say(`[owt] CANNOT DECIDE: this checker's own self-test arms failed (${selfTest.failures.join('; ')}). A checker that fails its own arms is not evidence of anything. Exit 2 is not a pass.`);
727
+ return finish(2);
728
+ }
729
+
730
+ const cmd = args.shift();
731
+ try {
732
+ if (cmd === 'next-action') {
733
+ const root = flag('--root') ?? process.cwd();
734
+ const idPattern = flag('--id-pattern');
735
+ const files = args;
736
+ if (!files.length) { say('[owt] next-action: no files given'); return finish(2); }
737
+ const res = checkNextActions(files.map(readCarrier), { root, idPattern });
738
+ say(`[owt] OWT-019 walked ${res.walked} next-action field(s) in ${files.length} carrier(s); ${res.empty} empty/done field(s) not evaluated; ${res.noFieldCarriers.length} carrier(s) had no next-action field`);
739
+ say(`[owt] named-resolved=${res.counts['named-resolved']} named-unresolved=${res.counts['named-unresolved']} unnamed=${res.counts.unnamed} undecidable-table-rows=${res.undecidable.length}`);
740
+ for (const d of res.detail) say(`[owt] ${d.status.padEnd(16)} ${d.path} ${d.where}: ${d.text.slice(0, 80)}${d.kinds.length ? ' <- ' + d.kinds.map((k) => `${k.kind}:${k.value}`).join(', ') : ''}`);
741
+ for (const v of res.violations) say(`[owt] VIOLATION OWT-019: ${v.path} ${v.where} names no file path, test name, command or requirement identifier: "${v.text.slice(0, 80)}"`);
742
+ for (const u of res.undecidable) say(`[owt] UNDECIDABLE: ${u.path} ${u.where} has ${u.cells} cell(s) but its header has ${u.expected}; the next-action cell cannot be located, so the row is neither judged nor counted as empty`);
743
+ say(`[owt] ${COVERAGE_NOTE}`);
744
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
745
+ if (res.walked + res.empty === 0) { say('[owt] CANNOT DECIDE: no next-action field found in any carrier (walked 0). Exit 2 is not a pass.'); return finish(2); }
746
+ // A violation is a definite answer whatever else is unreadable. Absent one, a row we could
747
+ // not read means "no violation found" is not "no violation": exit 2, never a green 0.
748
+ if (!res.violations.length && res.undecidable.length) { say(`[owt] CANNOT DECIDE: ${res.undecidable.length} table row(s) could not be read (see UNDECIDABLE above), so a clean result would cover only part of the field. Exit 2 is not a pass.`); return finish(2); }
749
+ return finish(res.violations.length ? 1 : 0);
750
+ }
751
+ if (cmd === 'revision') {
752
+ const file = flag('--file');
753
+ const base = flag('--base');
754
+ const beforeF = flag('--before');
755
+ const afterF = flag('--after');
756
+ let before; let after; let path;
757
+ if (file && base) { path = file; after = readFileSync(file, 'utf8'); before = gitShowBase(file, base); } else if (beforeF && afterF) { path = afterF; before = readFileSync(beforeF, 'utf8'); after = readFileSync(afterF, 'utf8'); } else { say('[owt] revision: give --file PATH --base REV, or --before FILE --after FILE'); return finish(2); }
758
+ const r = checkIntentRevision({ before, after, path });
759
+ say(`[owt] OWT-018 ${path}: ${r.status}${r.changedSections.length ? ` (changed: ${r.changedSections.join(', ')})` : ''}`);
760
+ if (r.status === 'no-baseline') say('[owt] the carrier did not exist in the baseline; there is nothing to compare');
761
+ if (r.status === 'undecidable') { say('[owt] CANNOT DECIDE: no acceptance/goal/constraint section found in either version, so the check cannot see the intent. Exit 2 is not a pass.'); say(`[owt] ${UNCALIBRATED_NOTE}`); return finish(2); }
762
+ for (const v of r.violations) say(`[owt] VIOLATION ${v.rule}: ${v.why}${v.sections ? ` [${v.sections.join(', ')}]` : ''}${v.entry ? ` <${v.entry.slice(0, 60)}>` : ''}`);
763
+ if (r.handBack.length) {
764
+ say('[owt] HAND-BACK (OWT-018 -> OWT-007) — list these when control returns to a human; this listing never blocks (OWT-008):');
765
+ for (const h of r.handBack) say(`[owt] edit to ${h.sections ? h.sections.join(', ') : 'intent'} in ${path}: ${h.why}`);
766
+ }
767
+ say('[owt] LIMIT: the check decides that intent changed, that a NEW record exists, that it is complete and whether an approver is filled in. It cannot decide that the record describes the change honestly (OWT-014).');
768
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
769
+ return finish(r.violations.length ? 1 : 0);
770
+ }
771
+ if (cmd === 'separation') {
772
+ const files = args;
773
+ if (!files.length) { say('[owt] separation: no files given'); return finish(2); }
774
+ const r = checkSeparation(files.map(readCarrier));
775
+ say(`[owt] OWT-017 walked ${r.walked} carrier(s); ${r.recognised} had a recognised intent or progress section`);
776
+ for (const v of r.violations) say(`[owt] VIOLATION OWT-017: ${v.path} holds intent (${v.intent.join(', ')}) and progress (${v.progress.join(', ')}) in one carrier`);
777
+ say(`[owt] ${UNCALIBRATED_NOTE}`);
778
+ if (r.recognised === 0) { say('[owt] CANNOT DECIDE: no carrier had a recognised intent or progress heading. Exit 2 is not a pass.'); return finish(2); }
779
+ return finish(r.violations.length ? 1 : 0);
780
+ }
781
+ // The repo shim (its own `--self-test` flag) is named FIRST on purpose: cli/scripts/check-command-existence.mjs
782
+ // reads a `uds <...>` string up to the closing quote, so a `--self-test` written AFTER `uds open-work`
783
+ // is judged as a flag of the `uds open-work` command, which has none (it is the subcommand `self-test`).
784
+ say('[owt] usage: node scripts/check-open-work-tracking.mjs ... | --self-test (or from the npm package: uds open-work next-action|revision|separation|self-test ...)');
785
+ return finish(2);
786
+ } catch (e) {
787
+ say(`[owt] CANNOT DECIDE: ${e.message.split('\n')[0]}`);
788
+ return finish(2);
789
+ }
790
+ }
791
+
792
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
793
+ process.exitCode = main(process.argv.slice(2));
794
+ }