@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0

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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -0,0 +1,698 @@
1
+ "use strict";
2
+ /**
3
+ * Markdown Table Model — canonical GFM table parsing + schema registry seam
4
+ * (ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out,
5
+ * no I/O. Compiled by tsc to gsd-core/bin/lib/markdown-table.cjs.
6
+ *
7
+ * NOTE: the `Result<T>` here is the ADR-2143 §5 parse-result shape {ok,value|reason},
8
+ * now defined once in `./write-set.cjs` (the shared fail-loud + write-set seam) and
9
+ * re-exported here so existing importers of `Result` from this module keep working
10
+ * unchanged — deliberately distinct from command-routing-hub's dispatch `Result`
11
+ * {ok,data|kind}; the two never mix (different modules).
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.TABLE_SCHEMAS = void 0;
15
+ exports.matchTableSchema = matchTableSchema;
16
+ exports.splitTableRow = splitTableRow;
17
+ exports.isDelimiterRow = isDelimiterRow;
18
+ exports.parseMarkdownTable = parseMarkdownTable;
19
+ exports.updateTableCell = updateTableCell;
20
+ exports.deleteTableRow = deleteTableRow;
21
+ exports.insertTableRow = insertTableRow;
22
+ exports.findTableBySchema = findTableBySchema;
23
+ exports.findTableWithColumns = findTableWithColumns;
24
+ exports.escapeCell = escapeCell;
25
+ exports.appendQuickTaskRow = appendQuickTaskRow;
26
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
27
+ // ─── Schema registry ──────────────────────────────────────────────────────────
28
+ /**
29
+ * Canonical column-header shapes for every GFM table GSD parses or generates.
30
+ * Each entry in `TABLE_SCHEMAS[id]` is one accepted variant (exact column names,
31
+ * in order); `matchTableSchema` resolves a parsed header back to `{id, label}`.
32
+ *
33
+ * This registry is the single source of truth — a parity test
34
+ * (tests/markdown-table.test.cjs) asserts every variant's header appears
35
+ * verbatim in the template/workflow file that generates it, so the registry
36
+ * and the templates can never silently drift (ADR-2143 §3 Generative-Fix-
37
+ * Divergence guard).
38
+ */
39
+ exports.TABLE_SCHEMAS = {
40
+ RoadmapProgress: [
41
+ { label: 'flat', columns: ['Phase', 'Plans Complete', 'Status', 'Completed'] },
42
+ {
43
+ label: 'milestone-grouped',
44
+ columns: ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed'],
45
+ },
46
+ ],
47
+ RequirementsTraceability: [
48
+ { label: 'default', columns: ['Requirement', 'Phase', 'Status'] },
49
+ ],
50
+ QuickTasks: [
51
+ { label: 'no-status', columns: ['#', 'Description', 'Date', 'Commit', 'Directory'] },
52
+ {
53
+ label: 'with-status',
54
+ columns: ['#', 'Description', 'Date', 'Commit', 'Status', 'Directory'],
55
+ },
56
+ ],
57
+ Security: [
58
+ { label: 'trust-boundaries', columns: ['Boundary', 'Description', 'Data Crossing'] },
59
+ {
60
+ label: 'threat-register',
61
+ columns: [
62
+ 'Threat ID',
63
+ 'Category',
64
+ 'Component',
65
+ 'Severity',
66
+ 'Disposition',
67
+ 'Mitigation',
68
+ 'Status',
69
+ ],
70
+ },
71
+ {
72
+ label: 'accepted-risks',
73
+ columns: ['Risk ID', 'Threat Ref', 'Rationale', 'Accepted By', 'Date'],
74
+ },
75
+ {
76
+ label: 'audit-trail',
77
+ columns: ['Audit Date', 'Threats Total', 'Closed', 'Open', 'Run By'],
78
+ },
79
+ ],
80
+ };
81
+ /**
82
+ * Resolve a parsed table's header columns to the canonical schema it matches
83
+ * (exact column names, same length, same order), else `null`.
84
+ */
85
+ function matchTableSchema(columns) {
86
+ for (const [id, variants] of Object.entries(exports.TABLE_SCHEMAS)) {
87
+ for (const variant of variants) {
88
+ if (variant.columns.length === columns.length
89
+ && variant.columns.every((col, idx) => col === columns[idx])) {
90
+ return { id, label: variant.label };
91
+ }
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ // ─── Parsing ──────────────────────────────────────────────────────────────────
97
+ /**
98
+ * Split one GFM table row line into trimmed cell strings.
99
+ * Strips one leading and one trailing `|`, splits on unescaped `|`, trims
100
+ * each cell, and unescapes `\\` back to `\` and `\|` back to `|` (the exact
101
+ * reverse of `escapeCell`'s `\`->`\\` then `|`->`\|` order below), so cell
102
+ * values round-trip exactly — including literal backslashes.
103
+ */
104
+ function splitTableRow(line) {
105
+ let stripped = line.trim();
106
+ if (stripped.startsWith('|'))
107
+ stripped = stripped.slice(1);
108
+ if (stripped.endsWith('|'))
109
+ stripped = stripped.slice(0, -1);
110
+ return stripped.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\([\\|])/g, '$1'));
111
+ }
112
+ /**
113
+ * True when every delimiter cell matches GFM's `:?-{1,}:?` shape (spaces
114
+ * removed). Exported (alongside `splitTableRow`) so callers that need their
115
+ * own ragged-tolerant header/delimiter detection — e.g. state.cts's
116
+ * `cmdStateRecordMetric` row-append, which must recognize an existing table
117
+ * without requiring every DATA row to also parse cleanly (#2245 Blocker 2) —
118
+ * reuse the exact same header/delimiter-shape check `parseMarkdownTable` uses,
119
+ * instead of re-deriving it and risking divergence.
120
+ */
121
+ function isDelimiterRow(cells) {
122
+ return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, '')));
123
+ }
124
+ /**
125
+ * Parse the FIRST GFM pipe table found in `sectionText`.
126
+ *
127
+ * Defensive by design: never throws — every malformed shape (no table,
128
+ * missing/misaligned delimiter row, ragged data row) returns a typed
129
+ * `{ok:false, reason}` instead of silently coercing or dropping data
130
+ * (ADR-2143 §3 — ragged rows are errors, not silent).
131
+ *
132
+ * Scope note: GSD planning tables (STATE.md/ROADMAP.md/requirements.md/
133
+ * SECURITY.md) are always fully-piped (leading + trailing `|` on every row)
134
+ * and non-indented — this parser targets THAT shape, not arbitrary
135
+ * CommonMark (which also allows non-piped rows and up to 3 leading spaces).
136
+ */
137
+ function parseMarkdownTable(sectionText) {
138
+ if (typeof sectionText !== 'string' || sectionText.trim() === '') {
139
+ return { ok: false, reason: 'empty or non-string input' };
140
+ }
141
+ const lines = sectionText.split(/\r?\n/);
142
+ let headerIdx = -1;
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const trimmed = lines[i].trim();
145
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
146
+ headerIdx = i;
147
+ break;
148
+ }
149
+ }
150
+ if (headerIdx === -1) {
151
+ return { ok: false, reason: 'no table found' };
152
+ }
153
+ const columns = splitTableRow(lines[headerIdx]);
154
+ const delimiterLine = lines[headerIdx + 1];
155
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
156
+ return { ok: false, reason: 'missing delimiter row' };
157
+ }
158
+ const delimiterCells = splitTableRow(delimiterLine);
159
+ if (!isDelimiterRow(delimiterCells)) {
160
+ return { ok: false, reason: 'missing delimiter row' };
161
+ }
162
+ if (delimiterCells.length !== columns.length) {
163
+ return { ok: false, reason: 'delimiter/header column count mismatch' };
164
+ }
165
+ const rows = [];
166
+ let rowNum = 0;
167
+ for (let i = headerIdx + 2; i < lines.length; i++) {
168
+ const trimmed = lines[i].trim();
169
+ if (!trimmed.startsWith('|'))
170
+ break;
171
+ rowNum += 1;
172
+ const cells = splitTableRow(lines[i]);
173
+ if (cells.length !== columns.length) {
174
+ return {
175
+ ok: false,
176
+ reason: `row ${rowNum} has ${cells.length} cells, expected ${columns.length}`,
177
+ };
178
+ }
179
+ const row = {};
180
+ columns.forEach((col, idx) => {
181
+ row[col] = cells[idx];
182
+ });
183
+ rows.push(row);
184
+ }
185
+ return { ok: true, value: { columns, rows } };
186
+ }
187
+ /**
188
+ * Split `text` into lines exactly like `.split(/\r?\n/)` (bare `\r` is NOT a
189
+ * line break, matching `parseMarkdownTable`), tracking each line's absolute
190
+ * start offset in `text` so cell ranges can be computed relative to the
191
+ * ORIGINAL string, not the trimmed/relative line.
192
+ */
193
+ function splitLinesWithOffsets(text) {
194
+ const result = [];
195
+ let start = 0;
196
+ const re = /\r\n|\n/g;
197
+ let m;
198
+ while ((m = re.exec(text)) !== null) {
199
+ result.push({ line: text.slice(start, m.index), start });
200
+ start = m.index + m[0].length;
201
+ }
202
+ result.push({ line: text.slice(start), start });
203
+ return result;
204
+ }
205
+ /**
206
+ * Split one GFM table row LINE into raw cell ranges, absolute to the original
207
+ * `text` the line was sliced from (`lineStart` = that line's start offset).
208
+ * Mirrors `splitTableRow`'s trim + strip-leading/trailing-pipe + unescaped-pipe
209
+ * split EXACTLY, but returns character ranges instead of trimmed values, so a
210
+ * caller can splice a replacement into the original string byte-for-byte.
211
+ */
212
+ function splitTableRowRanges(line, lineStart) {
213
+ const leftTrim = /^\s*/.exec(line)[0].length;
214
+ const rightTrim = /\s*$/.exec(line)[0].length;
215
+ let stripped = line.slice(leftTrim, line.length - rightTrim);
216
+ let strippedStart = lineStart + leftTrim;
217
+ if (stripped.startsWith('|')) {
218
+ stripped = stripped.slice(1);
219
+ strippedStart += 1;
220
+ }
221
+ if (stripped.endsWith('|')) {
222
+ stripped = stripped.slice(0, -1);
223
+ }
224
+ const cells = [];
225
+ const re = /(?<!\\)\|/g;
226
+ let cellStartRel = 0;
227
+ let m;
228
+ while ((m = re.exec(stripped)) !== null) {
229
+ cells.push({ start: strippedStart + cellStartRel, end: strippedStart + m.index });
230
+ cellStartRel = m.index + 1;
231
+ }
232
+ cells.push({ start: strippedStart + cellStartRel, end: strippedStart + stripped.length });
233
+ return cells;
234
+ }
235
+ /** Unescape one raw (still-`\`-escaped) cell/column-name span exactly like
236
+ * `splitTableRow`: trim, then reverse `\\` -> `\` and `\|` -> `|`. */
237
+ function unescapeCellText(raw) {
238
+ return raw.trim().replace(/\\([\\|])/g, '$1');
239
+ }
240
+ /**
241
+ * Surgically edit ONE table cell while preserving the table's exact byte
242
+ * formatting (ADR-2143 §7). Locates the first GFM table's header + delimiter
243
+ * row in `tableText` (own header/delimiter detection — deliberately does NOT
244
+ * gate on `parseMarkdownTable(tableText).ok`), finds the first DATA row where
245
+ * `match(row, index)` is true, and replaces ONLY that row's `column` cell's
246
+ * raw inner text (the span between its two delimiting `|` characters) — every
247
+ * other byte of `tableText` (other cells, padding, alignment, EOL style) is
248
+ * left BYTE-IDENTICAL. This is deliberately NOT a parse-then-render: a
249
+ * render pass would reformat padding/alignment/dates that mutation sites
250
+ * (e.g. `status.padEnd(11)`) depend on staying pinned.
251
+ *
252
+ * Ragged-tolerant by design (#2245 review Fix 2): each data row's
253
+ * `{colName:cellText}` record is built ONLY from the columns physically
254
+ * present in THAT row — a short row simply omits its trailing column names;
255
+ * an over-long row's extra trailing cells are ignored — so `match` is called
256
+ * with whatever partial record a ragged row yields. A single sibling row
257
+ * whose cell count doesn't match the header must never silently no-op the
258
+ * whole write (the prior `parseMarkdownTable(tableText).ok` gate failed the
259
+ * ENTIRE table — including an otherwise-well-formed target row — the moment
260
+ * ANY other row in the same table was ragged). A row that matches on content
261
+ * but is too short to physically contain `column` has no cell to splice
262
+ * into, so it cannot be selected; the scan continues past it.
263
+ *
264
+ * `newValue` is spliced in VERBATIM as the new raw cell span — it is the
265
+ * caller's responsibility to supply the fully-formatted text (including any
266
+ * leading/trailing padding needed to reproduce the table's existing column
267
+ * alignment, and to escape a literal `|` or `\` the value might contain via
268
+ * the same convention `splitTableRow`/`escapeCell` use elsewhere in this
269
+ * module). When `newValue` is a function, it receives the CURRENT (trimmed,
270
+ * unescaped) cell value — the same value that appears in `match`'s `row`
271
+ * argument — and must return the full literal replacement text. Returning
272
+ * the current value unchanged is a supported no-op-probe pattern for callers
273
+ * that need to know whether (and to what current value) a row matched
274
+ * without necessarily writing a new value.
275
+ *
276
+ * Returns `{ok:false, reason}` only for a genuinely absent/malformed table
277
+ * (no header line, or no valid delimiter row immediately below it), an
278
+ * unknown `column`, or zero rows satisfying `match` while physically
279
+ * containing `column` — never for a ragged sibling row.
280
+ */
281
+ function updateTableCell(tableText, match, column, newValue) {
282
+ const lines = splitLinesWithOffsets(tableText);
283
+ let headerIdx = -1;
284
+ for (let i = 0; i < lines.length; i++) {
285
+ const trimmed = lines[i].line.trim();
286
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
287
+ headerIdx = i;
288
+ break;
289
+ }
290
+ }
291
+ if (headerIdx === -1) {
292
+ return { ok: false, reason: 'no table found' };
293
+ }
294
+ const delimiterLine = lines[headerIdx + 1]?.line;
295
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
296
+ return { ok: false, reason: 'missing delimiter row' };
297
+ }
298
+ const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
299
+ const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
300
+ const delimiterCells = splitTableRow(delimiterLine);
301
+ if (!isDelimiterRow(delimiterCells)) {
302
+ return { ok: false, reason: 'missing delimiter row' };
303
+ }
304
+ if (delimiterCells.length !== columns.length) {
305
+ return { ok: false, reason: 'delimiter/header column count mismatch' };
306
+ }
307
+ if (!columns.includes(column)) {
308
+ return { ok: false, reason: `unknown column: ${column}` };
309
+ }
310
+ const targetColIdx = columns.indexOf(column);
311
+ let selectedRange;
312
+ let dataRowIndex = 0;
313
+ for (let i = headerIdx + 2; i < lines.length; i++) {
314
+ const trimmed = lines[i].line.trim();
315
+ if (!trimmed.startsWith('|'))
316
+ break;
317
+ const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start);
318
+ const record = {};
319
+ const presentCount = Math.min(cellRanges.length, columns.length);
320
+ for (let c = 0; c < presentCount; c++) {
321
+ record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end));
322
+ }
323
+ if (targetColIdx < cellRanges.length && match(record, dataRowIndex)) {
324
+ selectedRange = cellRanges[targetColIdx];
325
+ break;
326
+ }
327
+ dataRowIndex += 1;
328
+ }
329
+ if (!selectedRange) {
330
+ return { ok: false, reason: 'no matching row' };
331
+ }
332
+ const currentValue = unescapeCellText(tableText.slice(selectedRange.start, selectedRange.end));
333
+ const replacement = typeof newValue === 'function' ? newValue(currentValue) : newValue;
334
+ // True no-op guard: a function `newValue` that returns `current` UNCHANGED
335
+ // (the documented no-op-probe pattern) must leave `tableText` genuinely
336
+ // byte-identical, padding included. `current` is already trimmed/unescaped,
337
+ // so naively splicing it back in would strip the raw cell's original
338
+ // leading/trailing padding — this returns the ORIGINAL text untouched
339
+ // instead whenever the callback's answer is "no change".
340
+ if (typeof newValue === 'function' && replacement === currentValue) {
341
+ return { ok: true, value: tableText };
342
+ }
343
+ return {
344
+ ok: true,
345
+ value: tableText.slice(0, selectedRange.start) + replacement + tableText.slice(selectedRange.end),
346
+ };
347
+ }
348
+ // ─── deleteTableRow (ADR-2143 §7 row-removal sibling of updateTableCell) ─────
349
+ /**
350
+ * Surgically delete ONE whole table row while preserving every other byte of
351
+ * `tableText` (ADR-2143 §7, row-removal sibling of `updateTableCell`). Locates
352
+ * the first GFM table's header + delimiter row in `tableText` using the exact
353
+ * same self-contained, ragged-tolerant scan `updateTableCell` uses (own
354
+ * header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`),
355
+ * finds the FIRST data row where `match(row, index)` is true, and splices out
356
+ * that row's entire LINE — including its trailing newline (`\r\n` or `\n`,
357
+ * whichever terminates it) — from `tableText`. Every other byte (header,
358
+ * delimiter, other rows, surrounding prose before/after the table, EOL style)
359
+ * is left BYTE-IDENTICAL.
360
+ *
361
+ * Ragged-tolerant by design, mirroring `updateTableCell` (#2245 review Fix 2):
362
+ * each data row's `{colName:cellText}` record is built ONLY from the columns
363
+ * physically present in THAT row — a sibling row whose cell count doesn't
364
+ * match the header must never abort the whole scan; `match` is simply called
365
+ * with whatever partial record a ragged row yields.
366
+ *
367
+ * Returns `{ok:false, reason}` for a genuinely absent/malformed table (no
368
+ * header line, or no valid delimiter row immediately below it) or zero rows
369
+ * satisfying `match` — never for a ragged sibling row.
370
+ */
371
+ function deleteTableRow(tableText, match) {
372
+ const lines = splitLinesWithOffsets(tableText);
373
+ let headerIdx = -1;
374
+ for (let i = 0; i < lines.length; i++) {
375
+ const trimmed = lines[i].line.trim();
376
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
377
+ headerIdx = i;
378
+ break;
379
+ }
380
+ }
381
+ if (headerIdx === -1) {
382
+ return { ok: false, reason: 'no table found' };
383
+ }
384
+ const delimiterLine = lines[headerIdx + 1]?.line;
385
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
386
+ return { ok: false, reason: 'missing delimiter row' };
387
+ }
388
+ const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
389
+ const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
390
+ const delimiterCells = splitTableRow(delimiterLine);
391
+ if (!isDelimiterRow(delimiterCells)) {
392
+ return { ok: false, reason: 'missing delimiter row' };
393
+ }
394
+ if (delimiterCells.length !== columns.length) {
395
+ return { ok: false, reason: 'delimiter/header column count mismatch' };
396
+ }
397
+ let selectedLineIdx = -1;
398
+ let dataRowIndex = 0;
399
+ for (let i = headerIdx + 2; i < lines.length; i++) {
400
+ const trimmed = lines[i].line.trim();
401
+ if (!trimmed.startsWith('|'))
402
+ break;
403
+ const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start);
404
+ const record = {};
405
+ const presentCount = Math.min(cellRanges.length, columns.length);
406
+ for (let c = 0; c < presentCount; c++) {
407
+ record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end));
408
+ }
409
+ if (match(record, dataRowIndex)) {
410
+ selectedLineIdx = i;
411
+ break;
412
+ }
413
+ dataRowIndex += 1;
414
+ }
415
+ if (selectedLineIdx === -1) {
416
+ return { ok: false, reason: 'no matching row' };
417
+ }
418
+ // Splice out the whole LINE including its trailing EOL: the next line's
419
+ // recorded `start` offset is already positioned right after whatever EOL
420
+ // (`\r\n` or `\n`) terminated the selected line (see `splitLinesWithOffsets`
421
+ // above) — when the selected row is the LAST line in `tableText` (no
422
+ // trailing EOL to preserve), fall back to the end of the string.
423
+ let rowStart = lines[selectedLineIdx].start;
424
+ let rowEnd;
425
+ if (selectedLineIdx + 1 < lines.length) {
426
+ rowEnd = lines[selectedLineIdx + 1].start;
427
+ }
428
+ else {
429
+ // The selected row is the LAST line and has no trailing EOL: deleting from
430
+ // its `start` to end-of-string would strand the EOL that terminated the
431
+ // PREVIOUS line as a dangling newline. Back `rowStart` up over that
432
+ // preceding `\n` (and its `\r`, if any) so the table ends cleanly after the
433
+ // new last row.
434
+ rowEnd = tableText.length;
435
+ if (rowStart > 0 && tableText[rowStart - 1] === '\n') {
436
+ rowStart -= 1;
437
+ if (rowStart > 0 && tableText[rowStart - 1] === '\r')
438
+ rowStart -= 1;
439
+ }
440
+ }
441
+ return {
442
+ ok: true,
443
+ value: tableText.slice(0, rowStart) + tableText.slice(rowEnd),
444
+ };
445
+ }
446
+ // ─── insertTableRow (ADR-2143 §7 row-insertion sibling of updateTableCell) ───
447
+ /**
448
+ * Insert ONE new row into a GFM table while preserving every other byte of
449
+ * `tableText` (ADR-2143 §7, row-insertion sibling of `updateTableCell` /
450
+ * `deleteTableRow`). Locates the first table's header + delimiter row using
451
+ * the exact same self-contained, ragged-tolerant scan the other two use (own
452
+ * header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`),
453
+ * builds the new row's cells in the table's ACTUAL header order — each column
454
+ * name is passed through `valueFor(column)`; a column for which `valueFor`
455
+ * returns `undefined` gets `fallback` (default `'-'`) — and splices it in
456
+ * immediately after the table's LAST existing data row (or immediately after
457
+ * the delimiter row when the table has zero data rows).
458
+ *
459
+ * Name-addressed and header-order-agnostic by construction: unlike a
460
+ * hardcoded positional literal (`| ${a} | ${b} | - | - |`), this never
461
+ * silently no-ops or mis-maps a value onto the wrong column when the header
462
+ * is reordered or a superset of the columns `valueFor` knows about (#2245
463
+ * audit sibling finding — the bug this helper replaces).
464
+ *
465
+ * EOL-preserving: the new row reuses whatever exact EOL bytes (`\r\n` or
466
+ * `\n`) already terminate the line it's inserted after, so a CRLF document
467
+ * stays CRLF and an LF document stays LF — never guessed or hardcoded. When
468
+ * the insertion point is at the very end of `tableText` with no following
469
+ * line (the table's last row has no trailing EOL of its own), the existing
470
+ * last row is terminated with the header/delimiter boundary's own EOL (so it
471
+ * gains a terminator, since it is no longer the last line) and the new row
472
+ * becomes the new EOL-less tail — mirroring `tableText`'s own convention of
473
+ * not forcing a trailing newline that wasn't already there.
474
+ *
475
+ * Escaping (F4 #2245 review): unlike `updateTableCell`, whose `newValue` is
476
+ * spliced in VERBATIM (caller-must-escape — see its doc comment above), every
477
+ * value returned by `valueFor` (and `fallback`) IS escaped internally here via
478
+ * `escapeCell` before being joined into the new row, exactly like
479
+ * `appendQuickTaskRow` below — a caller-supplied name containing a literal
480
+ * `|` or `\` cannot silently split the new row into extra columns. Callers do
481
+ * NOT need to pre-escape their values.
482
+ *
483
+ * Returns `{ok:false, reason}` only for a genuinely absent/malformed table
484
+ * (no header line, or no valid delimiter row immediately below it) — never
485
+ * for a ragged data row (mirrors `updateTableCell`/`deleteTableRow`).
486
+ */
487
+ function insertTableRow(tableText, valueFor, fallback = '-') {
488
+ const lines = splitLinesWithOffsets(tableText);
489
+ let headerIdx = -1;
490
+ for (let i = 0; i < lines.length; i++) {
491
+ const trimmed = lines[i].line.trim();
492
+ if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
493
+ headerIdx = i;
494
+ break;
495
+ }
496
+ }
497
+ if (headerIdx === -1) {
498
+ return { ok: false, reason: 'no table found' };
499
+ }
500
+ const delimiterLine = lines[headerIdx + 1]?.line;
501
+ if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
502
+ return { ok: false, reason: 'missing delimiter row' };
503
+ }
504
+ const delimiterCells = splitTableRow(delimiterLine);
505
+ if (!isDelimiterRow(delimiterCells)) {
506
+ return { ok: false, reason: 'missing delimiter row' };
507
+ }
508
+ const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
509
+ const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
510
+ // Header -> delimiter EOL, reused as the fallback terminator for the "insert
511
+ // point is at the absolute end of tableText" edge case below.
512
+ const headerToDelimiterEol = tableText.slice(lines[headerIdx].start + lines[headerIdx].line.length, lines[headerIdx + 1].start) || '\n';
513
+ let lastLineIdx = headerIdx + 1; // delimiter row, when the table has zero data rows
514
+ for (let i = headerIdx + 2; i < lines.length; i++) {
515
+ if (!lines[i].line.trim().startsWith('|'))
516
+ break;
517
+ lastLineIdx = i;
518
+ }
519
+ const newRow = `| ${columns.map((col) => escapeCell(valueFor(col) ?? fallback)).join(' | ')} |`;
520
+ if (lastLineIdx + 1 < lines.length) {
521
+ // A following line exists — insert the new row, reusing the EXACT EOL
522
+ // that already terminates the current last table line, so every other
523
+ // byte (including everything after the table) stays untouched.
524
+ const insertAt = lines[lastLineIdx + 1].start;
525
+ const eol = tableText.slice(lines[lastLineIdx].start + lines[lastLineIdx].line.length, insertAt);
526
+ return { ok: true, value: tableText.slice(0, insertAt) + newRow + eol + tableText.slice(insertAt) };
527
+ }
528
+ // The table's last row is also the last line of `tableText` (no trailing
529
+ // EOL). Terminate it now — it needs one, since it is no longer last — and
530
+ // append the new row as the new EOL-less tail.
531
+ return { ok: true, value: tableText + headerToDelimiterEol + newRow };
532
+ }
533
+ /**
534
+ * Find the first table in `text` whose header matches `TABLE_SCHEMAS[schemaId]`,
535
+ * scanning the WHOLE document (not just a named section). Returns `null` when
536
+ * no table with that schema is found.
537
+ *
538
+ * Fixes the regression where callers first located a named heading (e.g.
539
+ * `## Progress`) via `collectSection` and only then parsed a table inside it —
540
+ * a schema-matching table that lives under a differently-named heading (or no
541
+ * heading at all), or that isn't the first table in the document, was
542
+ * invisible to that approach. Scanning the whole document by schema restores
543
+ * the old "find the progress table anywhere" behaviour while staying
544
+ * seam-based (ADR-2143).
545
+ */
546
+ function findTableBySchema(text, schemaId) {
547
+ if (typeof text !== 'string')
548
+ return null;
549
+ const lines = text.split(/\r?\n/);
550
+ for (let i = 0; i < lines.length; i++) {
551
+ const t = lines[i].trim();
552
+ if (!t.startsWith('|') || t.indexOf('|', 1) === -1)
553
+ continue;
554
+ const cols = splitTableRow(lines[i]);
555
+ const m = matchTableSchema(cols);
556
+ if (m && m.id === schemaId) {
557
+ const parsed = parseMarkdownTable(lines.slice(i).join('\n'));
558
+ if (parsed.ok)
559
+ return parsed.value;
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+ /**
565
+ * Find the first GFM table in `text` whose header contains ALL of `required`
566
+ * column names (order-independent; extra/injected columns allowed). Returns
567
+ * the parsed `MarkdownTable`, or `null` when no table's header is a superset
568
+ * of `required`.
569
+ *
570
+ * Column-NAME/order/count-invariant counterpart to `findTableBySchema` (ADR-2143
571
+ * §3 "addressed by NAME, never ordinal"): where `findTableBySchema` requires an
572
+ * EXACT canonical column set+order registered in `TABLE_SCHEMAS`, this scans
573
+ * for any header that names the required columns, in any order, tolerating
574
+ * extra/unrelated injected columns. Cells remain addressable by column NAME
575
+ * via the returned `MarkdownTable`.
576
+ */
577
+ function findTableWithColumns(text, required) {
578
+ if (typeof text !== 'string')
579
+ return null;
580
+ const lines = text.split(/\r?\n/);
581
+ for (let i = 0; i < lines.length; i++) {
582
+ const t = lines[i].trim();
583
+ if (!t.startsWith('|') || t.indexOf('|', 1) === -1)
584
+ continue;
585
+ const cols = splitTableRow(lines[i]);
586
+ if (required.every((rq) => cols.includes(rq))) {
587
+ const parsed = parseMarkdownTable(lines.slice(i).join('\n'));
588
+ if (parsed.ok)
589
+ return parsed.value;
590
+ }
591
+ }
592
+ return null;
593
+ }
594
+ // ─── Quick Tasks row append (#2133) ────────────────────────────────────────────
595
+ /**
596
+ * Escape one dynamic cell value for insertion into a GFM pipe-table row.
597
+ *
598
+ * Escapes `\` -> `\\` FIRST, then `|` -> `\|` (in that order, so a literal
599
+ * backslash already in the value is never mistaken for part of an escape
600
+ * sequence introduced by this function — CodeQL js/incomplete-sanitization).
601
+ * `splitTableRow` reverses both in the opposite order (`\\` -> `\` then
602
+ * `\|` -> `|`, see line ~114 above), so escaping/unescaping round-trips
603
+ * exactly, including literal backslashes. Newlines are collapsed to a
604
+ * single space — a raw `|` or embedded newline in a cell value (e.g. a task
605
+ * `description`) would otherwise corrupt the table (extra column / a fake
606
+ * extra row) and get rejected by the now-fail-loud `parseMarkdownTable` as a
607
+ * ragged row.
608
+ *
609
+ * Exported (F3/#2245 review) so callers of `updateTableCell` that build a
610
+ * replacement value by transforming the CURRENT (already-unescaped) cell
611
+ * text — e.g. phase.cts's Progress-ordinal renumber, which decrements the
612
+ * leading digit of a `Phase` cell like `3. Parser | Lexer` and splices the
613
+ * rest of the cell text back verbatim — can re-escape that value before
614
+ * returning it from the `newValue` callback, honoring `updateTableCell`'s
615
+ * caller-must-re-escape contract (see its doc comment above) instead of
616
+ * spliceing a raw, unescaped `|` back into the table and silently splitting
617
+ * the cell.
618
+ */
619
+ function escapeCell(value) {
620
+ return String(value)
621
+ .replace(/\r?\n+/g, ' ')
622
+ .replace(/\\/g, '\\\\') // escape the escape char FIRST (CodeQL js/incomplete-sanitization)
623
+ .replace(/\|/g, '\\|')
624
+ .trim();
625
+ }
626
+ /**
627
+ * Append one row to STATE.md's "Quick Tasks Completed" table.
628
+ *
629
+ * Pure, schema-driven replacement for fast.md's inline `awk NF-2` column-count
630
+ * guess (#2133, ADR-2143 §3 schema registry / §7 fail-loud unrecognized-schema
631
+ * guard). Never touches disk, git, or the clock — callers (the `gsd-tools
632
+ * quick-tasks-append` subcommand) compute `date`/`commit` and pass them in.
633
+ *
634
+ * Fails loud (`{ok:false, reason}`, never a silent skip) when:
635
+ * - no "Quick Tasks Completed" heading exists in `stateContent`
636
+ * - the section's body doesn't parse as a GFM table (parseMarkdownTable failure)
637
+ * - the table's header doesn't match a known `TABLE_SCHEMAS.QuickTasks` variant
638
+ * (the old awk arithmetic silently skipped here instead — that silent-skip
639
+ * branch is the bug this replaces).
640
+ *
641
+ * The new row is inserted immediately after the LAST existing table row line
642
+ * (or immediately after the header/delimiter when the table has zero data
643
+ * rows), preserving any surrounding blank lines/trailing content in the section.
644
+ */
645
+ function appendQuickTaskRow(stateContent, fields) {
646
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim()));
647
+ if (!section) {
648
+ return { ok: false, reason: 'no Quick Tasks Completed section' };
649
+ }
650
+ const parsed = parseMarkdownTable(section.body);
651
+ if (!parsed.ok) {
652
+ return { ok: false, reason: `quick-tasks table: ${parsed.reason}` };
653
+ }
654
+ const match = matchTableSchema(parsed.value.columns);
655
+ if (!match || match.id !== 'QuickTasks') {
656
+ return {
657
+ ok: false,
658
+ reason: `unrecognized Quick Tasks schema (columns: ${parsed.value.columns.join(' | ')})`,
659
+ };
660
+ }
661
+ const variant = exports.TABLE_SCHEMAS.QuickTasks.find((v) => v.label === match.label);
662
+ const columns = variant ? variant.columns : parsed.value.columns;
663
+ const rowNumber = parsed.value.rows.length + 1;
664
+ const cellFor = (col) => {
665
+ switch (col) {
666
+ case '#': return escapeCell(String(rowNumber));
667
+ case 'Description': return escapeCell(fields.description);
668
+ case 'Date': return escapeCell(fields.date);
669
+ case 'Commit': return escapeCell(fields.commit);
670
+ case 'Status': return escapeCell(fields.status ?? '—');
671
+ case 'Directory': return escapeCell(fields.directory ?? '—');
672
+ default: return '—';
673
+ }
674
+ };
675
+ const row = `| ${columns.map(cellFor).join(' | ')} |`;
676
+ // Detect the section's EOL BEFORE splitting on /\r?\n/ (which discards it) so
677
+ // the rejoin below preserves CRLF instead of downgrading a CRLF section to
678
+ // mixed EOL (the inserted `row` itself never contains a newline).
679
+ const eol = /\r\n/.test(section.body) ? '\r\n' : '\n';
680
+ const lines = section.body.split(/\r?\n/);
681
+ let lastTableLineIdx = -1;
682
+ for (let i = 0; i < lines.length; i++) {
683
+ if (lines[i].trim().startsWith('|'))
684
+ lastTableLineIdx = i;
685
+ }
686
+ // lastTableLineIdx is always >= 0 here — parseMarkdownTable already
687
+ // confirmed a header + delimiter row exist in this same `section.body`.
688
+ const newLines = [
689
+ ...lines.slice(0, lastTableLineIdx + 1),
690
+ row,
691
+ ...lines.slice(lastTableLineIdx + 1),
692
+ ];
693
+ const newBody = newLines.join(eol);
694
+ const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody);
695
+ return { ok: true, value: { content, row, variant: match.label } };
696
+ }
697
+ // Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
698
+ // Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).