@praxisflux/gates 0.61.0 → 0.62.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.
- package/codebase-to-course/lib/board-mirror.mjs +137 -8
- package/codebase-to-course/lib/gate-runner.mjs +10 -2
- package/codebase-to-course/lib/spec-derive.mjs +5 -2
- package/grounding-wiki/lib/board-mirror.mjs +137 -8
- package/grounding-wiki/lib/gate-runner.mjs +10 -2
- package/grounding-wiki/lib/spec-derive.mjs +5 -2
- package/lib/board-mirror.mjs +137 -8
- package/lib/gate-runner.mjs +10 -2
- package/lib/spec-derive.mjs +5 -2
- package/package.json +1 -1
- package/spec-bridge/gates/bridge.mjs +70 -0
- package/spec-bridge/lib/board-mirror.mjs +137 -8
- package/spec-bridge/lib/gate-runner.mjs +10 -2
- package/spec-bridge/lib/spec-derive.mjs +5 -2
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// "links": [
|
|
11
11
|
// { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
|
|
12
12
|
// "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
|
|
13
|
+
// "labels": ["paused"],
|
|
13
14
|
// "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
|
|
14
15
|
// ]
|
|
15
16
|
// }
|
|
@@ -20,6 +21,15 @@
|
|
|
20
21
|
// logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
|
|
21
22
|
// (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
|
|
22
23
|
//
|
|
24
|
+
// `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
|
|
25
|
+
// link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
|
|
26
|
+
// task with no labels, so an unlabelled task's link stays byte-identical to before this field
|
|
27
|
+
// existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
|
|
28
|
+
// `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
|
|
29
|
+
// assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
|
|
30
|
+
// Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
|
|
31
|
+
// required to emit it for an unlabelled link.
|
|
32
|
+
//
|
|
23
33
|
// `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
|
|
24
34
|
// never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
|
|
25
35
|
// top-level and per-link keys round-trip: read a mirror, write it back, and every key this
|
|
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
|
|
|
60
70
|
import { join, dirname, resolve } from "node:path";
|
|
61
71
|
import { spawnSync } from "node:child_process";
|
|
62
72
|
import { runAsCli } from "./cli.mjs";
|
|
73
|
+
import { TASK_LINE } from "./spec-derive.mjs";
|
|
63
74
|
|
|
64
75
|
/** The only schema this module understands. */
|
|
65
76
|
export const CURRENT_SCHEMA = 1;
|
|
@@ -115,7 +126,7 @@ export function readMirror(root) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
|
|
118
|
-
const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
|
|
129
|
+
const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
|
|
119
130
|
const AC_KEYS = ["index", "checked", "text"];
|
|
120
131
|
|
|
121
132
|
/** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
|
|
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
|
|
|
193
204
|
seenSpecDirs.add(link.specDir);
|
|
194
205
|
}
|
|
195
206
|
|
|
207
|
+
// labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
|
|
208
|
+
// mirror written before this field existed); present-but-malformed is an error naming the
|
|
209
|
+
// offending link.
|
|
210
|
+
if (link.labels !== undefined) {
|
|
211
|
+
if (!Array.isArray(link.labels)) {
|
|
212
|
+
problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
|
|
213
|
+
} else {
|
|
214
|
+
link.labels.forEach((l, k) => {
|
|
215
|
+
if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
if (!req(link.acs, `${where}.acs`, "array")) return;
|
|
197
221
|
let prev = -Infinity;
|
|
198
222
|
link.acs.forEach((ac, j) => {
|
|
@@ -211,6 +235,78 @@ export function validateMirror(mirror) {
|
|
|
211
235
|
return problems;
|
|
212
236
|
}
|
|
213
237
|
|
|
238
|
+
/** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
|
|
239
|
+
* the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
|
|
240
|
+
* `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
|
|
241
|
+
* against, so a mirror-only project — no live board files to fall back on, the case a
|
|
242
|
+
* Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
|
|
243
|
+
* (spec 055 R4/AC #7). */
|
|
244
|
+
export function isPausedLink(link) {
|
|
245
|
+
return Array.isArray(link?.labels) && link.labels.includes("paused");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
|
|
249
|
+
* Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
|
|
250
|
+
* consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
|
|
251
|
+
* else. Full contract: docs/board-verbs.md "R2 — the marked description block".
|
|
252
|
+
*
|
|
253
|
+
* Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
|
|
254
|
+
* or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
|
|
255
|
+
* `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
|
|
256
|
+
* (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
|
|
257
|
+
* matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
|
|
258
|
+
* test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
|
|
259
|
+
* validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
|
|
260
|
+
* violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
|
|
261
|
+
* the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
|
|
262
|
+
* specs/056-jira-provider/findings/phase-1-mcp-surface.md).
|
|
263
|
+
*
|
|
264
|
+
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
|
+
* block renumbers.
|
|
266
|
+
*
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates two normalizations a live Jira write→read round-trip is known
|
|
268
|
+
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line) and two trailing spaces appended
|
|
270
|
+
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
+
* strips trailing whitespace from the captured text). Reuses TASK_LINE from spec-derive.mjs
|
|
272
|
+
* rather than a third checkbox regex. */
|
|
273
|
+
|
|
274
|
+
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
|
+
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
276
|
+
|
|
277
|
+
/** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
|
|
278
|
+
export function renderSpecPhasesBlock(items) {
|
|
279
|
+
const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
|
|
280
|
+
return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse a description (or any string containing at most one block) into
|
|
285
|
+
* [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
|
|
286
|
+
* an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
|
|
287
|
+
* found, naming the counts.
|
|
288
|
+
*/
|
|
289
|
+
export function parseSpecPhasesBlock(text) {
|
|
290
|
+
const s = String(text ?? "");
|
|
291
|
+
const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
|
|
292
|
+
const endCount = s.split(SPEC_PHASES_END).length - 1;
|
|
293
|
+
if (beginCount > 1 || endCount > 1)
|
|
294
|
+
throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
|
|
295
|
+
const start = s.indexOf(SPEC_PHASES_BEGIN);
|
|
296
|
+
const stop = s.indexOf(SPEC_PHASES_END);
|
|
297
|
+
if (start === -1 || stop === -1) return [];
|
|
298
|
+
if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
|
|
299
|
+
const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const line of inner.split("\n")) {
|
|
302
|
+
if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
|
|
303
|
+
const m = line.match(TASK_LINE);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
}
|
|
309
|
+
|
|
214
310
|
/* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
|
|
215
311
|
*
|
|
216
312
|
* Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
|
|
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
|
|
|
219
315
|
|
|
220
316
|
const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
|
|
221
317
|
|
|
318
|
+
/** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
|
|
319
|
+
* form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
|
|
320
|
+
* Returns [] when absent, empty, or unparseable — never throws. */
|
|
321
|
+
function parseFrontmatterLabels(fm) {
|
|
322
|
+
const m = fm.match(/^labels:(.*)$/m);
|
|
323
|
+
if (!m) return [];
|
|
324
|
+
const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
325
|
+
const rest = m[1].trim();
|
|
326
|
+
if (rest) {
|
|
327
|
+
const inline = rest.match(/^\[(.*)\]$/);
|
|
328
|
+
return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
|
|
329
|
+
}
|
|
330
|
+
const items = [];
|
|
331
|
+
for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
|
|
332
|
+
if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
|
|
333
|
+
const li = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
334
|
+
if (!li) break;
|
|
335
|
+
items.push(strip(li[1]));
|
|
336
|
+
}
|
|
337
|
+
return items;
|
|
338
|
+
}
|
|
339
|
+
|
|
222
340
|
/**
|
|
223
|
-
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
|
|
224
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
341
|
+
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
|
|
342
|
+
* for anything else (no marker, unreadable, or not a task file). `acs` is the task's
|
|
343
|
+
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
|
|
344
|
+
* read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
|
|
345
|
+
* R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
|
|
346
|
+
* unlabelled task's return value stays byte-identical to before this field existed (this is
|
|
347
|
+
* load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
|
|
348
|
+
* 052-055 all state it must pass unmodified).
|
|
227
349
|
*/
|
|
228
350
|
export function parseLinkedTask(raw) {
|
|
229
351
|
const text = String(raw ?? "");
|
|
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
|
|
|
240
362
|
if (block)
|
|
241
363
|
for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
|
|
242
364
|
acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
|
|
243
|
-
|
|
365
|
+
const labels = parseFrontmatterLabels(fm[1]);
|
|
366
|
+
const linked = { id, status, specDir: marker[1], acs };
|
|
367
|
+
if (labels.length) linked.labels = labels;
|
|
368
|
+
return linked;
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
|
|
|
259
384
|
}
|
|
260
385
|
|
|
261
386
|
/** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
|
|
262
|
-
* exactly the mirror's per-link fields (drops `file`).
|
|
387
|
+
* exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
|
|
388
|
+
* through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
|
|
389
|
+
* extra logic to keep an unlabelled projection byte-identical to before this field existed. */
|
|
263
390
|
export function projectBacklog(root) {
|
|
264
|
-
return findLinkedTasks(root).map(({ id, status, specDir, acs }) => (
|
|
391
|
+
return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
|
|
392
|
+
labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
|
|
393
|
+
));
|
|
265
394
|
}
|
|
266
395
|
|
|
267
396
|
/** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
|
|
20
20
|
// exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
|
|
21
|
+
//
|
|
22
|
+
// Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
|
|
23
|
+
// $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
|
|
24
|
+
// directory on purpose and must never be silently overridden by an ambient variable. The env
|
|
25
|
+
// var still outranks input.cwd: it's the harness's authoritative statement of the project root,
|
|
26
|
+
// while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
|
|
27
|
+
// session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
|
|
28
|
+
// is byte-identical to before for the real Stop-hook path.
|
|
21
29
|
|
|
22
30
|
/** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
|
|
23
31
|
export function readStdin() {
|
|
@@ -34,9 +42,9 @@ export function readStdin() {
|
|
|
34
42
|
* Pure core: given the parsed hook input and the gates, return { block, message }.
|
|
35
43
|
* Separated from process I/O so it is unit-testable.
|
|
36
44
|
*/
|
|
37
|
-
export function evaluate(input, gates, { cwd =
|
|
45
|
+
export function evaluate(input, gates, { cwd = undefined } = {}) {
|
|
38
46
|
if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
|
|
39
|
-
const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
|
|
47
|
+
const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
|
|
40
48
|
const ctx = {
|
|
41
49
|
sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
42
50
|
input: input || {},
|
|
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
|
61
61
|
// identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
|
|
62
62
|
// per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
|
|
63
63
|
// group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
|
|
64
|
-
// (AC #1: name the phase, the box, and the failing gate).
|
|
65
|
-
|
|
64
|
+
// (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
|
|
65
|
+
// lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
|
|
66
|
+
// third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
|
|
67
|
+
// captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
|
|
68
|
+
export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
|
|
66
69
|
|
|
67
70
|
/** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
|
|
68
71
|
function phaseName(heading) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// "links": [
|
|
11
11
|
// { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
|
|
12
12
|
// "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
|
|
13
|
+
// "labels": ["paused"],
|
|
13
14
|
// "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
|
|
14
15
|
// ]
|
|
15
16
|
// }
|
|
@@ -20,6 +21,15 @@
|
|
|
20
21
|
// logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
|
|
21
22
|
// (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
|
|
22
23
|
//
|
|
24
|
+
// `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
|
|
25
|
+
// link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
|
|
26
|
+
// task with no labels, so an unlabelled task's link stays byte-identical to before this field
|
|
27
|
+
// existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
|
|
28
|
+
// `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
|
|
29
|
+
// assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
|
|
30
|
+
// Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
|
|
31
|
+
// required to emit it for an unlabelled link.
|
|
32
|
+
//
|
|
23
33
|
// `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
|
|
24
34
|
// never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
|
|
25
35
|
// top-level and per-link keys round-trip: read a mirror, write it back, and every key this
|
|
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
|
|
|
60
70
|
import { join, dirname, resolve } from "node:path";
|
|
61
71
|
import { spawnSync } from "node:child_process";
|
|
62
72
|
import { runAsCli } from "./cli.mjs";
|
|
73
|
+
import { TASK_LINE } from "./spec-derive.mjs";
|
|
63
74
|
|
|
64
75
|
/** The only schema this module understands. */
|
|
65
76
|
export const CURRENT_SCHEMA = 1;
|
|
@@ -115,7 +126,7 @@ export function readMirror(root) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
|
|
118
|
-
const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
|
|
129
|
+
const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
|
|
119
130
|
const AC_KEYS = ["index", "checked", "text"];
|
|
120
131
|
|
|
121
132
|
/** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
|
|
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
|
|
|
193
204
|
seenSpecDirs.add(link.specDir);
|
|
194
205
|
}
|
|
195
206
|
|
|
207
|
+
// labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
|
|
208
|
+
// mirror written before this field existed); present-but-malformed is an error naming the
|
|
209
|
+
// offending link.
|
|
210
|
+
if (link.labels !== undefined) {
|
|
211
|
+
if (!Array.isArray(link.labels)) {
|
|
212
|
+
problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
|
|
213
|
+
} else {
|
|
214
|
+
link.labels.forEach((l, k) => {
|
|
215
|
+
if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
if (!req(link.acs, `${where}.acs`, "array")) return;
|
|
197
221
|
let prev = -Infinity;
|
|
198
222
|
link.acs.forEach((ac, j) => {
|
|
@@ -211,6 +235,78 @@ export function validateMirror(mirror) {
|
|
|
211
235
|
return problems;
|
|
212
236
|
}
|
|
213
237
|
|
|
238
|
+
/** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
|
|
239
|
+
* the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
|
|
240
|
+
* `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
|
|
241
|
+
* against, so a mirror-only project — no live board files to fall back on, the case a
|
|
242
|
+
* Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
|
|
243
|
+
* (spec 055 R4/AC #7). */
|
|
244
|
+
export function isPausedLink(link) {
|
|
245
|
+
return Array.isArray(link?.labels) && link.labels.includes("paused");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
|
|
249
|
+
* Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
|
|
250
|
+
* consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
|
|
251
|
+
* else. Full contract: docs/board-verbs.md "R2 — the marked description block".
|
|
252
|
+
*
|
|
253
|
+
* Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
|
|
254
|
+
* or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
|
|
255
|
+
* `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
|
|
256
|
+
* (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
|
|
257
|
+
* matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
|
|
258
|
+
* test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
|
|
259
|
+
* validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
|
|
260
|
+
* violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
|
|
261
|
+
* the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
|
|
262
|
+
* specs/056-jira-provider/findings/phase-1-mcp-surface.md).
|
|
263
|
+
*
|
|
264
|
+
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
|
+
* block renumbers.
|
|
266
|
+
*
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates two normalizations a live Jira write→read round-trip is known
|
|
268
|
+
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line) and two trailing spaces appended
|
|
270
|
+
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
+
* strips trailing whitespace from the captured text). Reuses TASK_LINE from spec-derive.mjs
|
|
272
|
+
* rather than a third checkbox regex. */
|
|
273
|
+
|
|
274
|
+
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
|
+
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
276
|
+
|
|
277
|
+
/** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
|
|
278
|
+
export function renderSpecPhasesBlock(items) {
|
|
279
|
+
const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
|
|
280
|
+
return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse a description (or any string containing at most one block) into
|
|
285
|
+
* [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
|
|
286
|
+
* an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
|
|
287
|
+
* found, naming the counts.
|
|
288
|
+
*/
|
|
289
|
+
export function parseSpecPhasesBlock(text) {
|
|
290
|
+
const s = String(text ?? "");
|
|
291
|
+
const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
|
|
292
|
+
const endCount = s.split(SPEC_PHASES_END).length - 1;
|
|
293
|
+
if (beginCount > 1 || endCount > 1)
|
|
294
|
+
throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
|
|
295
|
+
const start = s.indexOf(SPEC_PHASES_BEGIN);
|
|
296
|
+
const stop = s.indexOf(SPEC_PHASES_END);
|
|
297
|
+
if (start === -1 || stop === -1) return [];
|
|
298
|
+
if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
|
|
299
|
+
const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const line of inner.split("\n")) {
|
|
302
|
+
if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
|
|
303
|
+
const m = line.match(TASK_LINE);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
}
|
|
309
|
+
|
|
214
310
|
/* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
|
|
215
311
|
*
|
|
216
312
|
* Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
|
|
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
|
|
|
219
315
|
|
|
220
316
|
const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
|
|
221
317
|
|
|
318
|
+
/** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
|
|
319
|
+
* form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
|
|
320
|
+
* Returns [] when absent, empty, or unparseable — never throws. */
|
|
321
|
+
function parseFrontmatterLabels(fm) {
|
|
322
|
+
const m = fm.match(/^labels:(.*)$/m);
|
|
323
|
+
if (!m) return [];
|
|
324
|
+
const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
325
|
+
const rest = m[1].trim();
|
|
326
|
+
if (rest) {
|
|
327
|
+
const inline = rest.match(/^\[(.*)\]$/);
|
|
328
|
+
return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
|
|
329
|
+
}
|
|
330
|
+
const items = [];
|
|
331
|
+
for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
|
|
332
|
+
if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
|
|
333
|
+
const li = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
334
|
+
if (!li) break;
|
|
335
|
+
items.push(strip(li[1]));
|
|
336
|
+
}
|
|
337
|
+
return items;
|
|
338
|
+
}
|
|
339
|
+
|
|
222
340
|
/**
|
|
223
|
-
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
|
|
224
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
341
|
+
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
|
|
342
|
+
* for anything else (no marker, unreadable, or not a task file). `acs` is the task's
|
|
343
|
+
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
|
|
344
|
+
* read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
|
|
345
|
+
* R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
|
|
346
|
+
* unlabelled task's return value stays byte-identical to before this field existed (this is
|
|
347
|
+
* load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
|
|
348
|
+
* 052-055 all state it must pass unmodified).
|
|
227
349
|
*/
|
|
228
350
|
export function parseLinkedTask(raw) {
|
|
229
351
|
const text = String(raw ?? "");
|
|
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
|
|
|
240
362
|
if (block)
|
|
241
363
|
for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
|
|
242
364
|
acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
|
|
243
|
-
|
|
365
|
+
const labels = parseFrontmatterLabels(fm[1]);
|
|
366
|
+
const linked = { id, status, specDir: marker[1], acs };
|
|
367
|
+
if (labels.length) linked.labels = labels;
|
|
368
|
+
return linked;
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
|
|
|
259
384
|
}
|
|
260
385
|
|
|
261
386
|
/** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
|
|
262
|
-
* exactly the mirror's per-link fields (drops `file`).
|
|
387
|
+
* exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
|
|
388
|
+
* through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
|
|
389
|
+
* extra logic to keep an unlabelled projection byte-identical to before this field existed. */
|
|
263
390
|
export function projectBacklog(root) {
|
|
264
|
-
return findLinkedTasks(root).map(({ id, status, specDir, acs }) => (
|
|
391
|
+
return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
|
|
392
|
+
labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
|
|
393
|
+
));
|
|
265
394
|
}
|
|
266
395
|
|
|
267
396
|
/** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
|
|
20
20
|
// exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
|
|
21
|
+
//
|
|
22
|
+
// Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
|
|
23
|
+
// $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
|
|
24
|
+
// directory on purpose and must never be silently overridden by an ambient variable. The env
|
|
25
|
+
// var still outranks input.cwd: it's the harness's authoritative statement of the project root,
|
|
26
|
+
// while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
|
|
27
|
+
// session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
|
|
28
|
+
// is byte-identical to before for the real Stop-hook path.
|
|
21
29
|
|
|
22
30
|
/** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
|
|
23
31
|
export function readStdin() {
|
|
@@ -34,9 +42,9 @@ export function readStdin() {
|
|
|
34
42
|
* Pure core: given the parsed hook input and the gates, return { block, message }.
|
|
35
43
|
* Separated from process I/O so it is unit-testable.
|
|
36
44
|
*/
|
|
37
|
-
export function evaluate(input, gates, { cwd =
|
|
45
|
+
export function evaluate(input, gates, { cwd = undefined } = {}) {
|
|
38
46
|
if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
|
|
39
|
-
const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
|
|
47
|
+
const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
|
|
40
48
|
const ctx = {
|
|
41
49
|
sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
42
50
|
input: input || {},
|
|
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
|
61
61
|
// identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
|
|
62
62
|
// per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
|
|
63
63
|
// group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
|
|
64
|
-
// (AC #1: name the phase, the box, and the failing gate).
|
|
65
|
-
|
|
64
|
+
// (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
|
|
65
|
+
// lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
|
|
66
|
+
// third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
|
|
67
|
+
// captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
|
|
68
|
+
export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
|
|
66
69
|
|
|
67
70
|
/** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
|
|
68
71
|
function phaseName(heading) {
|
package/lib/board-mirror.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// "links": [
|
|
11
11
|
// { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
|
|
12
12
|
// "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
|
|
13
|
+
// "labels": ["paused"],
|
|
13
14
|
// "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
|
|
14
15
|
// ]
|
|
15
16
|
// }
|
|
@@ -20,6 +21,15 @@
|
|
|
20
21
|
// logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
|
|
21
22
|
// (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
|
|
22
23
|
//
|
|
24
|
+
// `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
|
|
25
|
+
// link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
|
|
26
|
+
// task with no labels, so an unlabelled task's link stays byte-identical to before this field
|
|
27
|
+
// existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
|
|
28
|
+
// `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
|
|
29
|
+
// assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
|
|
30
|
+
// Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
|
|
31
|
+
// required to emit it for an unlabelled link.
|
|
32
|
+
//
|
|
23
33
|
// `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
|
|
24
34
|
// never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
|
|
25
35
|
// top-level and per-link keys round-trip: read a mirror, write it back, and every key this
|
|
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
|
|
|
60
70
|
import { join, dirname, resolve } from "node:path";
|
|
61
71
|
import { spawnSync } from "node:child_process";
|
|
62
72
|
import { runAsCli } from "./cli.mjs";
|
|
73
|
+
import { TASK_LINE } from "./spec-derive.mjs";
|
|
63
74
|
|
|
64
75
|
/** The only schema this module understands. */
|
|
65
76
|
export const CURRENT_SCHEMA = 1;
|
|
@@ -115,7 +126,7 @@ export function readMirror(root) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
|
|
118
|
-
const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
|
|
129
|
+
const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
|
|
119
130
|
const AC_KEYS = ["index", "checked", "text"];
|
|
120
131
|
|
|
121
132
|
/** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
|
|
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
|
|
|
193
204
|
seenSpecDirs.add(link.specDir);
|
|
194
205
|
}
|
|
195
206
|
|
|
207
|
+
// labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
|
|
208
|
+
// mirror written before this field existed); present-but-malformed is an error naming the
|
|
209
|
+
// offending link.
|
|
210
|
+
if (link.labels !== undefined) {
|
|
211
|
+
if (!Array.isArray(link.labels)) {
|
|
212
|
+
problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
|
|
213
|
+
} else {
|
|
214
|
+
link.labels.forEach((l, k) => {
|
|
215
|
+
if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
if (!req(link.acs, `${where}.acs`, "array")) return;
|
|
197
221
|
let prev = -Infinity;
|
|
198
222
|
link.acs.forEach((ac, j) => {
|
|
@@ -211,6 +235,78 @@ export function validateMirror(mirror) {
|
|
|
211
235
|
return problems;
|
|
212
236
|
}
|
|
213
237
|
|
|
238
|
+
/** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
|
|
239
|
+
* the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
|
|
240
|
+
* `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
|
|
241
|
+
* against, so a mirror-only project — no live board files to fall back on, the case a
|
|
242
|
+
* Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
|
|
243
|
+
* (spec 055 R4/AC #7). */
|
|
244
|
+
export function isPausedLink(link) {
|
|
245
|
+
return Array.isArray(link?.labels) && link.labels.includes("paused");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
|
|
249
|
+
* Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
|
|
250
|
+
* consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
|
|
251
|
+
* else. Full contract: docs/board-verbs.md "R2 — the marked description block".
|
|
252
|
+
*
|
|
253
|
+
* Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
|
|
254
|
+
* or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
|
|
255
|
+
* `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
|
|
256
|
+
* (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
|
|
257
|
+
* matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
|
|
258
|
+
* test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
|
|
259
|
+
* validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
|
|
260
|
+
* violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
|
|
261
|
+
* the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
|
|
262
|
+
* specs/056-jira-provider/findings/phase-1-mcp-surface.md).
|
|
263
|
+
*
|
|
264
|
+
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
|
+
* block renumbers.
|
|
266
|
+
*
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates two normalizations a live Jira write→read round-trip is known
|
|
268
|
+
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line) and two trailing spaces appended
|
|
270
|
+
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
+
* strips trailing whitespace from the captured text). Reuses TASK_LINE from spec-derive.mjs
|
|
272
|
+
* rather than a third checkbox regex. */
|
|
273
|
+
|
|
274
|
+
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
|
+
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
276
|
+
|
|
277
|
+
/** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
|
|
278
|
+
export function renderSpecPhasesBlock(items) {
|
|
279
|
+
const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
|
|
280
|
+
return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse a description (or any string containing at most one block) into
|
|
285
|
+
* [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
|
|
286
|
+
* an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
|
|
287
|
+
* found, naming the counts.
|
|
288
|
+
*/
|
|
289
|
+
export function parseSpecPhasesBlock(text) {
|
|
290
|
+
const s = String(text ?? "");
|
|
291
|
+
const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
|
|
292
|
+
const endCount = s.split(SPEC_PHASES_END).length - 1;
|
|
293
|
+
if (beginCount > 1 || endCount > 1)
|
|
294
|
+
throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
|
|
295
|
+
const start = s.indexOf(SPEC_PHASES_BEGIN);
|
|
296
|
+
const stop = s.indexOf(SPEC_PHASES_END);
|
|
297
|
+
if (start === -1 || stop === -1) return [];
|
|
298
|
+
if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
|
|
299
|
+
const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const line of inner.split("\n")) {
|
|
302
|
+
if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
|
|
303
|
+
const m = line.match(TASK_LINE);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
}
|
|
309
|
+
|
|
214
310
|
/* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
|
|
215
311
|
*
|
|
216
312
|
* Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
|
|
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
|
|
|
219
315
|
|
|
220
316
|
const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
|
|
221
317
|
|
|
318
|
+
/** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
|
|
319
|
+
* form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
|
|
320
|
+
* Returns [] when absent, empty, or unparseable — never throws. */
|
|
321
|
+
function parseFrontmatterLabels(fm) {
|
|
322
|
+
const m = fm.match(/^labels:(.*)$/m);
|
|
323
|
+
if (!m) return [];
|
|
324
|
+
const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
325
|
+
const rest = m[1].trim();
|
|
326
|
+
if (rest) {
|
|
327
|
+
const inline = rest.match(/^\[(.*)\]$/);
|
|
328
|
+
return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
|
|
329
|
+
}
|
|
330
|
+
const items = [];
|
|
331
|
+
for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
|
|
332
|
+
if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
|
|
333
|
+
const li = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
334
|
+
if (!li) break;
|
|
335
|
+
items.push(strip(li[1]));
|
|
336
|
+
}
|
|
337
|
+
return items;
|
|
338
|
+
}
|
|
339
|
+
|
|
222
340
|
/**
|
|
223
|
-
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
|
|
224
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
341
|
+
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
|
|
342
|
+
* for anything else (no marker, unreadable, or not a task file). `acs` is the task's
|
|
343
|
+
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
|
|
344
|
+
* read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
|
|
345
|
+
* R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
|
|
346
|
+
* unlabelled task's return value stays byte-identical to before this field existed (this is
|
|
347
|
+
* load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
|
|
348
|
+
* 052-055 all state it must pass unmodified).
|
|
227
349
|
*/
|
|
228
350
|
export function parseLinkedTask(raw) {
|
|
229
351
|
const text = String(raw ?? "");
|
|
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
|
|
|
240
362
|
if (block)
|
|
241
363
|
for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
|
|
242
364
|
acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
|
|
243
|
-
|
|
365
|
+
const labels = parseFrontmatterLabels(fm[1]);
|
|
366
|
+
const linked = { id, status, specDir: marker[1], acs };
|
|
367
|
+
if (labels.length) linked.labels = labels;
|
|
368
|
+
return linked;
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
|
|
|
259
384
|
}
|
|
260
385
|
|
|
261
386
|
/** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
|
|
262
|
-
* exactly the mirror's per-link fields (drops `file`).
|
|
387
|
+
* exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
|
|
388
|
+
* through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
|
|
389
|
+
* extra logic to keep an unlabelled projection byte-identical to before this field existed. */
|
|
263
390
|
export function projectBacklog(root) {
|
|
264
|
-
return findLinkedTasks(root).map(({ id, status, specDir, acs }) => (
|
|
391
|
+
return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
|
|
392
|
+
labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
|
|
393
|
+
));
|
|
265
394
|
}
|
|
266
395
|
|
|
267
396
|
/** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
|
package/lib/gate-runner.mjs
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
|
|
20
20
|
// exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
|
|
21
|
+
//
|
|
22
|
+
// Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
|
|
23
|
+
// $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
|
|
24
|
+
// directory on purpose and must never be silently overridden by an ambient variable. The env
|
|
25
|
+
// var still outranks input.cwd: it's the harness's authoritative statement of the project root,
|
|
26
|
+
// while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
|
|
27
|
+
// session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
|
|
28
|
+
// is byte-identical to before for the real Stop-hook path.
|
|
21
29
|
|
|
22
30
|
/** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
|
|
23
31
|
export function readStdin() {
|
|
@@ -34,9 +42,9 @@ export function readStdin() {
|
|
|
34
42
|
* Pure core: given the parsed hook input and the gates, return { block, message }.
|
|
35
43
|
* Separated from process I/O so it is unit-testable.
|
|
36
44
|
*/
|
|
37
|
-
export function evaluate(input, gates, { cwd =
|
|
45
|
+
export function evaluate(input, gates, { cwd = undefined } = {}) {
|
|
38
46
|
if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
|
|
39
|
-
const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
|
|
47
|
+
const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
|
|
40
48
|
const ctx = {
|
|
41
49
|
sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
42
50
|
input: input || {},
|
package/lib/spec-derive.mjs
CHANGED
|
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
|
61
61
|
// identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
|
|
62
62
|
// per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
|
|
63
63
|
// group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
|
|
64
|
-
// (AC #1: name the phase, the box, and the failing gate).
|
|
65
|
-
|
|
64
|
+
// (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
|
|
65
|
+
// lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
|
|
66
|
+
// third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
|
|
67
|
+
// captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
|
|
68
|
+
export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
|
|
66
69
|
|
|
67
70
|
/** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
|
|
68
71
|
function phaseName(heading) {
|
package/package.json
CHANGED
|
@@ -779,6 +779,76 @@ export function renderBacklog(id, intents) {
|
|
|
779
779
|
return cmds;
|
|
780
780
|
}
|
|
781
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Render one task's intents as ordered Jira MCP call descriptions (spec 055 R6): `renderBacklog`'s
|
|
784
|
+
* sibling for the `jira` provider. Each entry is `{ tool, args, why }` — `tool` a bare MCP tool
|
|
785
|
+
* name, unprefixed (the connector-specific prefix these Atlassian tools carry, and the actual
|
|
786
|
+
* dispatch, are spec 056's skill's job, not this module's), `args` a plain object, `why` the
|
|
787
|
+
* human-legible reason (feeds the sync skill's progress note). PURE: no MCP call, no network, no
|
|
788
|
+
* remote fetch of any kind — this is what keeps `lib/` (and this gate) network-free
|
|
789
|
+
* (docs/design/board-provider-seam.md invariant 4).
|
|
790
|
+
*
|
|
791
|
+
* `config` is `.board.json`'s `jira` sub-object (spec 054); only `statusMap` is consulted.
|
|
792
|
+
*
|
|
793
|
+
* The AC-block collapse: the Backlog renderer above issues one command PER acRemove/acAdd/
|
|
794
|
+
* acCheck/acUncheck entry, because Backlog's AC list has a partial-edit CLI. Jira's marked
|
|
795
|
+
* description block (docs/board-verbs.md R2) has no partial-edit form — it is replaced
|
|
796
|
+
* wholesale — so every ac* array here folds into exactly ONE `editJiraIssue` call, whatever the
|
|
797
|
+
* mix of adds/removes/checks/unchecks. That call's `args` carries the RAW diff, not a
|
|
798
|
+
* fully-resolved description string: resolving it against the block's CURRENT text needs the
|
|
799
|
+
* live issue (read via `getJiraIssue`, parsed with `parseSpecPhasesBlock`, the diff applied,
|
|
800
|
+
* re-rendered with `renderSpecPhasesBlock` — both `lib/board-mirror.mjs`), which needs live data
|
|
801
|
+
* this pure function neither has nor is allowed to fetch. That resolution is spec 056's skill's
|
|
802
|
+
* job — this function only describes the diff to make, in order; executing (and resolving) it
|
|
803
|
+
* is not this module's job.
|
|
804
|
+
*/
|
|
805
|
+
export function renderJira(id, intents, config = {}) {
|
|
806
|
+
const statusMap = config?.statusMap ?? {};
|
|
807
|
+
const calls = [];
|
|
808
|
+
|
|
809
|
+
if (intents.statusTo) {
|
|
810
|
+
const target = statusMap[intents.statusTo] ?? intents.statusTo;
|
|
811
|
+
if (intents.statusTo === "Done" && intents.finalSummary) {
|
|
812
|
+
// board:final's jira column: comment the summary, THEN transition — so the summary is on
|
|
813
|
+
// record before the issue moves, mirroring Backlog's combined `-s Done --final-summary`.
|
|
814
|
+
calls.push({
|
|
815
|
+
tool: "addOrEditJiraIssueComment",
|
|
816
|
+
args: { issueIdOrKey: id, commentBody: intents.finalSummary },
|
|
817
|
+
why: intents.finalSummary,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
calls.push({
|
|
821
|
+
tool: "transitionJiraIssue",
|
|
822
|
+
args: { issueIdOrKey: id, status: target },
|
|
823
|
+
why: `status ${intents.statusFrom} -> ${intents.statusTo}`,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const acChanged = intents.acRemove.length > 0 || intents.acAdd.length > 0
|
|
828
|
+
|| intents.acCheck.length > 0 || intents.acUncheck.length > 0;
|
|
829
|
+
if (acChanged) {
|
|
830
|
+
calls.push({
|
|
831
|
+
tool: "editJiraIssue",
|
|
832
|
+
args: {
|
|
833
|
+
issueIdOrKey: id,
|
|
834
|
+
acRemove: intents.acRemove, acAdd: intents.acAdd,
|
|
835
|
+
acCheck: intents.acCheck, acUncheck: intents.acUncheck,
|
|
836
|
+
},
|
|
837
|
+
why: intents.note ?? "phase ACs reconciled",
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (intents.note) {
|
|
842
|
+
calls.push({
|
|
843
|
+
tool: "addOrEditJiraIssueComment",
|
|
844
|
+
args: { issueIdOrKey: id, commentBody: intents.note },
|
|
845
|
+
why: intents.note,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return calls;
|
|
850
|
+
}
|
|
851
|
+
|
|
782
852
|
/**
|
|
783
853
|
* Backward-compatible single-shot planner: `planIntents` then `renderBacklog` in one call.
|
|
784
854
|
* Every call site inside this module now goes through the two halves directly; this wrapper
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// "links": [
|
|
11
11
|
// { "id": "TASK-109", "status": "In Progress", "specDir": "specs/052-board-adapter-seam",
|
|
12
12
|
// "acs": [ { "index": 1, "checked": true, "text": "Spec phase: Seam" } ],
|
|
13
|
+
// "labels": ["paused"],
|
|
13
14
|
// "observedAt": "<ISO 8601>", "observedSha": "<git sha>" }
|
|
14
15
|
// ]
|
|
15
16
|
// }
|
|
@@ -20,6 +21,15 @@
|
|
|
20
21
|
// logic. `observedAt` / `observedSha` exist for providers whose projection needs a model
|
|
21
22
|
// (MCP-backed boards); a deterministic provider MAY set them, nothing requires it to.
|
|
22
23
|
//
|
|
24
|
+
// `labels` (spec 055 R4, additive to spec 052's schema) is an OPTIONAL array of strings per
|
|
25
|
+
// link. A mirror that omits it still validates — `projectBacklog` itself omits the key for a
|
|
26
|
+
// task with no labels, so an unlabelled task's link stays byte-identical to before this field
|
|
27
|
+
// existed. It exists so `docs/task-labels.md`'s Reserved `paused` label — machine-read by
|
|
28
|
+
// `pdlc:sweep`'s paused-lane doctrine — can be resolved from the mirror alone, without
|
|
29
|
+
// assuming `backlog/tasks/*.md` is present to read frontmatter from directly (the case a
|
|
30
|
+
// Jira-only project is in; see `isPausedLink` below). Both providers project it; neither is
|
|
31
|
+
// required to emit it for an unlabelled link.
|
|
32
|
+
//
|
|
23
33
|
// `schema` is an integer; a `schema` this module does not recognize is a HARD ERROR on read —
|
|
24
34
|
// never a silent best-effort parse (fail-closed, docs/wiki/gates-convention.md). Unknown
|
|
25
35
|
// top-level and per-link keys round-trip: read a mirror, write it back, and every key this
|
|
@@ -60,6 +70,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
|
|
|
60
70
|
import { join, dirname, resolve } from "node:path";
|
|
61
71
|
import { spawnSync } from "node:child_process";
|
|
62
72
|
import { runAsCli } from "./cli.mjs";
|
|
73
|
+
import { TASK_LINE } from "./spec-derive.mjs";
|
|
63
74
|
|
|
64
75
|
/** The only schema this module understands. */
|
|
65
76
|
export const CURRENT_SCHEMA = 1;
|
|
@@ -115,7 +126,7 @@ export function readMirror(root) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
const TOP_KEYS = ["schema", "provider", "generatedAt", "links"];
|
|
118
|
-
const LINK_KEYS = ["id", "status", "specDir", "acs", "observedAt", "observedSha"];
|
|
129
|
+
const LINK_KEYS = ["id", "status", "specDir", "acs", "labels", "observedAt", "observedSha"];
|
|
119
130
|
const AC_KEYS = ["index", "checked", "text"];
|
|
120
131
|
|
|
121
132
|
/** Rebuild `obj` with `knownKeys` first (in that order, when present) and every other own key
|
|
@@ -193,6 +204,19 @@ export function validateMirror(mirror) {
|
|
|
193
204
|
seenSpecDirs.add(link.specDir);
|
|
194
205
|
}
|
|
195
206
|
|
|
207
|
+
// labels is OPTIONAL (spec 055 R4) — a missing key is valid (backward compat with every
|
|
208
|
+
// mirror written before this field existed); present-but-malformed is an error naming the
|
|
209
|
+
// offending link.
|
|
210
|
+
if (link.labels !== undefined) {
|
|
211
|
+
if (!Array.isArray(link.labels)) {
|
|
212
|
+
problems.push(`${where}.labels: expected array, got ${typeof link.labels}`);
|
|
213
|
+
} else {
|
|
214
|
+
link.labels.forEach((l, k) => {
|
|
215
|
+
if (typeof l !== "string") problems.push(`${where}.labels[${k}]: expected string, got ${typeof l}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
if (!req(link.acs, `${where}.acs`, "array")) return;
|
|
197
221
|
let prev = -Infinity;
|
|
198
222
|
link.acs.forEach((ac, j) => {
|
|
@@ -211,6 +235,78 @@ export function validateMirror(mirror) {
|
|
|
211
235
|
return problems;
|
|
212
236
|
}
|
|
213
237
|
|
|
238
|
+
/** Is this mirror link paused (`docs/task-labels.md`'s Reserved `paused` label), read from
|
|
239
|
+
* the mirror's OWN `labels` — never from `backlog/tasks/*.md`. This is the primitive
|
|
240
|
+
* `pdlc:sweep`'s paused-lane doctrine (and any future conflict-analysis script) resolves
|
|
241
|
+
* against, so a mirror-only project — no live board files to fall back on, the case a
|
|
242
|
+
* Jira-only host is in — still excludes a parked branch's task from lane-conflict analysis
|
|
243
|
+
* (spec 055 R4/AC #7). */
|
|
244
|
+
export function isPausedLink(link) {
|
|
245
|
+
return Array.isArray(link?.labels) && link.labels.includes("paused");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── the marked description block (spec 055 R2): render/parse pair for the Jira analogue of
|
|
249
|
+
* Backlog's AC:BEGIN/END block. Lives here, not a new lib/ module, because it produces and
|
|
250
|
+
* consumes exactly this file's own `acs` shape ([{ index, checked, text }]) and needs nothing
|
|
251
|
+
* else. Full contract: docs/board-verbs.md "R2 — the marked description block".
|
|
252
|
+
*
|
|
253
|
+
* Rules: text OUTSIDE the markers is human-authored and NEVER touched — neither function reads
|
|
254
|
+
* or writes past them. The block is REPLACED WHOLESALE; there is no partial-edit form. The
|
|
255
|
+
* `Spec: <dir>` marker line stays OUTSIDE the block — bridge.mjs's `MARKER`
|
|
256
|
+
* (`/^Spec:\s*(\S+?)\/?\s*$/m`) is unaffected either way, since it is anchored per-line and
|
|
257
|
+
* matches wherever a bare `Spec: ` line sits, block or no block (verified by fixture,
|
|
258
|
+
* test/board-mirror.test.mjs). ONE block per description: a second BEGIN or END is a
|
|
259
|
+
* validation error, not a merge. MARKDOWN ONLY — an `html`-format read or write is a contract
|
|
260
|
+
* violation, not a formatting choice: Jira's html rendering escapes the markers and swallows
|
|
261
|
+
* the END marker inside the final task-list `<li>` (verified live, spec 056 phase 1,
|
|
262
|
+
* specs/056-jira-provider/findings/phase-1-mcp-surface.md).
|
|
263
|
+
*
|
|
264
|
+
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
|
+
* block renumbers.
|
|
266
|
+
*
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates two normalizations a live Jira write→read round-trip is known
|
|
268
|
+
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line) and two trailing spaces appended
|
|
270
|
+
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
+
* strips trailing whitespace from the captured text). Reuses TASK_LINE from spec-derive.mjs
|
|
272
|
+
* rather than a third checkbox regex. */
|
|
273
|
+
|
|
274
|
+
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
|
+
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
276
|
+
|
|
277
|
+
/** Render items ([{ checked, text }], in order) as the block's exact text, markers included. */
|
|
278
|
+
export function renderSpecPhasesBlock(items) {
|
|
279
|
+
const lines = items.map((it) => `- [${it.checked ? "x" : " "}] ${it.text}`);
|
|
280
|
+
return [SPEC_PHASES_BEGIN, ...lines, SPEC_PHASES_END].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse a description (or any string containing at most one block) into
|
|
285
|
+
* [{ index, checked, text }], 1-based positional — [] when no block is present (absent is not
|
|
286
|
+
* an error; only a duplicated block is). Throws when more than one BEGIN or END marker is
|
|
287
|
+
* found, naming the counts.
|
|
288
|
+
*/
|
|
289
|
+
export function parseSpecPhasesBlock(text) {
|
|
290
|
+
const s = String(text ?? "");
|
|
291
|
+
const beginCount = s.split(SPEC_PHASES_BEGIN).length - 1;
|
|
292
|
+
const endCount = s.split(SPEC_PHASES_END).length - 1;
|
|
293
|
+
if (beginCount > 1 || endCount > 1)
|
|
294
|
+
throw new Error(`spec-phases block: found ${beginCount} BEGIN marker(s) and ${endCount} END marker(s) — exactly one block is allowed per description`);
|
|
295
|
+
const start = s.indexOf(SPEC_PHASES_BEGIN);
|
|
296
|
+
const stop = s.indexOf(SPEC_PHASES_END);
|
|
297
|
+
if (start === -1 || stop === -1) return [];
|
|
298
|
+
if (stop < start) throw new Error("spec-phases block: END marker precedes BEGIN marker");
|
|
299
|
+
const inner = s.slice(start + SPEC_PHASES_BEGIN.length, stop);
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const line of inner.split("\n")) {
|
|
302
|
+
if (line.trim() === "") continue; // tolerates the blank line Jira inserts after BEGIN
|
|
303
|
+
const m = line.match(TASK_LINE);
|
|
304
|
+
if (!m) continue;
|
|
305
|
+
items.push({ index: items.length + 1, checked: m[1] !== " ", text: m[2] });
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
}
|
|
309
|
+
|
|
214
310
|
/* ── the backlog projector: parses backlog/tasks/*.md, moved from spec-bridge/gates/bridge.mjs ──
|
|
215
311
|
*
|
|
216
312
|
* Moved rather than duplicated (spec 052 phase 2): two parsers would silently drift the first
|
|
@@ -219,11 +315,37 @@ export function validateMirror(mirror) {
|
|
|
219
315
|
|
|
220
316
|
const MARKER = /^Spec:\s*(\S+?)\/?\s*$/m;
|
|
221
317
|
|
|
318
|
+
/** Parse a frontmatter block's `labels:` field — either Backlog.md's actual YAML block-list
|
|
319
|
+
* form (`labels:\n - a\n - b\n`) or an inline form (`labels: []`, `labels: [a, b]`).
|
|
320
|
+
* Returns [] when absent, empty, or unparseable — never throws. */
|
|
321
|
+
function parseFrontmatterLabels(fm) {
|
|
322
|
+
const m = fm.match(/^labels:(.*)$/m);
|
|
323
|
+
if (!m) return [];
|
|
324
|
+
const strip = (s) => s.trim().replace(/^['"]|['"]$/g, "");
|
|
325
|
+
const rest = m[1].trim();
|
|
326
|
+
if (rest) {
|
|
327
|
+
const inline = rest.match(/^\[(.*)\]$/);
|
|
328
|
+
return inline ? inline[1].split(",").map(strip).filter(Boolean) : [];
|
|
329
|
+
}
|
|
330
|
+
const items = [];
|
|
331
|
+
for (const line of fm.slice(fm.indexOf(m[0]) + m[0].length).split("\n")) {
|
|
332
|
+
if (line.trim() === "") continue; // the blank remainder of the "labels:" line itself
|
|
333
|
+
const li = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
334
|
+
if (!li) break;
|
|
335
|
+
items.push(strip(li[1]));
|
|
336
|
+
}
|
|
337
|
+
return items;
|
|
338
|
+
}
|
|
339
|
+
|
|
222
340
|
/**
|
|
223
|
-
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task,
|
|
224
|
-
*
|
|
225
|
-
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block —
|
|
226
|
-
*
|
|
341
|
+
* Parse one Backlog task file. Returns { id, status, specDir, acs } for a linked task, null
|
|
342
|
+
* for anything else (no marker, unreadable, or not a task file). `acs` is the task's
|
|
343
|
+
* acceptance criteria as [{ index, checked, text }] read from the AC:BEGIN/END block — still
|
|
344
|
+
* read-only; the plan command needs them to compute reconciling edits. `labels` (spec 055
|
|
345
|
+
* R4) is included ONLY when the task's frontmatter `labels:` list is non-empty — an
|
|
346
|
+
* unlabelled task's return value stays byte-identical to before this field existed (this is
|
|
347
|
+
* load-bearing: test/spec-bridge.test.mjs asserts the exact shape and is frozen — specs
|
|
348
|
+
* 052-055 all state it must pass unmodified).
|
|
227
349
|
*/
|
|
228
350
|
export function parseLinkedTask(raw) {
|
|
229
351
|
const text = String(raw ?? "");
|
|
@@ -240,7 +362,10 @@ export function parseLinkedTask(raw) {
|
|
|
240
362
|
if (block)
|
|
241
363
|
for (const m of block[1].matchAll(/^- \[( |x|X)\] #(\d+)\s+(.*\S)\s*$/gm))
|
|
242
364
|
acs.push({ index: +m[2], checked: m[1] !== " ", text: m[3] });
|
|
243
|
-
|
|
365
|
+
const labels = parseFrontmatterLabels(fm[1]);
|
|
366
|
+
const linked = { id, status, specDir: marker[1], acs };
|
|
367
|
+
if (labels.length) linked.labels = labels;
|
|
368
|
+
return linked;
|
|
244
369
|
}
|
|
245
370
|
|
|
246
371
|
/** Scan <root>/backlog/tasks/*.md for linked tasks. Unreadable files are skipped. */
|
|
@@ -259,9 +384,13 @@ export function findLinkedTasks(root) {
|
|
|
259
384
|
}
|
|
260
385
|
|
|
261
386
|
/** The `backlog` provider's projector (spec 052 R4): `findLinkedTasks`' output reshaped to
|
|
262
|
-
* exactly the mirror's per-link fields (drops `file`).
|
|
387
|
+
* exactly the mirror's per-link fields (drops `file`). `labels` (spec 055 R4) rides straight
|
|
388
|
+
* through — `parseLinkedTask` already omits it for an unlabelled task, so this needs no
|
|
389
|
+
* extra logic to keep an unlabelled projection byte-identical to before this field existed. */
|
|
263
390
|
export function projectBacklog(root) {
|
|
264
|
-
return findLinkedTasks(root).map(({ id, status, specDir, acs }) => (
|
|
391
|
+
return findLinkedTasks(root).map(({ id, status, specDir, acs, labels }) => (
|
|
392
|
+
labels ? { id, status, specDir, acs, labels } : { id, status, specDir, acs }
|
|
393
|
+
));
|
|
265
394
|
}
|
|
266
395
|
|
|
267
396
|
/** Provider registry (spec 052 R4): provider name -> { requiresSync, project }.
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
//
|
|
19
19
|
// Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, session_id, cwd, … };
|
|
20
20
|
// exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
|
|
21
|
+
//
|
|
22
|
+
// Root resolution in evaluate() (spec 062), most specific first: explicit { cwd } option ->
|
|
23
|
+
// $CLAUDE_PROJECT_DIR -> input.cwd -> process.cwd(). An explicit argument is a caller naming a
|
|
24
|
+
// directory on purpose and must never be silently overridden by an ambient variable. The env
|
|
25
|
+
// var still outranks input.cwd: it's the harness's authoritative statement of the project root,
|
|
26
|
+
// while input.cwd is merely where the hook happened to fire (a worktree, a subdir, wherever the
|
|
27
|
+
// session sat). The only production caller, runStopHook below, passes no cwd, so this ordering
|
|
28
|
+
// is byte-identical to before for the real Stop-hook path.
|
|
21
29
|
|
|
22
30
|
/** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
|
|
23
31
|
export function readStdin() {
|
|
@@ -34,9 +42,9 @@ export function readStdin() {
|
|
|
34
42
|
* Pure core: given the parsed hook input and the gates, return { block, message }.
|
|
35
43
|
* Separated from process I/O so it is unit-testable.
|
|
36
44
|
*/
|
|
37
|
-
export function evaluate(input, gates, { cwd =
|
|
45
|
+
export function evaluate(input, gates, { cwd = undefined } = {}) {
|
|
38
46
|
if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
|
|
39
|
-
const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
|
|
47
|
+
const start = cwd || process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || process.cwd();
|
|
40
48
|
const ctx = {
|
|
41
49
|
sessionId: (input && input.session_id) || process.env.CLAUDE_CODE_SESSION_ID || null,
|
|
42
50
|
input: input || {},
|
|
@@ -61,8 +61,11 @@ const PHASE_HEADING = /^##\s+(.+?)\s*$/;
|
|
|
61
61
|
// identical to the old `/^\s*[-*]\s+\[([ xX])\]\s+\S/` (both require a non-space after the box;
|
|
62
62
|
// per-line matching, so `.*?\s*$` always closes over the remainder) — this only adds capture
|
|
63
63
|
// group 2, so parseTasks() output is byte-identical. The text feeds spec 050's blocking message
|
|
64
|
-
// (AC #1: name the phase, the box, and the failing gate).
|
|
65
|
-
|
|
64
|
+
// (AC #1: name the phase, the box, and the failing gate). Exported (spec 055 R2/Phase 3) so
|
|
65
|
+
// lib/board-mirror.mjs's spec-phases block parser reuses this exact regex rather than writing a
|
|
66
|
+
// third checkbox parser; per-line `(\S.*?)\s*$` already strips trailing whitespace from the
|
|
67
|
+
// captured text, which is what makes it tolerate Jira's own trailing-space normalization too.
|
|
68
|
+
export const TASK_LINE = /^\s*[-*]\s+\[([ xX])\]\s+(\S.*?)\s*$/;
|
|
66
69
|
|
|
67
70
|
/** Strip Spec Kit's "Phase 3.1:" style prefix so phase names read as AC labels ("Setup"). */
|
|
68
71
|
function phaseName(heading) {
|