universal-dev-standards 6.14.0-beta.2 → 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.
@@ -532,6 +532,42 @@ export function computeIntegrationBlockHash(filePath) {
532
532
  }
533
533
  }
534
534
 
535
+ /**
536
+ * Hash everything in an integration file that is OUTSIDE the UDS marker block.
537
+ *
538
+ * `computeIntegrationBlockHash` answers "did anyone edit what UDS manages?".
539
+ * This answers the opposite question, which `uds uninstall` needs: "is anything
540
+ * outside the block something other than what UDS itself wrote?". A file UDS
541
+ * created from nothing (AGENTS.md's `# AGENTS.md` / `> Auto-generated by ...`
542
+ * header and its trailing sections, CLAUDE.md's leading guidance) has plenty of
543
+ * text outside the markers, and that text alone cannot tell "generated by UDS"
544
+ * from "typed by the adopter" — only a hash recorded when UDS created the file,
545
+ * compared with the same hash now, can.
546
+ *
547
+ * Line endings are normalized (same reason as every other hash here) and each
548
+ * side is trimmed, so an editor adding a trailing newline is not "a user edit".
549
+ *
550
+ * @param {string} filePath - Absolute file path
551
+ * @returns {{hash: string, size: number}|null} null when the file cannot be read,
552
+ * has no marker block, or has an ambiguous marker pair (no proof is available)
553
+ */
554
+ export function computeOutsideBlockHash(filePath) {
555
+ try {
556
+ const content = normalizeLineEndings(readFileSync(filePath, 'utf-8'));
557
+ const { before, blockContent, after } = extractBlockContent(content, detectFormat(filePath));
558
+ if (!blockContent) return null;
559
+ const outside = `${before.trim()}\n\u0000\n${after.trim()}`;
560
+ return {
561
+ hash: `sha256:${createHash('sha256').update(outside).digest('hex')}`,
562
+ size: Buffer.byteLength(outside, 'utf-8')
563
+ };
564
+ } catch {
565
+ // Includes AmbiguousMarkerError: with two marker pairs there is no single
566
+ // "outside", so there is nothing to prove anything with.
567
+ return null;
568
+ }
569
+ }
570
+
535
571
  /**
536
572
  * Compare integration block hash with stored hash info
537
573
  * @param {string} filePath - Absolute file path
@@ -3202,8 +3202,14 @@ export function writeIntegrationFile(tool, config, projectPath) {
3202
3202
  try {
3203
3203
  let content = generateIntegrationContent({ ...config, tool });
3204
3204
 
3205
+ // Whether this call CREATES the file (as opposed to merging into one that was
3206
+ // already there). Only a file UDS created from nothing can later be proven
3207
+ // to be wholly UDS's — see core/install-records.js. Taken from the existence
3208
+ // check that already gates the merge below, before anything is written.
3209
+ const created = !existsSync(filePath);
3210
+
3205
3211
  // Handle merge if file exists
3206
- if (existsSync(filePath)) {
3212
+ if (!created) {
3207
3213
  const existingContent = readFileSync(filePath, 'utf-8');
3208
3214
 
3209
3215
  if (config.mergeStrategy) {
@@ -3242,6 +3248,7 @@ export function writeIntegrationFile(tool, config, projectPath) {
3242
3248
  success: true,
3243
3249
  path: fileName, // Return relative path for consistency
3244
3250
  absolutePath: filePath,
3251
+ created,
3245
3252
  blockHashInfo // Contains: blockHash, blockSize, fullHash, fullSize
3246
3253
  };
3247
3254
  } catch (error) {
@@ -3584,8 +3591,8 @@ export function parseStandardsIndexCount(content) {
3584
3591
  export function wrapWithMarkers(content, format) {
3585
3592
  const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
3586
3593
  const warning = format === 'plaintext'
3587
- ? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify.'
3588
- : '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify. -->';
3594
+ ? '# WARNING: This block is managed by UDS. DO NOT manually edit. Use \'npx universal-dev-standards init\' or \'npx universal-dev-standards update\' to modify.'
3595
+ : '<!-- WARNING: This block is managed by UDS. DO NOT manually edit. Use \'npx universal-dev-standards init\' or \'npx universal-dev-standards update\' to modify. -->';
3589
3596
  // 冪等:warning 位於 markers **內部**,而 extractMarkedContent 取出的內容也含它,
3590
3597
  // 於是重新包裝會疊出第二份(dev-platform CLAUDE.md 實測 178/179 兩行完全相同)。
3591
3598
  // 這裡先剝掉內容開頭既有的 warning,不論上游哪條路徑造成都能修掉。
@@ -3949,7 +3956,7 @@ export function generateAgentsMdSummary(config = {}) {
3949
3956
  blockLines.push(`- \`${dir}/${filename}\` — ${filename.replace(suffix, '')}`);
3950
3957
  }
3951
3958
  } else {
3952
- blockLines.push('No standards installed yet. Run `npx uds init` to install.');
3959
+ blockLines.push('No standards installed yet. Run `npx universal-dev-standards init` to install.');
3953
3960
  }
3954
3961
 
3955
3962
  lines.push(wrapWithMarkers(blockLines.join('\n'), 'markdown'));
@@ -3976,12 +3983,14 @@ export function generateAgentsMdSummary(config = {}) {
3976
3983
  */
3977
3984
  export function writeAgentsMdSummary(config, projectPath) {
3978
3985
  const filePath = join(projectPath, 'AGENTS.md');
3979
-
3980
3986
  try {
3981
3987
  let content = generateAgentsMdSummary({ ...config, projectPath });
3982
3988
 
3989
+ // See writeIntegrationFile: true only when this call creates the file.
3990
+ const created = !existsSync(filePath);
3991
+
3983
3992
  // Handle merge if file exists (preserve user content outside markers)
3984
- if (existsSync(filePath)) {
3993
+ if (!created) {
3985
3994
  const existingContent = readFileSync(filePath, 'utf-8');
3986
3995
  const newParts = extractMarkedContent(content, 'markdown');
3987
3996
  if (newParts.content) {
@@ -3997,6 +4006,7 @@ export function writeAgentsMdSummary(config, projectPath) {
3997
4006
  success: true,
3998
4007
  path: 'AGENTS.md',
3999
4008
  absolutePath: filePath,
4009
+ created,
4000
4010
  blockHashInfo
4001
4011
  };
4002
4012
  } catch (error) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Migrate the pre-commit hook line older UDS versions wrote
3
+ * (`npx uds check`) to the block current UDS writes.
4
+ *
5
+ * Why it exists: every adopter who ran `uds init` before this fix has
6
+ * `.husky/pre-commit` asking `npx` to run the bare name `uds`. `npx` looks in
7
+ * node_modules/.bin and PATH first and otherwise asks the npm registry — where
8
+ * `uds` is an unrelated package that is not ours (see buildPreCommitBlock in
9
+ * git-hooks.js for the measurements). Fixing what `uds init` writes today does
10
+ * nothing for the hooks already on disk, so `uds update` swaps them.
11
+ *
12
+ * ⚠️ THIS FILE RECOGNISES the old text; it must never EMIT it. The regression
13
+ * guard (tests/unit/no-bare-uds-npx.test.js) lists this file as the one place
14
+ * a bare-name npx string is allowed, and says why. Nothing here may write one.
15
+ *
16
+ * What counts as "UDS wrote this line" — exact equality, not a pattern:
17
+ * only the two shapes UDS has ever generated, and only directly under the
18
+ * marker comment UDS put above them.
19
+ * 2026-02-04 .. 2026-03-04 npx uds check --standard checkin-standards
20
+ * 2026-03-04 .. this fix npx uds check
21
+ * (from `git log -S` on the writer in cli/src/commands/init.js.) A line an
22
+ * adopter edited, or wrote themselves, is NOT swapped: it cannot be proven to be
23
+ * ours, and a hook that runs the adopter's own arguments is theirs to change.
24
+ * It is reported, with the exact line number and what to write instead.
25
+ *
26
+ * @module utils/legacy-hook-migration
27
+ */
28
+
29
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
30
+ import { join } from 'path';
31
+ import { buildPreCommitBlock, UDS_HOOK_MARKER, BARE_UDS_RUNNER_RE, hasBareUdsRunner } from './git-hooks.js';
32
+ import { getFileRecord, proveUnchanged, recordFile, newRecorder, RECORD_KINDS } from '../core/install-records.js';
33
+
34
+ /** The exact command lines UDS itself has written under the marker, and the extra arguments each carried. */
35
+ const UDS_WRITTEN_LEGACY_LINES = new Map([
36
+ ['npx uds check', ''],
37
+ ['npx uds check --standard checkin-standards', '--standard checkin-standards']
38
+ ]);
39
+
40
+ /**
41
+ * Swap the legacy hook line UDS wrote in `.husky/pre-commit` for the current block.
42
+ *
43
+ * @param {string} projectPath
44
+ * @param {{plan?: boolean, manifest?: object|null}} [opts]
45
+ * plan — report what would change, write nothing
46
+ * manifest — read only to see whether the file was provably UDS's before the
47
+ * swap (so the install record can follow it); may be null
48
+ * @returns {{
49
+ * state: 'none'|'migrated'|'would-migrate'|'kept'|'error',
50
+ * replaced: number, // legacy UDS-written lines swapped (or that would be)
51
+ * kept: Array<{line: number, text: string}>, // bare-name lines UDS cannot prove it wrote
52
+ * recorder: object|null, // fold into the manifest when non-null
53
+ * error?: string
54
+ * }}
55
+ */
56
+ export function migrateLegacyHuskyHook(projectPath, { plan = false, manifest = null } = {}) {
57
+ const rel = '.husky/pre-commit';
58
+ const abs = join(projectPath, '.husky', 'pre-commit');
59
+ const none = { state: 'none', replaced: 0, kept: [], recorder: null };
60
+ if (!existsSync(abs)) return none;
61
+
62
+ let content;
63
+ try {
64
+ content = readFileSync(abs, 'utf-8');
65
+ } catch (e) {
66
+ return { ...none, state: 'error', error: e.message };
67
+ }
68
+ if (!hasBareUdsRunner(content)) return none;
69
+
70
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
71
+ const lines = content.split('\n');
72
+ const out = [];
73
+ const kept = [];
74
+ let replaced = 0;
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const text = lines[i].replace(/\r$/, '');
78
+ const next = i + 1 < lines.length ? lines[i + 1].replace(/\r$/, '').trim() : null;
79
+ // Marker line directly followed by a line UDS is known to have written.
80
+ if (text.trim() === UDS_HOOK_MARKER && next !== null && UDS_WRITTEN_LEGACY_LINES.has(next)) {
81
+ const block = buildPreCommitBlock({ args: UDS_WRITTEN_LEGACY_LINES.get(next) });
82
+ // block ends with '\n'; drop that so the surrounding line join supplies it.
83
+ out.push(...block.replace(/\n$/, '').split('\n').map((l) => l + (eol === '\r\n' ? '\r' : '')));
84
+ replaced += 1;
85
+ i += 1; // the legacy command line is consumed
86
+ continue;
87
+ }
88
+ if (BARE_UDS_RUNNER_RE.test(text)) kept.push({ line: i + 1, text: text.trim() });
89
+ out.push(lines[i]);
90
+ }
91
+
92
+ if (replaced === 0) return { state: 'kept', replaced: 0, kept, recorder: null };
93
+
94
+ if (plan) return { state: 'would-migrate', replaced, kept, recorder: null };
95
+
96
+ // Was the whole file provably UDS's until now? Then the swap must not cost it
97
+ // that proof: re-record the new content, or uninstall falls back from "delete
98
+ // the file UDS created" to "strip lines".
99
+ let recorder = null;
100
+ const wasProven = !!manifest && !!getFileRecord(manifest, rel) && proveUnchanged(manifest, projectPath, rel).state === 'proven';
101
+
102
+ try {
103
+ writeFileSync(abs, out.join('\n'), 'utf-8');
104
+ } catch (e) {
105
+ return { state: 'error', replaced: 0, kept, recorder: null, error: e.message };
106
+ }
107
+ if (wasProven) {
108
+ recorder = newRecorder();
109
+ recordFile(recorder, projectPath, rel, RECORD_KINDS.GIT_HOOK);
110
+ }
111
+ return { state: 'migrated', replaced, kept, recorder };
112
+ }
@@ -44,6 +44,19 @@
44
44
  * could not apply (command, test
45
45
  * name, identifier)
46
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.
47
60
  * revision OWT-018 the acceptance/goal/constraint sections changed
48
61
  * between two versions of a carrier; a NEW revision
49
62
  * record (change, approver, reason) must exist. No
@@ -75,8 +88,8 @@
75
88
  * (--id-pattern) and read the heading vocabulary as a starting point.
76
89
  *
77
90
  * Exit codes: 0 no violation · 1 violation · 2 cannot decide (no structure
78
- * found, git failed, or this script's own self-test arms failed). 2 is NOT a
79
- * pass.
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.
80
93
  *
81
94
  * Usage (`uds` from the npm package, or the repo shim; same arguments):
82
95
  * uds open-work next-action <file...> [--root DIR] [--id-pattern RE]
@@ -97,7 +110,16 @@ import { fileURLToPath } from 'node:url';
97
110
 
98
111
  export const VOCAB = {
99
112
  revision: /revision|change[\s-]?log|amendment|history|修訂|變更紀錄|變更記錄|修改紀錄|修改記錄|異動/i,
100
- nextAction: /next[\s-]?(action|step)s?|下一步|下一動/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,
101
123
  progress: /\bprogress\b|\bstate\b|\bblockers?\b|\bblocked\b|進度|現況|卡在/i,
102
124
  intent: /acceptance|criteria|\brequirements?\b|\bgoals?\b|objectives?|constraints?|驗收|需求|目標|限制/i,
103
125
  // revision-table columns / labelled list fields
@@ -198,14 +220,58 @@ function innermost(stack) {
198
220
 
199
221
  const norm = (s) => s.replace(/\s+/g, ' ').trim();
200
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
+ */
201
246
  function splitRow(line) {
202
- let t = line.trim();
247
+ let t = unquote(line).trim();
203
248
  if (t.startsWith('|')) t = t.slice(1);
204
- if (t.endsWith('|')) t = t.slice(0, -1);
205
- return t.split(/(?<!\\)\|/).map((c) => norm(c.replace(/\\\|/g, '|')));
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);
206
272
  }
207
273
 
208
- const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && /-/.test(line);
274
+ const isSepRow = (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(unquote(line)) && /-/.test(line);
209
275
 
210
276
  /** Tables among annotated lines: [{header, rows:[{cells, idx}]}] */
211
277
  function findTables(lines) {
@@ -213,12 +279,12 @@ function findTables(lines) {
213
279
  let k = 0;
214
280
  while (k < lines.length) {
215
281
  const l = lines[k];
216
- if (!l.inFence && !l.fence && !l.front && l.text.trim().startsWith('|') && k + 1 < lines.length
217
- && isSepRow(lines[k + 1].text) && lines[k + 1].text.trim().startsWith('|')) {
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('|')) {
218
284
  const header = splitRow(l.text);
219
285
  const rows = [];
220
286
  let j = k + 2;
221
- while (j < lines.length && lines[j].text.trim().startsWith('|')) {
287
+ while (j < lines.length && unquote(lines[j].text).trim().startsWith('|')) {
222
288
  rows.push({ cells: splitRow(lines[j].text), idx: j });
223
289
  j++;
224
290
  }
@@ -456,24 +522,45 @@ export function classifyNextAction(text, opts = {}) {
456
522
  };
457
523
  }
458
524
 
459
- /** Find "next action" fields in a carrier. @returns {{fields:number, items:{text:string,where:string}[]}} */
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
+ */
460
538
  export function extractNextActions(md) {
461
539
  const lines = parseDoc(md);
462
540
  const items = [];
541
+ const ragged = [];
463
542
  let fields = 0;
464
543
  const tableIdx = new Set();
465
544
  const headerIdx = new Set();
466
545
  const tables = findTables(lines);
467
546
 
468
547
  for (const t of tables) {
469
- const col = t.header.findIndex((c) => VOCAB.nextAction.test(c));
470
- const hasCol = col >= 0;
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;
471
551
  tableIdx.add(t.headerIdx); tableIdx.add(t.headerIdx + 1); headerIdx.add(t.headerIdx);
472
552
  t.rows.forEach((r) => tableIdx.add(r.idx));
473
553
  if (hasCol) {
474
554
  for (const r of t.rows) {
475
- fields++;
476
- items.push({ text: r.cells[col] || '', where: `table column "${t.header[col]}" (line ${r.idx + 1})` });
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
+ }
477
564
  }
478
565
  }
479
566
  // rows inside a nextAction-class section are handled by the section walk below
@@ -481,12 +568,16 @@ export function extractNextActions(md) {
481
568
  }
482
569
 
483
570
  // inline labelled "**Next action**: ..." (outside tables that have their own column)
484
- const labelRe = /(?:\*\*|__)?(?:next[\s-]?(?:action|step)s?|下一步|下一動)(?:\*\*|__)?\s*[::]\s*(.+)$/i;
571
+ const labelRe = new RegExp(`(?:\\*\\*|__)?(?:${VOCAB.nextAction.source})(?:\\*\\*|__)?\\s*[::]\\s*(.+)$`, 'i');
485
572
  const colTableRows = new Set();
486
- for (const t of tables) if (t.hasNextActionColumn) t.rows.forEach((r) => colTableRows.add(r.idx));
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); });
487
577
  lines.forEach((l, idx) => {
488
578
  if (l.front || l.fence || l.inFence || l.heading || l.cls === 'nextAction' || colTableRows.has(idx)) return;
489
- const m = labelRe.exec(l.text);
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);
490
581
  if (m && !/^\s*$/.test(m[1])) { fields++; items.push({ text: m[1], where: `label (line ${idx + 1})` }); }
491
582
  });
492
583
 
@@ -522,14 +613,15 @@ export function extractNextActions(md) {
522
613
  if (sec.length === 0) items.push({ text: '', where: `section "${owner.heading}" (empty)` });
523
614
  for (const s of sec) items.push({ text: s, where: `section "${owner.heading}"` });
524
615
  }
525
- return { fields, items };
616
+ return { fields, items, ragged };
526
617
  }
527
618
 
528
619
  export function checkNextActions(carriers, opts = {}) {
529
- const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [] };
620
+ const res = { walked: 0, empty: 0, counts: { 'named-resolved': 0, 'named-unresolved': 0, unnamed: 0 }, violations: [], detail: [], noFieldCarriers: [], undecidable: [] };
530
621
  for (const c of carriers) {
531
- const { fields, items } = extractNextActions(c.content);
532
- if (fields === 0) { res.noFieldCarriers.push(c.path); continue; }
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; }
533
625
  for (const it of items) {
534
626
  const text = it.text.replace(/\*\*|__/g, '').trim();
535
627
  if (EMPTY_FIELD.test(text)) { res.empty++; continue; }
@@ -557,6 +649,11 @@ export function runSelfTest() {
557
649
  expect('D2 clean: identifier', classifyNextAction('finish XSPEC-436 R1', {}).status !== 'unnamed');
558
650
  expect('D2 clean: test name', classifyNextAction('make the test "a next action that names nothing is reported" pass', {}).status !== 'unnamed');
559
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
+
560
657
  // OWT-018
561
658
  const spec = (ac, rev) => `# S\n\n## Acceptance criteria\n\n${ac}\n\n## Revisions\n\n${rev}\n`;
562
659
  const table = (rows) => `| Change | Approver | Reason |\n|---|---|---|\n${rows}`;
@@ -639,12 +736,16 @@ export function main(argv, io = { log: console.log, err: console.error }) {
639
736
  if (!files.length) { say('[owt] next-action: no files given'); return finish(2); }
640
737
  const res = checkNextActions(files.map(readCarrier), { root, idPattern });
641
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`);
642
- say(`[owt] named-resolved=${res.counts['named-resolved']} named-unresolved=${res.counts['named-unresolved']} unnamed=${res.counts.unnamed}`);
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}`);
643
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(', ') : ''}`);
644
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`);
645
743
  say(`[owt] ${COVERAGE_NOTE}`);
646
744
  say(`[owt] ${UNCALIBRATED_NOTE}`);
647
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); }
648
749
  return finish(res.violations.length ? 1 : 0);
649
750
  }
650
751
  if (cmd === 'revision') {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "version": "6.14.0-beta.2",
3
+ "version": "6.14.0-beta.3",
4
4
  "lastUpdated": "2026-05-13",
5
5
  "description": "Standards registry for universal-dev-standards with integrated skills and AI-optimized formats",
6
6
  "formats": {
@@ -58,14 +58,14 @@
58
58
  "standards": {
59
59
  "name": "universal-dev-standards",
60
60
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
61
- "version": "6.14.0-beta.2"
61
+ "version": "6.14.0-beta.3"
62
62
  },
63
63
  "skills": {
64
64
  "name": "universal-dev-standards",
65
65
  "url": "https://github.com/AsiaOstrich/universal-dev-standards",
66
66
  "localPath": "skills",
67
67
  "rawUrl": "https://raw.githubusercontent.com/AsiaOstrich/universal-dev-standards/main/skills",
68
- "version": "6.14.0-beta.2",
68
+ "version": "6.14.0-beta.3",
69
69
  "note": "Skills are now included in the main repository under skills/"
70
70
  }
71
71
  },
@@ -2295,7 +2295,7 @@
2295
2295
  "id": "license-compliance",
2296
2296
  "name": "License Compliance Standards",
2297
2297
  "nameZh": "授權合規標準",
2298
- "version": "6.14.0-beta.2",
2298
+ "version": "6.14.0-beta.3",
2299
2299
  "source": {
2300
2300
  "human": "core/license-compliance.md",
2301
2301
  "ai": "ai/standards/license-compliance.ai.yaml"
@@ -2307,7 +2307,7 @@
2307
2307
  "id": "verification-oracle",
2308
2308
  "name": "Verification Oracle Standards",
2309
2309
  "nameZh": "驗證 Oracle 標準",
2310
- "version": "6.14.0-beta.2",
2310
+ "version": "6.14.0-beta.3",
2311
2311
  "source": {
2312
2312
  "human": "core/verification-oracle.md",
2313
2313
  "ai": "ai/standards/verification-oracle.ai.yaml"
@@ -2319,7 +2319,7 @@
2319
2319
  "id": "model-provenance",
2320
2320
  "name": "Model Provenance Policy Standards",
2321
2321
  "nameZh": "模型來源政策標準",
2322
- "version": "6.14.0-beta.2",
2322
+ "version": "6.14.0-beta.3",
2323
2323
  "source": {
2324
2324
  "human": "core/model-provenance.md",
2325
2325
  "ai": "ai/standards/model-provenance.ai.yaml"
@@ -2331,7 +2331,7 @@
2331
2331
  "id": "resource-cost-boundary",
2332
2332
  "name": "Resource / Cost Boundary Declaration Standards",
2333
2333
  "nameZh": "資源/成本邊界宣告標準",
2334
- "version": "6.14.0-beta.2",
2334
+ "version": "6.14.0-beta.3",
2335
2335
  "source": {
2336
2336
  "human": "core/resource-cost-boundary.md",
2337
2337
  "ai": "ai/standards/resource-cost-boundary.ai.yaml"