@sabaiway/agent-workflow-kit 12.3.0 → 13.0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,121 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 13.0.0 — a plan proves its own ground: `plan-shape` judges a plan's rows against its epic story's claims in both arms and `--in-flight`, the claim relation lives in one leaf, and the containment rule is one positive predicate (AD-139)
8
+
9
+ **MAJOR: the containment rule refuses a row or anchor path spelling 12.4.0 accepted — a trailing or doubled slash, a
10
+ `.` segment — so a consumer's declared `--in-flight` gate can turn red with no edit of theirs (the refusal names the
11
+ row and the rule; no migration note, as for every kit MAJOR since 4.0.0 — the class AD-117 and AD-133 called MAJOR),
12
+ and the story relation joins the structural set, so a consumer already on the 12.4.0 epic tier can go red the same
13
+ way with no edit of theirs (each code's reach is stated in the bullets below); three new shipped leaves, one new
14
+ structural rule set, no deployed gate line's TEXT changes.** The epic
15
+ tier (12.4.0) gave a story a ledger of claims; the bar "a plan stays inside its story's claims" lived in prose, and
16
+ the last bar this epic wrote down drifted inside a day. The rung is now a tool, enforced where a dispatch is spent: `--in-flight` is already the gate
17
+ this family runs before every dispatch. The contract is `docs/ai/specs/kit/plan-shape-ownership.md` (live, revision 1,
18
+ S1–S11 bound) beside `plan-review-loop` revision 9.
19
+
20
+ - **The story line.** A plan carrying an epic story names it with exactly one line `Story: S<N> of <ID>` inside
21
+ `## Goal and boundary`, `<ID>` the epic's file stem under `docs/ai/epics/`. Two lines refuse `story-line`; an
22
+ unknown epic or story id refuses `story-unknown` by name; a landed epic refuses `story-epic-landed`. Zero lines is
23
+ a storyless plan and stays legal — judged as today plus the collision half. The plan's own story is looked up in
24
+ the full record list, landed rows included; its `state:` is never read.
25
+ - **ONE relation, in the STRUCTURAL set.** Over every concrete row path after glob expansion, against every
26
+ not-landed story of every open epic minus the plan's own: an `owns` overlap refuses `story-owns`; a `shared`
27
+ overlap inside the plan's own epic refuses `story-shared` unless a transitive `depends-on` edge orders the two
28
+ in either direction; a storyless plan against a `shared` claim refuses the same way (name the story); a
29
+ cross-epic `shared` overlap is not judged (the epic tier prints it). For a storied plan, an in-scope row path —
30
+ the declared source-size practice's roots, extensions and exclusions — that no own claim covers refuses
31
+ `story-scope`; the package-content pin path is covered by the shipped `create` or `delete` row it pins and is
32
+ never claimed by a story. `checkPlan`, `verifyPlan` and `checkPlanStructure` all carry it, so `--check`,
33
+ `--verify` and `--check --in-flight` agree.
34
+ - **Facts.** `openRepo` reads `docs/ai/epics/` once per run through the epic sweep's own no-follow door, and
35
+ `buildFacts` hands the judge `stories` (one record per valid story row of every open epic: `{ path, id, line, state, claims,
36
+ reachable }`), `landedEpics` (the file stems the sweep read as landed) and `storeRefusal`. Only ENOENT reads as
37
+ an absent store; a symlinked root, a regular file, ENOTDIR, any other error, a failed read, an unreadable `.md`
38
+ entry, a frontmatter the header reader refuses, an open epic the shape check refuses or a state outside the
39
+ closed set refuse EVERY judged plan `story-store`. A facts object with no `stories` field (the bare callers)
40
+ leaves the relation unjudged.
41
+ - **The pin exemption (`plan-review-loop` revision 9, S34).** Several `modify` rows whose OWN path is one
42
+ package-content pin path may own it — the ONE exemption to `duplicate-path`: a single pin row owes nothing; with
43
+ two or more, every one binds in ledger order the nearest untaken shipped `create` or `delete` row above it whose
44
+ package pin is that path, and a row that finds none refuses `pin-unbound`. A sweep expansion never rides it; the
45
+ `before`/`after` totals count each concrete path once; a judge with no `pathFacts` admits repeated `modify` pin
46
+ rows unjudged; a duplicate involving a `create` or `delete` still refuses.
47
+ - **Containment enumerated by proof.** A row or anchor path is admitted only when it has no backslash and every
48
+ `/`-separated segment is non-empty and neither `.` nor `..` — one predicate instead of an exclusion list, after
49
+ `./x` (plan review) and `x/` (diff review, confirmed live) each walked around a claim; a leading slash was
50
+ already refused by 12.4.0's own predicate.
51
+ - **Three leaves, no cycle, no claim primitive written twice.** `tools/claim-relation.mjs` (`expandClaim`,
52
+ `hasPathOverlap`, `findReachable`, `listStories`, `unique` — `plan-shape.mjs` re-exports `unique` so every import
53
+ site stands), `tools/epic-store.mjs` (`EPICS_REL`, `readEpicEntries` — the epic CLI's door, unchanged — and
54
+ `readEpicStore`, the facts half's no-follow probe), `tools/plan-shape-ownership.mjs` (`readStoryLine`,
55
+ `checkStoryOwnership`, `PIN_FILE`, `PIN_VERBS`). `epic-shape-ledger.mjs` and `epic-shape-cli.mjs` keep their
56
+ findings and counts byte for byte under their unchanged suites; the sweep result gains `landedEpics` beside
57
+ `counts`. Tarball pin 301 → 304. Every touched shipped module stays under the 400-line cap (`plan-shape.mjs` 384);
58
+ the kit's recorded aggregate rises by the three leaves and their suites, the reason recorded in the ratchet.
59
+ - **Recorded debt (queue).** `openRepo` walks the repository before the store probe, so an EACCES directory
60
+ anywhere exits 2 before `story-store` can be named (refuse-direction, pre-existing); the robustness coverage
61
+ table reads a filesystem errno as a process-launch outcome; a delegate read its own sandbox's spawn limit as a
62
+ reason to stop a slice; a near-miss story line (`- Story: …`, the bullet marker the canon's own sentence sits
63
+ under) reads as a storyless plan instead of refusing; the one-line read describer is written twice by the store
64
+ extraction (`describeRead` in the epic CLI and in the store leaf); a storied plan whose only pin-path row ships
65
+ no `create` or `delete` above it refuses `story-scope` with no repair inside the rule.
66
+
67
+ ## 12.4.0 — the epic tier gets its checker: `epic-shape` judges a capped concept file by one altitude predicate and one claim relation, renders its review brief, folds the findings by the same predicate, and closes it by rewriting one byte range (AD-138)
68
+
69
+ **MINOR: one new tool, four verbs, four shipped modules; no deployed gate line changes its verdict.** The epic this
70
+ family works under was settled after a six-round design consult whose brief had no altitude bar, and the file drifted
71
+ within a day — a backticked hash in a state field, ground named in prose, an id paragraph above the first section. A
72
+ prose bar has been measured failing under load here, so the rung is a tool. The contract is
73
+ `docs/ai/specs/kit/epic-shape/` (index + `ledger.md` + `brief.md`, live, revision 1, S1–S16 bound).
74
+
75
+ - **`tools/epic-shape-cli.mjs --check <epic>`** reads `docs/ai/epics/<ID>.md` through the no-follow door — the id is
76
+ the file stem — and refuses on: a frontmatter key set other than the substrate six plus `state: open | landed`,
77
+ `maxLines` other than 60, a file over 60 lines, a title other than `# Epic: <title>`, any heading outside the seven
78
+ sections in their order (Intent, Value, Non-goals, Acceptance, Specs, Stories ledger, Queue), a non-blank line
79
+ above Intent, a `## Queue` line outside `Row <ID> in <bucket>`, a second or non-date `Result line:`, a
80
+ `docs/plans/<name>.md` token other than the queue, and — the ALTITUDE BAR, one predicate over every body line
81
+ outside the two claim fields — a code fence line, a backtick span or a path-with-line citation (`something.ext:123`,
82
+ `dir/file:123`, backticked or bare); a mechanism written as plain prose passes, a stated residual, no word list.
83
+ The story ledger is six ` | ` fields per row (`S<N>`, name, `depends-on:`, `owns:`, `shared:`, `state:`), ids
84
+ contiguous from S1, at most 200 bytes counted OUTSIDE `owns:` and `shared:`; a claim is a repo-relative path in one
85
+ canonical spelling (a closed byte set — no whitespace, backtick, parenthesis, wildcard, pipe, colon or hash — no
86
+ `.`/`..` segment, no leading slash, an optional trailing slash as a directory prefix), never a file the checker
87
+ opens; a co-located test path (`foo.test.mjs`, `foo.test/`) is covered by `foo.mjs`. ONE claim relation between
88
+ distinct not-landed stories identified by (epic path, id): overlap is equality or a slash-boundary prefix;
89
+ owns × owns refuses inside and across epics; a pair with a shared side needs a transitive `depends-on` edge inside
90
+ one epic and is REPORTED (exit 0) across epics; the graph is closed and acyclic, a cycle named by its stories. The
91
+ sibling sweep reads the DIRECT entries of the store into an entry list — a symlinked or unreadable `.md` refuses
92
+ naming it, a `type: epic` file with a state outside `open | landed` refuses, an OPEN sibling is judged whole by
93
+ the same shape check and a refusal names the file (unknown never reads as "owns nothing"), a LANDED sibling is
94
+ its header only and contributes no story and no finding whatever its body holds, another type or a subdirectory
95
+ is skipped and counted — and prints `epic-shape: sweep <root>: epics read <n>; entries skipped <n>`, the handed
96
+ epic counted among the read.
97
+ - **`--review-brief <epic>`** prints, for an epic `--check` accepts and for no other file, a deterministic brief: the
98
+ id and state, the guard verbatim ("Judge intent, value, boundaries, ownership and story order. A finding that names
99
+ a mechanism, a file line or an implementation choice is below this tier's altitude and is NOT counted."), the
100
+ seven sections, five questions one per line. The same render serves a design consult over a draft.
101
+ - **`--fold <findings-file>`** reads entries by its own reader (column-0 `-`, `*`, `<n>.` with indented and blank
102
+ continuation), excises each entry's first citation (with its backticks only when the span is exactly the token),
103
+ judges the remainder by the same predicate and prints three named lists with counts — kept, discarded with a
104
+ reason each, lines outside entries; a zero-entry file refuses.
105
+ - **`--close <epic>`** runs the check, then four conditions — every story `landed <date>`, a result-line date, the
106
+ header not already landed, the queue row not standing by parsed id or whole-token display title over
107
+ `queue-audit`'s parser of `<root>/docs/plans/queue.md` read whole (absent or unparseable refuses) — and on accept
108
+ rewrites ONLY the header `state` bytes through the one descriptor it read; a second hard link refuses without a
109
+ write; an epic outside a `docs/ai/epics/` store refuses.
110
+ - **Grammar and exits.** `--help` alone, or exactly one verb and exactly one positional; a positional opening with
111
+ `-`, a second verb, a repeated positional and an empty invocation are usage. Exit 0 accept · 1 refuse · 2 usage.
112
+ Findings render as `epic-shape: <path>:<line>: <code>: <message>`. Nothing reads git.
113
+ - **Modules and suites.** `tools/epic-shape.mjs` (232 lines — `EPIC_SECTIONS`, `parseEpic`, `parseStoryRow`,
114
+ `judgeAltitude`, `checkEpic`), `tools/epic-shape-ledger.mjs` (180 — `checkClaims`, `sweepSiblings`, `judgeClose`),
115
+ `tools/epic-shape-brief.mjs` (75 — `BRIEF_GUARD`, `renderBrief`, `readEntries`, `foldFindings`),
116
+ `tools/epic-shape-cli.mjs` (153 — argv and fs only, `main(argv, io)`); four suites under `tools/epic-shape.test/`
117
+ bind S1–S16 with every changed line covered. The tarball gains the four modules by name (301 files, from 297).
118
+
119
+ Repo-only: this repo's own epic is rewritten to the contract inside its 60 lines (58) and its `--check` line is a
120
+ declared gate before `coverage-check`; the engine canon gains `## The epic` (engine 5.3.0).
121
+
7
122
  ## 12.3.0 — the purge archive is a tool: `queue-purge` snapshots a section's bytes and vouches for what leaves, and `queue-audit` exports the two surfaces it reads (AD-137)
8
123
 
9
124
  **MINOR: one new tool, two verbs, and the sibling's revision 6; no deployed gate line changes its verdict.** The canon has
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '12.3.0'
6
+ version: '13.0.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "12.3.0",
6
+ "version": "13.0.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "12.3.0",
3
+ "version": "13.0.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,33 @@
1
+ const OPEN = 'open';
2
+ const CLAIM_FIELDS = Object.freeze(['owns', 'shared']);
3
+ const TRAILING_SLASH = /\/$/;
4
+ const SOURCE_EXTENSION = /^(.*[^/])(\.[^./]+)$/;
5
+ const TEST_FILE = /\.test\.[^/]+$/;
6
+ const TEST_SUFFIX = '.test';
7
+
8
+ export const unique = (items) => [...new Set(items)];
9
+
10
+ export const expandClaim = (path) => {
11
+ const source = SOURCE_EXTENSION.exec(path);
12
+ return source && !TEST_FILE.test(path)
13
+ ? [path, `${source[1]}${TEST_SUFFIX}${source[2]}`, `${source[1]}${TEST_SUFFIX}/`] : [path];
14
+ };
15
+ export const hasPathOverlap = (left, right) => {
16
+ const a = left.replace(TRAILING_SLASH, '');
17
+ const b = right.replace(TRAILING_SLASH, '');
18
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
19
+ };
20
+ export const findReachable = (graph, pending, visited = new Set()) => {
21
+ const unseen = unique(pending.filter((id) => !visited.has(id)));
22
+ if (unseen.length === 0) return visited;
23
+ return findReachable(graph, unseen.flatMap((id) => graph.get(id) ?? []), new Set([...visited, ...unseen]));
24
+ };
25
+ const getClaims = (row) => CLAIM_FIELDS.flatMap((field) => row[field].map((path) => ({ field, path, ground: expandClaim(path) })));
26
+
27
+ export const listStories = (epics) => epics.filter((epic) => epic.state === OPEN).flatMap((epic) => {
28
+ const graph = new Map(epic.rows.map((row) => [row.id, row.dependsOn]));
29
+ return epic.rows.filter((row) => row.valid).map((row) => ({
30
+ path: epic.path, id: row.id, line: row.line, state: row.state,
31
+ claims: getClaims(row), reachable: findReachable(graph, row.dependsOn),
32
+ }));
33
+ });
@@ -0,0 +1,75 @@
1
+ import { checkEpic, judgeAltitude, EPIC_SECTIONS, findCitation } from './epic-shape.mjs';
2
+ import { getLineCount } from './plan-shape.mjs';
3
+
4
+ export const BRIEF_GUARD = 'Judge intent, value, boundaries, ownership and story order. A finding that names a mechanism, a file line or an implementation choice is below this tier\'s altitude and is NOT counted.';
5
+ const QUESTIONS = Object.freeze([
6
+ 'Intent: Is the intended change clear?',
7
+ 'Value: Is the value worth the work?',
8
+ 'Boundaries: Are the non-goals and acceptance boundaries clear?',
9
+ 'Ownership: Does each story have distinct, declared ownership?',
10
+ 'Story order: Do the dependencies express the required order?',
11
+ ]);
12
+ const BRIEF_PREFIX = '# Epic review: ';
13
+ const FIRST_LINE = 1;
14
+ const ENTRY_START = /^(?:[-*]|\d+\.)(?:[ \t]|$)/;
15
+ const INDENTED_LINE = /^[ \t]+\S/;
16
+ const CR_END = /\r$/;
17
+ const CITATION_SPAN = /(?<!`)(`+)(?!`)([\s\S]*?)(?<!`)\1(?!`)/g;
18
+ const SPAN_CONTENT = 2;
19
+ const CITATION_GAP = ' ';
20
+ const ERROR = 'error';
21
+ const makeEntry = (entry) => ({ ...entry, text: entry.lines.join('\n') });
22
+
23
+ export const renderBrief = (text, path) => {
24
+ const { findings, epic } = checkEpic(text, path);
25
+ if (findings.length) return { findings, brief: null };
26
+ const sections = EPIC_SECTIONS.map((heading) => epic.sections.find((section) => section.heading === heading).text).join('');
27
+ const separator = sections.endsWith('\n') ? '\n' : '\n\n';
28
+ return `${BRIEF_PREFIX}${epic.id} (${epic.state})\n\n${BRIEF_GUARD}\n\n${sections}${separator}${QUESTIONS.join('\n')}\n`;
29
+ };
30
+
31
+ export const readEntries = (text) => {
32
+ const source = String(text ?? '');
33
+ const lines = source.split('\n').slice(0, getLineCount(source));
34
+ const read = lines.reduce((state, raw, index) => {
35
+ const line = index + FIRST_LINE;
36
+ if (ENTRY_START.test(raw.replace(CR_END, ''))) {
37
+ return { ...state, entries: state.current ? [...state.entries, makeEntry(state.current)] : state.entries,
38
+ current: { line, lines: [raw] } };
39
+ }
40
+ if (state.current && (raw.trim() === '' || INDENTED_LINE.test(raw))) {
41
+ return { ...state, current: { ...state.current, lines: [...state.current.lines, raw] } };
42
+ }
43
+ return { current: null, entries: state.current ? [...state.entries, makeEntry(state.current)] : state.entries,
44
+ outside: raw.trim() === '' ? state.outside : [...state.outside, { line, text: raw }] };
45
+ }, { entries: [], outside: [], current: null });
46
+ return { entries: read.current ? [...read.entries, makeEntry(read.current)] : read.entries, outside: read.outside };
47
+ };
48
+
49
+ const findCitationRange = (text) => {
50
+ const citation = findCitation(text);
51
+ if (!citation) return null;
52
+ const span = [...text.matchAll(CITATION_SPAN)].find((match) => match.index < citation.index && match.index + match[0].length > citation.end);
53
+ if (!span) return citation;
54
+ return span[SPAN_CONTENT] === citation.text ? { index: span.index, end: span.index + span[0].length } : null;
55
+ };
56
+ const judgeEntry = (entry) => {
57
+ const citation = findCitationRange(entry.text);
58
+ const remainder = citation ? entry.text.slice(0, citation.index) + CITATION_GAP + entry.text.slice(citation.end) : entry.text;
59
+ const lines = remainder.split('\n').map((line, index) => ({
60
+ ...judgeAltitude(index === 0 ? line.replace(ENTRY_START, '') : line), reasonLine: entry.line + index,
61
+ }));
62
+ const violation = lines.find((line) => line.below) ?? { ...judgeAltitude(remainder), reasonLine: entry.line };
63
+ return violation.below ? { ...entry, form: violation.form, reasonLine: violation.reasonLine,
64
+ reason: `${violation.form} is below concept altitude` } : null;
65
+ };
66
+
67
+ export const foldFindings = (text) => {
68
+ const { entries, outside } = readEntries(text);
69
+ const judged = entries.map((entry) => ({ entry, discarded: judgeEntry(entry) }));
70
+ const kept = judged.filter((item) => !item.discarded).map((item) => item.entry);
71
+ const discarded = judged.filter((item) => item.discarded).map((item) => item.discarded);
72
+ const findings = entries.length ? [] : [{ line: FIRST_LINE, code: 'empty-findings', severity: ERROR, message: 'the findings file contains no entries' }];
73
+ return { ok: findings.length === 0, findings, kept, discarded, outside,
74
+ counts: { kept: kept.length, discarded: discarded.length, outside: outside.length } };
75
+ };
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { constants, openSync, closeSync, fstatSync, writeSync, ftruncateSync } from 'node:fs';
3
+ import { basename, dirname, resolve } from 'node:path';
4
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
5
+ import { isDirectRun } from './direct-run.mjs';
6
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
7
+ import { readEpicEntries } from './epic-store.mjs';
8
+ import { checkEpic } from './epic-shape.mjs';
9
+ import { checkClaims, sweepSiblings, judgeClose, STORE_PATH, QUEUE_PATH } from './epic-shape-ledger.mjs';
10
+ import { renderBrief, foldFindings } from './epic-shape-brief.mjs';
11
+
12
+ const EXIT = Object.freeze({ accept: 0, refuse: 1, usage: 2 });
13
+ const CHECK = '--check';
14
+ const BRIEF = '--review-brief';
15
+ const FOLD = '--fold';
16
+ const CLOSE = '--close';
17
+ const VERBS = Object.freeze([CHECK, BRIEF, FOLD, CLOSE]);
18
+ const HELP_FLAG = '--help';
19
+ const HELP_ARITY = 1;
20
+ const VERB_ARITY = 2;
21
+ const FIRST_LINE = 1;
22
+ const PREFIX = 'epic-shape:';
23
+ const INFO = 'info';
24
+ const STORE_TO_PROJECT = '../../..';
25
+ const LANDED_BYTES = Buffer.from('landed');
26
+ const HELP = `Usage:
27
+ node epic-shape-cli.mjs --check <epic>
28
+ node epic-shape-cli.mjs --review-brief <epic>
29
+ node epic-shape-cli.mjs --fold <findings-file>
30
+ node epic-shape-cli.mjs --close <epic>
31
+ node epic-shape-cli.mjs --help
32
+
33
+ Only --close writes: the epic header state becomes landed.
34
+ Exit codes: 0 accept, 1 refuse, 2 usage.`;
35
+
36
+ const parseArgv = (argv) => {
37
+ if (argv.length === HELP_ARITY && argv[0] === HELP_FLAG) return { help: true };
38
+ const [verb, path] = argv;
39
+ if (argv.length !== VERB_ARITY || !VERBS.includes(verb) || !path || path.startsWith('-')) {
40
+ throw fail(EXIT.usage, 'use --help alone, or exactly one verb followed by exactly one path that does not open with a dash');
41
+ }
42
+ return { verb, path: resolve(path) };
43
+ };
44
+ const makeFailure = (path, code, message) => Object.assign(fail(EXIT.refuse, message), { path, code });
45
+ const formatFinding = (finding, fallbackPath) => {
46
+ const path = finding.path ?? fallbackPath;
47
+ const prefix = `${path}: `;
48
+ const message = finding.message.startsWith(prefix) ? finding.message.slice(prefix.length) : finding.message;
49
+ return `${PREFIX} ${path}:${finding.line ?? FIRST_LINE}: ${finding.code}: ${message}`;
50
+ };
51
+ const printFindings = (findings, path, io) => {
52
+ for (const finding of findings) (finding.severity === INFO ? io.log : io.error)(formatFinding(finding, path));
53
+ };
54
+ const printAccept = (verb, path, log) => log(`${PREFIX} accept ${verb} ${path}`);
55
+ const printSweep = (sweep, log) => log(`${PREFIX} sweep ${sweep.root}: epics read ${sweep.counts.epicsRead + FIRST_LINE}; entries skipped ${sweep.counts.skipped}`);
56
+ const describeRead = (result) => result.code ?? result.className ?? result.outcome;
57
+ const openWritable = (path, flags) => openSync(path, flags | constants.O_RDWR);
58
+ const readInput = (path, read, writable) => {
59
+ const result = read(path, writable ? { keepFd: true, open: openWritable } : {});
60
+ if (result.outcome !== 'ok') throw makeFailure(path, 'read', describeRead(result));
61
+ return result;
62
+ };
63
+ const readQueue = (path, read) => {
64
+ if (!STORE_PATH.test(path)) return { outcome: 'unreadable', reason: 'epic is outside a project store' };
65
+ const queuePath = resolve(dirname(path), STORE_TO_PROJECT, QUEUE_PATH);
66
+ const result = read(queuePath);
67
+ return result.outcome === 'ok' ? result.content : { outcome: result.outcome, reason: describeRead(result) };
68
+ };
69
+
70
+ const writeAll = (fd, bytes, position, path, offset = 0) => {
71
+ if (offset === bytes.length) return;
72
+ const written = writeSync(fd, bytes, offset, bytes.length - offset, position + offset);
73
+ if (written <= 0) throw makeFailure(path, 'write', 'the descriptor write made no progress');
74
+ writeAll(fd, bytes, position, path, offset + written);
75
+ };
76
+ const writeState = (input, range, path) => {
77
+ const stats = fstatSync(input.fd);
78
+ if (stats.nlink !== FIRST_LINE) throw makeFailure(path, 'hardlink', 'close refuses a file with a second hard link');
79
+ if (!stats.isFile() || stats.dev !== input.dev || stats.ino !== input.ino || stats.size !== input.bytes.length) throw makeFailure(path, 'changed-file', 'the held file changed after the check');
80
+ const suffix = Buffer.concat([LANDED_BYTES, input.bytes.subarray(range.end)]);
81
+ writeAll(input.fd, suffix, range.start, path);
82
+ ftruncateSync(input.fd, range.start + suffix.length);
83
+ };
84
+ const runFold = (input, path, io) => {
85
+ const result = foldFindings(input.content);
86
+ io.log(`${PREFIX} kept (${result.counts.kept})`);
87
+ for (const entry of result.kept) io.log(`${entry.line}: ${entry.text}`);
88
+ io.log(`${PREFIX} discarded (${result.counts.discarded})`);
89
+ for (const entry of result.discarded) io.log(`${entry.line}: ${entry.reason} (line ${entry.reasonLine})\n${entry.text}`);
90
+ io.log(`${PREFIX} outside (${result.counts.outside})`);
91
+ for (const line of result.outside) io.log(`${line.line}: ${line.text}`);
92
+ printFindings(result.findings, path, io);
93
+ if (!result.ok) return EXIT.refuse;
94
+ printAccept(FOLD, path, io.log);
95
+ return EXIT.accept;
96
+ };
97
+ const runEpic = (verb, input, path, io) => {
98
+ const entries = readEpicEntries(dirname(path), io.read).filter((entry) => entry.name !== basename(path));
99
+ const shape = checkEpic(input.content, path);
100
+ const sweep = sweepSiblings(entries, dirname(path));
101
+ const claims = shape.findings.length === 0 && sweep.ok
102
+ ? checkClaims([shape.epic, ...sweep.epics]) : { findings: [] };
103
+ const findings = [...shape.findings, ...sweep.findings, ...claims.findings];
104
+ const output = verb === BRIEF ? { ...io, log: io.error } : io;
105
+ printSweep(sweep, output.log);
106
+ if (findings.some((finding) => finding.severity !== INFO)) {
107
+ printFindings(findings, path, output);
108
+ return EXIT.refuse;
109
+ }
110
+ if (verb === CLOSE) {
111
+ const closed = judgeClose({ epic: input.content, path, queue: readQueue(path, io.read), entries });
112
+ printFindings(closed.findings, path, io);
113
+ if (!closed.ok) return EXIT.refuse;
114
+ writeState(input, closed.stateRange, path);
115
+ } else {
116
+ printFindings(findings, path, output);
117
+ if (verb === BRIEF) {
118
+ const brief = renderBrief(input.content, path);
119
+ if (typeof brief !== 'string') { printFindings(brief.findings, path, output); return EXIT.refuse; }
120
+ io.log(brief.slice(0, -FIRST_LINE));
121
+ }
122
+ }
123
+ printAccept(verb, path, output.log);
124
+ return EXIT.accept;
125
+ };
126
+
127
+ export const main = (argv, { log = console.log, error = console.error, read = readRegularFileNoFollow } = {}) => {
128
+ try {
129
+ const options = parseArgv(argv);
130
+ if (options.help) { log(HELP); return EXIT.accept; }
131
+ const input = readInput(options.path, read, options.verb === CLOSE);
132
+ try {
133
+ const io = { log, error, read };
134
+ return options.verb === FOLD ? runFold(input, options.path, io) : runEpic(options.verb, input, options.path, io);
135
+ } finally {
136
+ if (input.fd !== undefined) closeSync(input.fd);
137
+ }
138
+ } catch (failure) {
139
+ error(formatFinding({ path: failure.path, line: FIRST_LINE, code: failure.code ?? 'usage', message: failure.message }, argv[1] ?? 'invocation'));
140
+ return failure.exitCode === EXIT.usage ? EXIT.usage : EXIT.refuse;
141
+ }
142
+ };
143
+
144
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(VERB_ARITY));
@@ -0,0 +1,160 @@
1
+ import { posix } from 'node:path';
2
+ import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
3
+ import { parseHeader, checkEpic } from './epic-shape.mjs';
4
+ import { auditQueue } from './queue-audit.mjs';
5
+ import { hasPathOverlap, findReachable, listStories, unique } from './claim-relation.mjs';
6
+
7
+ const FIRST_LINE = 1;
8
+ const ERROR = 'error';
9
+ const INFO = 'info';
10
+ const OPEN = 'open';
11
+ const LANDED = 'landed';
12
+ const EPIC = 'epic';
13
+ const OWN = 'owns';
14
+ const FRONT_DELIMITER = '---';
15
+ const CR_END = /\r$/;
16
+ const MARKDOWN_SUFFIX = '.md';
17
+ export const STORE_PATH = /^(?:(.*)\/)?docs\/ai\/epics\/([^/]+)\.md$/;
18
+ export const QUEUE_PATH = 'docs/plans/queue.md';
19
+ const TOKEN_EDGE = '[\\p{L}\\p{N}_-]';
20
+ const REGEX_BYTES = /[.*+?^${}()|[\]\\]/g;
21
+ const PARSE_REFUSALS = Object.freeze([1, 2]);
22
+ const makeFinding = (path, line, code, message, severity = ERROR) => ({ path, line, code, message: `${path}: ${message}`, severity });
23
+ const finishJudgement = (findings, data = {}) => ({ ...data, ok: !findings.some((finding) => finding.severity === ERROR), findings });
24
+ const nameFindings = (findings, path) => findings.map((finding) => makeFinding(path, finding.line, finding.code, finding.message));
25
+ const getIdentity = (story) => JSON.stringify([story.path, story.id]);
26
+
27
+ const readGraph = (epic, rows, stories) => {
28
+ const graph = new Map(rows.map((row) => [row.id, row.dependsOn]));
29
+ const byLine = new Map(stories.map((story) => [story.line, story.reachable]));
30
+ const reachable = new Map(rows.map((row) => [row.id, byLine.get(row.line) ?? findReachable(graph, row.dependsOn)]));
31
+ const unknowns = rows.flatMap((row) => row.dependsOn.filter((id) => !graph.has(id))
32
+ .map((id) => makeFinding(epic.path, row.line, 'dependency-id', `${row.id} depends on absent story ${id}`)));
33
+ const cyclic = rows.filter((row) => reachable.get(row.id).has(row.id));
34
+ const groups = unique(cyclic.map((row) => JSON.stringify(cyclic.filter((other) =>
35
+ reachable.get(row.id).has(other.id) && reachable.get(other.id).has(row.id)).map((other) => other.id))));
36
+ const cycles = groups.map((group) => {
37
+ const ids = JSON.parse(group);
38
+ return makeFinding(epic.path, rows.find((row) => row.id === ids[0]).line, 'dependency-cycle', `dependency cycle includes ${ids.join(', ')}`);
39
+ });
40
+ return { reachable, findings: [...unknowns, ...cycles] };
41
+ };
42
+
43
+ const hasClaimOverlap = (left, right) => left.ground.some((a) => right.ground.some((b) => hasPathOverlap(a, b)));
44
+ const checkPair = (left, right) => {
45
+ if (getIdentity(left) === getIdentity(right)) return [];
46
+ const sameEpic = left.path === right.path;
47
+ const ordered = sameEpic && (left.reachable.has(right.id) || right.reachable.has(left.id));
48
+ return left.claims.flatMap((a) => right.claims.flatMap((b) => {
49
+ if (!hasClaimOverlap(a, b)) return [];
50
+ const owners = a.field === OWN && b.field === OWN;
51
+ if (!owners && ordered) return [];
52
+ const code = owners ? 'owns-overlap' : sameEpic ? 'shared-order' : 'shared-cross-epic';
53
+ const severity = owners || sameEpic ? ERROR : INFO;
54
+ const message = `${left.id} ${a.field} ${a.path} overlaps ${right.path} ${right.id} ${b.field} ${b.path}`;
55
+ return [makeFinding(left.path, left.line, code, message, severity)];
56
+ }));
57
+ };
58
+
59
+ export const checkClaims = (epics) => {
60
+ const parsed = epics.map((epic) => {
61
+ const rows = epic.rows;
62
+ const stories = listStories([epic]);
63
+ const graph = readGraph(epic, rows, stories);
64
+ const findings = rows.filter((row) => !row.valid)
65
+ .map((row) => makeFinding(epic.path, row.line, 'row-grammar', `${row.id} has invalid story fields or claim tokens`));
66
+ return { stories: stories.filter((story) => story.state !== LANDED),
67
+ findings: [...nameFindings(epic.findings, epic.path), ...findings, ...graph.findings] };
68
+ });
69
+ const stories = parsed.flatMap((epic) => epic.stories);
70
+ const collisions = stories.flatMap((left, index) => stories.slice(index + FIRST_LINE).flatMap((right) => checkPair(left, right)));
71
+ return finishJudgement([...parsed.flatMap((epic) => epic.findings), ...collisions]);
72
+ };
73
+
74
+ const readSiblingHeader = (text, path) => {
75
+ const lines = text.split('\n');
76
+ if (lines[0].replace(CR_END, '') !== FRONT_DELIMITER) return { fields: null, findings: [] };
77
+ const end = lines.findIndex((line, index) => index > 0 && line.replace(CR_END, '') === FRONT_DELIMITER);
78
+ if (end < 0) return { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', 'frontmatter opens but never closes')] };
79
+ try {
80
+ const frontmatter = tokenizeMarkdown(`${lines.slice(0, end + FIRST_LINE).join('\n')}\n`, path).frontmatter;
81
+ const { fields } = parseHeader(frontmatter);
82
+ const valid = Object.keys(fields).length === end - FIRST_LINE;
83
+ return valid ? { fields, findings: [] }
84
+ : { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', 'frontmatter contains an unreadable or repeated field')] };
85
+ } catch (error) {
86
+ if (!PARSE_REFUSALS.includes(error.exitCode)) throw error;
87
+ return { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', error.message)] };
88
+ }
89
+ };
90
+ const readSibling = (entry, root) => {
91
+ const skipped = { read: 0, skipped: FIRST_LINE, epics: [], findings: [] };
92
+ if (entry.outcome === 'directory' || entry.kind === 'directory') return skipped;
93
+ if (!entry.name.endsWith(MARKDOWN_SUFFIX)) return skipped;
94
+ const path = posix.join(root, entry.name);
95
+ if (typeof entry.text !== 'string' || entry.outcome) {
96
+ return { read: 0, skipped: 0, epics: [], findings: [makeFinding(path, FIRST_LINE, 'sibling-read', `${entry.outcome ?? 'unreadable'}: ${entry.reason ?? entry.kind ?? 'no readable text'}`)] };
97
+ }
98
+ const header = readSiblingHeader(entry.text, path);
99
+ if (header.findings.length) return { read: 0, skipped: 0, epics: [], findings: header.findings };
100
+ if (header.fields?.type !== EPIC) return skipped;
101
+ if (![OPEN, LANDED].includes(header.fields.state)) {
102
+ return { read: 0, skipped: 0, epics: [], findings: [makeFinding(path, FIRST_LINE, 'sibling-state', 'epic state is missing or outside open and landed')] };
103
+ }
104
+ if (header.fields.state === LANDED) return { read: FIRST_LINE, skipped: 0, epics: [], findings: [], landedEpics: [entry.name.slice(0, -MARKDOWN_SUFFIX.length)] };
105
+ const { epic, findings } = checkEpic(entry.text, path);
106
+ return { read: FIRST_LINE, skipped: 0, epics: findings.length ? [] : [epic], findings: nameFindings(findings, path) };
107
+ };
108
+
109
+ export const sweepSiblings = (entries, root) => {
110
+ if (!Array.isArray(entries)) {
111
+ return finishJudgement([makeFinding(root, FIRST_LINE, 'sibling-read', 'the direct entry list is unreadable')], { root, epics: [], counts: { epicsRead: 0, skipped: 0 }, landedEpics: [] });
112
+ }
113
+ const results = entries.map((entry) => readSibling(entry, root));
114
+ const epics = results.flatMap((result) => result.epics);
115
+ return finishJudgement(results.flatMap((result) => result.findings), {
116
+ root, epics, counts: { epicsRead: results.reduce((sum, result) => sum + result.read, 0), skipped: results.reduce((sum, result) => sum + result.skipped, 0) },
117
+ landedEpics: results.flatMap((result) => result.landedEpics ?? []),
118
+ });
119
+ };
120
+
121
+ const carriesToken = (title, id) => {
122
+ const escaped = id.replace(REGEX_BYTES, '\\$&');
123
+ return new RegExp(`(?<!${TOKEN_EDGE})${escaped}(?!${TOKEN_EDGE})`, 'u').test(title);
124
+ };
125
+ const checkQueueAbsence = (queue, path, id) => {
126
+ if (typeof queue !== 'string') {
127
+ return [makeFinding(path, FIRST_LINE, 'queue-read', `${queue?.outcome ?? 'absent'}: ${queue?.reason ?? 'no readable queue text'}`)];
128
+ }
129
+ try {
130
+ const { rows } = auditQueue(queue, { label: path });
131
+ return rows.filter((row) => row.id === id || carriesToken(row.title, id))
132
+ .map((row) => makeFinding(path, row.line, 'close-queue', `queue row still stands for ${id}: ${row.title}`));
133
+ } catch (error) {
134
+ if (!PARSE_REFUSALS.includes(error.exitCode)) throw error;
135
+ return [makeFinding(path, FIRST_LINE, 'queue-read', error.message)];
136
+ }
137
+ };
138
+
139
+ export const judgeClose = ({ epic: text, path, queue, entries }) => {
140
+ const checked = checkEpic(text, path);
141
+ const epic = checked.epic;
142
+ const base = { epic, stateRange: null };
143
+ if (checked.findings.length) return finishJudgement(nameFindings(checked.findings, path), base);
144
+ const store = STORE_PATH.exec(path);
145
+ if (!store) return finishJudgement([makeFinding(path, FIRST_LINE, 'close-path', 'close requires docs/ai/epics/<ID>.md in a project store')], base);
146
+ const root = store[1] === undefined ? '.' : store[1] || '/';
147
+ const queuePath = posix.join(root, QUEUE_PATH);
148
+ const sweep = sweepSiblings(entries, posix.dirname(path));
149
+ const claims = checkClaims([epic, ...sweep.epics.filter((sibling) => sibling.path !== posix.normalize(path))]);
150
+ const checks = [...sweep.findings, ...claims.findings];
151
+ const data = { ...base, root, queuePath, sweep };
152
+ if (!sweep.ok || !claims.ok) return finishJudgement(checks, data);
153
+ const stories = epic.rows.filter((row) => row.state !== LANDED)
154
+ .map((row) => makeFinding(path, row.line, 'close-stories', `${row.id} has not landed with a date`));
155
+ const result = epic.result ? [] : [makeFinding(path, FIRST_LINE, 'close-result', 'Acceptance has no Result line with a bare date')];
156
+ const state = epic.state === LANDED ? [makeFinding(path, FIRST_LINE, 'close-state', 'header state is already landed')] : [];
157
+ const findings = [...checks, ...stories, ...result, ...state, ...checkQueueAbsence(queue, queuePath, epic.id)];
158
+ const judged = finishJudgement(findings, data);
159
+ return { ...judged, stateRange: judged.ok ? epic.stateRange : null };
160
+ };
@@ -0,0 +1,232 @@
1
+ import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
2
+ import { bulletBlocks, getLineCount, unique } from './plan-shape.mjs';
3
+
4
+ export const EPIC_SECTIONS = Object.freeze([
5
+ '## Intent', '## Value', '## Non-goals', '## Acceptance', '## Specs', '## Stories ledger', '## Queue',
6
+ ]);
7
+ const [INTENT, , , ACCEPTANCE, , LEDGER, QUEUE] = EPIC_SECTIONS;
8
+ const HEADER_KEYS = Object.freeze(['type', 'lastUpdated', 'scope', 'staleAfter', 'owner', 'maxLines', 'state']);
9
+ const HEADER_STATES = Object.freeze(['open', 'landed']);
10
+ const STORY_STATES = Object.freeze(['planned', 'in-flight']);
11
+ const MAX_LINES = 60;
12
+ const MAX_ROW_BYTES = 200;
13
+ const FIELD_COUNT = 6;
14
+ const FIRST_LINE = 1;
15
+ const HEADER_TAIL_LINES = 2;
16
+ const MARKDOWN_REFUSAL = 1;
17
+ const FIELD_SEPARATOR = ' | ';
18
+ const NONE = 'none';
19
+ const EPIC_TYPE = 'epic';
20
+ const TITLE_PREFIX = '# Epic: ';
21
+ const RESULT_PREFIX = 'Result line:';
22
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
23
+ const DATE_TIME_SUFFIX = 'T00:00:00.000Z';
24
+ const STORY_ID = /^S[1-9]\d*$/;
25
+ const DEPENDENCIES = /^depends-on: (none|S[1-9]\d*(?:, S[1-9]\d*)*)$/;
26
+ const CLAIM_FIELD = /^(owns|shared): (.+)$/;
27
+ const CLAIM_FORBIDDEN = /[\s`()\*?\[\]{}|:#]/;
28
+ const STATE_FIELD = /^state: (planned|in-flight|landed (\d{4}-\d{2}-\d{2}))$/;
29
+ const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9]*):([ \t]*)(.*?)[ \t]*\r?$/;
30
+ const QUEUE_LINE = /^Row (\S+) in (\S(?:.*\S)?)$/;
31
+ const RESULT_LINE = /^Result line: (\d{4}-\d{2}-\d{2})$/;
32
+ const PLAN_PATH = /docs\/plans\/([^\s`()[\]{}<>|,:;"']+\.md)(?=$|[\s`()[\]{}<>|,:;"'.!?])/g;
33
+ const QUEUE_NAME = 'queue.md';
34
+ const FENCE_LINE = /^\s*(?:`{3,}[^`]*|~{3,}.*)$/;
35
+ const BACKTICK_RUN = /`+/g;
36
+ const CITATION = /[^\s`()[\]{}<>|:;,"'!?]+[./][^\s`()[\]{}<>|:;,"'!?]+:\d+(?![\dA-Za-z_])/;
37
+ const TOP_BULLET = /^-\s+\S/;
38
+ const BULLET_PREFIX = /^-\s+/;
39
+ const INDENTED_LINE = /^\s+\S/;
40
+ const MARKDOWN_ERROR_LINE = /:(\d+):/;
41
+ const makeFinding = (line, code, message) => ({ line, code, message });
42
+ const getFileLine = (document, index) => document.frontLines + index + FIRST_LINE;
43
+ const getSection = (epic, heading) => epic.sections.find((section) => section.heading === heading);
44
+ const getNonBlank = (entries) => entries.filter((entry) => entry.raw.trim() !== '');
45
+ const getEntries = (document, start, end) => document.lines.slice(start, end).map((raw, offset) => ({
46
+ raw, index: start + offset, line: getFileLine(document, start + offset),
47
+ }));
48
+ export const isEpicDate = (value) => {
49
+ if (!DATE_PATTERN.test(value)) return false;
50
+ const date = new Date(`${value}${DATE_TIME_SUFFIX}`);
51
+ return Number.isFinite(date.getTime()) && date.toISOString() === `${value}${DATE_TIME_SUFFIX}`;
52
+ };
53
+
54
+ export const findCitation = (line) => {
55
+ const match = CITATION.exec(line);
56
+ return match ? { text: match[0], index: match.index, end: match.index + match[0].length } : null;
57
+ };
58
+
59
+ const hasBacktickSpan = (line) => {
60
+ const runs = [...line.matchAll(BACKTICK_RUN)];
61
+ return runs.some((opening, index) => runs.slice(index + FIRST_LINE).some((closing) => closing[0].length === opening[0].length));
62
+ };
63
+
64
+ export const judgeAltitude = (line) => {
65
+ const form = FENCE_LINE.test(line) ? 'fence' : hasBacktickSpan(line) ? 'backtick' : findCitation(line) ? 'citation' : null;
66
+ return { below: form !== null, form };
67
+ };
68
+
69
+ const isClaim = (token) => {
70
+ if (!token || token === NONE || token.startsWith('/') || CLAIM_FORBIDDEN.test(token)) return false;
71
+ const segments = token.replace(/\/$/, '').split('/');
72
+ return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
73
+ };
74
+ const parseClaims = (field, name) => {
75
+ const match = CLAIM_FIELD.exec(field ?? '');
76
+ if (!match || match[1] !== name) return null;
77
+ if (match[2] === NONE) return [];
78
+ const claims = match[2].split(',').map((claim) => claim.trim());
79
+ return claims.every(isClaim) ? claims : null;
80
+ };
81
+
82
+ export const parseStoryRow = (blockLines) => {
83
+ const lines = blockLines.map((line) => line.replace(/\r$/, ''));
84
+ const raw = lines[0] ?? '';
85
+ const parts = raw.replace(BULLET_PREFIX, '').split(FIELD_SEPARATOR).map((part) => part.trim());
86
+ const [id, name, dependencyField, ownsField, sharedField, stateField] = parts;
87
+ const dependencies = DEPENDENCIES.exec(dependencyField ?? '');
88
+ const owns = parseClaims(ownsField, 'owns');
89
+ const shared = parseClaims(sharedField, 'shared');
90
+ const stateMatch = STATE_FIELD.exec(stateField ?? '');
91
+ const date = stateMatch?.[2] ?? null;
92
+ const state = date ? 'landed' : stateMatch?.[1] ?? null;
93
+ const bodyLines = lines.slice(FIRST_LINE);
94
+ const body = bodyLines.join('\n').trimEnd();
95
+ const outsideClaims = [id, name, dependencyField, stateField].join(FIELD_SEPARATOR);
96
+ const countedBytes = Buffer.byteLength(outsideClaims + (body ? `\n${body}` : ''), 'utf8');
97
+ const valid = TOP_BULLET.test(raw) && parts.length === FIELD_COUNT && STORY_ID.test(id ?? '') && Boolean(name)
98
+ && Boolean(dependencies) && owns !== null && shared !== null
99
+ && (STORY_STATES.includes(state) || (state === 'landed' && isEpicDate(date)))
100
+ && bodyLines.every((line) => line.trim() === '' || INDENTED_LINE.test(line));
101
+ return {
102
+ raw, id: id ?? null, name: name ?? null, valid, countedBytes, body, bodyLines,
103
+ dependsOn: dependencies ? (dependencies[1] === NONE ? [] : unique(dependencies[1].split(', '))) : [],
104
+ owns: owns ?? [], shared: shared ?? [], state, date,
105
+ altitudeLine: parts.length === FIELD_COUNT ? outsideClaims : raw,
106
+ };
107
+ };
108
+
109
+ export const parseHeader = (frontmatter) => {
110
+ const lines = frontmatter.split('\n');
111
+ const entries = lines.slice(FIRST_LINE, -HEADER_TAIL_LINES).map((raw, index) => {
112
+ const match = HEADER_FIELD.exec(raw);
113
+ const prefix = lines.slice(0, index + FIRST_LINE).join('\n') + '\n';
114
+ const start = match ? Buffer.byteLength(prefix + `${match[1]}:${match[2]}`, 'utf8') : null;
115
+ return { key: match?.[1], value: match?.[3], line: index + FIRST_LINE + FIRST_LINE,
116
+ range: match ? { start, end: start + Buffer.byteLength(match[3], 'utf8') } : null };
117
+ });
118
+ const fields = Object.fromEntries(entries.filter((entry) => entry.key).map((entry) => [entry.key, entry.value]));
119
+ const findings = entries.flatMap((entry) => !entry.key || !HEADER_KEYS.includes(entry.key) || entry.value === ''
120
+ ? [makeFinding(entry.line, 'frontmatter', 'frontmatter must contain only the seven non-empty epic fields')] : []);
121
+ for (const key of HEADER_KEYS) {
122
+ if (entries.filter((entry) => entry.key === key).length !== FIRST_LINE) {
123
+ findings.push(makeFinding(FIRST_LINE, 'frontmatter', `frontmatter must carry ${key} exactly once`));
124
+ }
125
+ }
126
+ if (fields.type !== EPIC_TYPE) findings.push(makeFinding(FIRST_LINE, 'frontmatter', 'type must be epic'));
127
+ if (fields.maxLines !== String(MAX_LINES)) findings.push(makeFinding(FIRST_LINE, 'max-lines', `maxLines is frozen at ${MAX_LINES}`));
128
+ if (!HEADER_STATES.includes(fields.state)) findings.push(makeFinding(FIRST_LINE, 'header-state', 'header state must be open or landed'));
129
+ const states = entries.filter((entry) => entry.key === 'state');
130
+ return { fields, stateRange: states.length === FIRST_LINE ? states[0].range : null, findings };
131
+ };
132
+
133
+ const parseSections = (document, titleHeading) => document.headings.filter((heading) => heading !== titleHeading).map((heading, index, headings) => {
134
+ const end = headings[index + FIRST_LINE]?.index ?? document.lines.length;
135
+ const lines = document.lines.slice(heading.index + FIRST_LINE, end);
136
+ const raw = document.lines.slice(heading.index, end).join('\n');
137
+ return { heading: heading.text, index: heading.index, line: getFileLine(document, heading.index), end, lines,
138
+ text: raw + (end < document.lines.length ? '\n' : ''), entries: getEntries(document, heading.index + FIRST_LINE, end) };
139
+ });
140
+
141
+ export const parseEpic = (text, path) => {
142
+ const source = String(text ?? '');
143
+ const epicPath = String(path ?? '');
144
+ const id = epicPath.split('/').at(-1).replace(/\.md$/, '');
145
+ const base = { text: source, path: epicPath, id, title: null, fields: {}, state: null, stateRange: null,
146
+ sections: [], rows: [], queue: null, result: null, resultLines: [], ledgerEntries: [], document: null, findings: [] };
147
+ try {
148
+ const document = tokenizeMarkdown(source, epicPath);
149
+ const header = parseHeader(document.frontmatter);
150
+ const titleHeading = document.headings.find((heading) => heading.level === FIRST_LINE && heading.text.startsWith(TITLE_PREFIX));
151
+ const sections = parseSections(document, titleHeading);
152
+ const ledger = sections.find((section) => section.heading === LEDGER);
153
+ const blocks = ledger ? bulletBlocks(document.lines, document.fencedLines, ledger.index + FIRST_LINE, ledger.end) : [];
154
+ const rows = blocks.map((block) => ({ ...parseStoryRow(block.lines), index: block.start,
155
+ line: getFileLine(document, block.start), span: block.span }));
156
+ const queueEntries = getNonBlank(sections.find((section) => section.heading === QUEUE)?.entries ?? []);
157
+ const queueMatch = queueEntries.length === FIRST_LINE ? QUEUE_LINE.exec(queueEntries[0].raw.trim()) : null;
158
+ const resultLines = (sections.find((section) => section.heading === ACCEPTANCE)?.entries ?? [])
159
+ .filter((entry) => entry.raw.trim().startsWith(RESULT_PREFIX));
160
+ const resultMatch = resultLines.length === FIRST_LINE ? RESULT_LINE.exec(resultLines[0].raw.trim()) : null;
161
+ return {
162
+ ...base, ...header, document, titleHeading, title: titleHeading?.text.slice(TITLE_PREFIX.length).trim() ?? null,
163
+ state: header.fields.state ?? null, sections, rows, ledgerEntries: ledger?.entries ?? [], resultLines,
164
+ queue: queueMatch ? { id: queueMatch[1], bucket: queueMatch[2], line: queueEntries[0].line } : null,
165
+ result: resultMatch && isEpicDate(resultMatch[1]) ? { date: resultMatch[1], line: resultLines[0].line } : null,
166
+ };
167
+ } catch (error) {
168
+ if (error.exitCode !== MARKDOWN_REFUSAL) throw error;
169
+ return { ...base, findings: [makeFinding(Number(MARKDOWN_ERROR_LINE.exec(error.message)?.[1] ?? FIRST_LINE), 'markdown', error.message)] };
170
+ }
171
+ };
172
+
173
+ const checkSections = (epic) => {
174
+ const { document, titleHeading, sections } = epic;
175
+ const findings = [];
176
+ if (!epic.title || document.headings[0] !== titleHeading) {
177
+ findings.push(makeFinding(titleHeading ? getFileLine(document, titleHeading.index) : FIRST_LINE, 'title', `the first heading must be ${TITLE_PREFIX}<title>`));
178
+ }
179
+ if (sections.length !== EPIC_SECTIONS.length || sections.some((section, index) => section.heading !== EPIC_SECTIONS[index])) {
180
+ findings.push(makeFinding(sections[0]?.line ?? FIRST_LINE, 'headings', 'the seven epic sections must appear exactly once in their literal order, with no other headings'));
181
+ }
182
+ const intent = getSection(epic, INTENT);
183
+ const outside = getEntries(document, 0, intent?.index ?? document.lines.length)
184
+ .filter((entry) => entry.index !== titleHeading?.index && entry.raw.trim() !== '');
185
+ for (const entry of outside) findings.push(makeFinding(entry.line, 'outside-section', 'every non-blank body line after the title must lie inside a section'));
186
+ return findings;
187
+ };
188
+
189
+ const checkClosedLines = (epic) => {
190
+ const findings = [];
191
+ if (!epic.queue || epic.queue.id !== epic.id) {
192
+ findings.push(makeFinding(getSection(epic, QUEUE)?.line ?? FIRST_LINE, 'queue', `Queue must hold exactly one line: Row ${epic.id} in <bucket>`));
193
+ }
194
+ if (epic.resultLines.length > FIRST_LINE || (epic.resultLines.length === FIRST_LINE && !epic.result)) {
195
+ findings.push(makeFinding(epic.resultLines[0].line, 'result-line', 'Acceptance admits at most one Result line: YYYY-MM-DD with a date and nothing else'));
196
+ }
197
+ return findings;
198
+ };
199
+
200
+ const checkRows = (epic) => {
201
+ const findings = [];
202
+ if (epic.rows.length === 0) findings.push(makeFinding(getSection(epic, LEDGER)?.line ?? FIRST_LINE, 'empty-ledger', 'the ledger must carry at least S1'));
203
+ const covered = new Set(epic.rows.flatMap((row) => Array.from({ length: row.span }, (_, offset) => row.index + offset)));
204
+ for (const entry of getNonBlank(epic.ledgerEntries)) {
205
+ if (!covered.has(entry.index)) findings.push(makeFinding(entry.line, 'ledger-line', 'ledger content must belong to a top-level story bullet'));
206
+ }
207
+ for (const [index, row] of epic.rows.entries()) {
208
+ if (!row.valid) findings.push(makeFinding(row.line, 'row-grammar', 'a story must carry the six ordered fields, canonical claims and a valid state'));
209
+ if (row.id !== `S${index + FIRST_LINE}`) findings.push(makeFinding(row.line, 'story-ids', 'story ids must run contiguously from S1'));
210
+ if (row.countedBytes > MAX_ROW_BYTES) findings.push(makeFinding(row.line, 'row-bytes', `${row.id} has ${row.countedBytes} bytes outside owns and shared; the cap is ${MAX_ROW_BYTES}`));
211
+ }
212
+ return findings;
213
+ };
214
+
215
+ const checkAltitude = (epic) => {
216
+ const rowLines = new Map(epic.rows.map((row) => [row.index, row.altitudeLine]));
217
+ return epic.document.lines.flatMap((line, index) => {
218
+ const judgement = judgeAltitude(rowLines.get(index) ?? line);
219
+ return judgement.below ? [makeFinding(getFileLine(epic.document, index), 'altitude', `${judgement.form} is below concept altitude`)] : [];
220
+ });
221
+ };
222
+ const checkPlanPaths = (text) => text.split('\n').flatMap((line, index) => [...line.matchAll(PLAN_PATH)]
223
+ .filter((match) => match[1] !== QUEUE_NAME)
224
+ .map((match) => makeFinding(index + FIRST_LINE, 'plan-path', `${match[0]} is ephemeral; only docs/plans/queue.md may be stored`)));
225
+
226
+ export const checkEpic = (text, path) => {
227
+ const epic = parseEpic(text, path);
228
+ const capFindings = getLineCount(epic.text) > MAX_LINES
229
+ ? [makeFinding(FIRST_LINE, 'line-cap', `the epic has ${getLineCount(epic.text)} lines; the cap is ${MAX_LINES}`)] : [];
230
+ const shapeFindings = epic.document ? [...checkSections(epic), ...checkClosedLines(epic), ...checkRows(epic), ...checkAltitude(epic)] : [];
231
+ return { findings: [...epic.findings, ...capFindings, ...checkPlanPaths(epic.text), ...shapeFindings], epic };
232
+ };
@@ -0,0 +1,33 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describeNonRegular, readRegularFileNoFollow } from './fs-read-nofollow.mjs';
4
+
5
+ export const EPICS_REL = 'docs/ai/epics';
6
+ const MARKDOWN_SUFFIX = '.md';
7
+ const ABSENT_CODE = 'ENOENT';
8
+ const REGULAR_FILE = 'regular file';
9
+ const describeRead = (result) => result.code ?? result.className ?? result.outcome;
10
+ const refuseStore = (error) => ({ outcome: 'refused', reason: error?.code ?? error?.message ?? String(error) });
11
+
12
+ export const readEpicEntries = (root, read) => readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => {
13
+ const name = entry.name;
14
+ if (entry.isDirectory()) return { name, outcome: 'directory' };
15
+ if (!name.endsWith(MARKDOWN_SUFFIX)) return { name, text: '' };
16
+ const result = read(join(root, name));
17
+ if (result.outcome === 'ok') return { name, text: result.content };
18
+ if (result.outcome === 'foreign') return { name, outcome: 'non-regular', kind: result.className };
19
+ return { name, outcome: 'unreadable', reason: describeRead(result) };
20
+ });
21
+
22
+ export const readEpicStore = (projectRoot, read = readRegularFileNoFollow) => {
23
+ const root = join(projectRoot, EPICS_REL);
24
+ const probe = (() => {
25
+ try { return { stats: lstatSync(root) }; }
26
+ catch (error) { return { result: error?.code === ABSENT_CODE ? { outcome: 'absent', entries: [] } : refuseStore(error) }; }
27
+ })();
28
+ if (probe.stats?.isDirectory()) {
29
+ try { return { outcome: 'ok', entries: readEpicEntries(root, read) }; }
30
+ catch (error) { return refuseStore(error); }
31
+ }
32
+ return probe.result ?? { outcome: 'refused', reason: probe.stats.isFile() ? REGULAR_FILE : describeNonRegular(probe.stats) };
33
+ };
@@ -4,13 +4,18 @@ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
4
4
  import { classIds, readShippedRobustnessLiterals } from './robustness-literals.mjs';
5
5
  import { segmentPrefixOf, validateSourceSizeConfig } from './source-size-config.mjs';
6
6
  import { getLineCount, isSweep, resolveAnchorCandidates, unique } from './plan-shape.mjs';
7
+ import { listStories } from './claim-relation.mjs';
8
+ import { EPICS_REL, readEpicStore } from './epic-store.mjs';
9
+ import { sweepSiblings } from './epic-shape-ledger.mjs';
10
+ import { PIN_FILE } from './plan-shape-ownership.mjs';
7
11
 
8
12
  const SOURCE_SIZE_REL = 'docs/ai/source-size.json';
9
13
  const PACKAGE_FILE = 'package.json';
10
- const PIN_FILE = 'package-content.test.mjs';
11
14
  const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules']);
12
15
  const NEGATED_CLASS = /\[[!^]/;
13
16
  const UNSUPPORTED_GLOB = /[()\\\u0000-\u001f]/;
17
+ const STORE_REFUSED = 'refused';
18
+ const FINDING_SEPARATOR = '; ';
14
19
 
15
20
  const usageError = (message, scope = 'repository') => Object.assign(new Error(message), { exitCode: 2, scope });
16
21
  const planError = (message) => usageError(message, 'plan');
@@ -200,18 +205,31 @@ const describePath = (root, path, practice, repoFiles, packageCache) => {
200
205
  };
201
206
  };
202
207
 
208
+ const readStoryFacts = (root) => {
209
+ const store = readEpicStore(root);
210
+ if (store.outcome === STORE_REFUSED) return { stories: [], landedEpics: [], storeRefusal: `${EPICS_REL}: ${store.reason}` };
211
+ const sweep = sweepSiblings(store.entries, EPICS_REL);
212
+ return {
213
+ stories: sweep.ok ? listStories(sweep.epics) : [],
214
+ landedEpics: sweep.ok ? sweep.landedEpics : [],
215
+ storeRefusal: sweep.ok ? null : sweep.findings.map((finding) => finding.message).join(FINDING_SEPARATOR),
216
+ };
217
+ };
218
+
203
219
  export const openRepo = (root, { robustnessDeps } = {}) => {
204
220
  const repoRoot = realpathSync(root);
205
- return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), robustClasses: loadRobustClasses(robustnessDeps), packageCache: new Map() };
221
+ return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), robustClasses: loadRobustClasses(robustnessDeps),
222
+ packageCache: new Map(), ...readStoryFacts(repoRoot) };
206
223
  };
207
224
 
208
225
  export const expandSweepPaths = (root, patterns) => expandPatterns(walkRegularFiles(realpathSync(root)), patterns);
209
226
 
210
227
  export const buildFacts = (root, { paths = [], robustnessDeps, repo = openRepo(root, { robustnessDeps }) } = {}) => {
211
- const { repoRoot, repoFiles, practice, robustClasses, packageCache } = repo;
228
+ const { repoRoot, repoFiles, practice, robustClasses, packageCache, stories, landedEpics, storeRefusal } = repo;
212
229
  const expansions = expandPatterns(repoFiles, paths.filter(isSweep));
213
230
  const allPaths = unique([...paths, ...Object.values(expansions).flat()]);
214
231
  const pathFacts = Object.fromEntries(allPaths.map((path) => [path, describePath(repoRoot, path, practice, repoFiles, packageCache)]));
215
232
  const candidates = (suffix, precedingPaths = []) => resolveAnchorCandidates(repoFiles, suffix, precedingPaths);
216
- return { capDeclared: practice.capDeclared, cap: practice.cap, robustClasses, pathFacts, expansions, repoFiles, candidates };
233
+ return { capDeclared: practice.capDeclared, cap: practice.cap, robustClasses, pathFacts, expansions, repoFiles, candidates,
234
+ stories, landedEpics, storeRefusal };
217
235
  };
@@ -0,0 +1,83 @@
1
+ import { posix } from 'node:path';
2
+ import { hasPathOverlap } from './claim-relation.mjs';
3
+
4
+ const STORY_LINE = /^Story: (S[1-9]\d*) of ([^\s/]+)$/;
5
+ const STORY_FIELDS = 'stories';
6
+ export const PIN_FILE = 'package-content.test.mjs';
7
+ const MARKDOWN_SUFFIX = '.md';
8
+ const FIRST_LINE = 1;
9
+ const LANDED = 'landed';
10
+ const OWNS = 'owns';
11
+ const SHARED = 'shared';
12
+ const STRING_TYPE = 'string';
13
+ export const PIN_VERBS = new Set(['create', 'delete']);
14
+ const CODES = Object.freeze({ line: 'story-line', store: 'story-store', landed: 'story-epic-landed',
15
+ unknown: 'story-unknown', owns: 'story-owns', shared: 'story-shared', scope: 'story-scope' });
16
+ const ONE_STORY = 'the plan names its story with one line';
17
+ const NAME_STORY = 'name the story that carries the file';
18
+ const ORDER_STORIES = 'declare a transitive depends-on order between the stories';
19
+ const makeFinding = (line, code, message, rowId = null) => ({ line, code, message, rowId });
20
+ const sortFindings = (findings) => ({ findings: findings.sort((left, right) => left.line - right.line) });
21
+ const hasIdentity = (left, right) => left.path === right.path && left.id === right.id;
22
+ const hasClaimCover = (claim, path) => claim.ground.some((ground) => hasPathOverlap(path, ground));
23
+ const hasOwnCover = (own, path) => own.claims.some((claim) => hasClaimCover(claim, posix.normalize(path)));
24
+
25
+ export const readStoryLine = (lines) => {
26
+ const [story = null, second] = lines.flatMap(({ text, line }) => {
27
+ const match = STORY_LINE.exec(text.trim());
28
+ if (!match) return [];
29
+ const [, id, epic] = match;
30
+ return [{ id, epic, line }];
31
+ });
32
+ return { story, findings: second ? [makeFinding(second.line, CODES.line, ONE_STORY)] : [] };
33
+ };
34
+
35
+ const findOwnStory = (story, facts) => {
36
+ if (!story) return { own: null, findings: [] };
37
+ const epics = facts.stories.filter((record) => posix.basename(record.path, MARKDOWN_SUFFIX) === story.epic);
38
+ const own = epics.find((record) => record.id === story.id) ?? null;
39
+ if (facts.landedEpics?.includes(story.epic)) {
40
+ return { own: null, findings: [makeFinding(story.line, CODES.landed, `${story.epic} is landed; name a story of an open epic`)] };
41
+ }
42
+ if (!own) {
43
+ const name = epics.length ? `${story.epic} ${story.id}` : story.epic;
44
+ return { own: null, findings: [makeFinding(story.line, CODES.unknown, `${name} is unknown; name a story present in an open epic`)] };
45
+ }
46
+ return { own, findings: [] };
47
+ };
48
+
49
+ const checkCollision = (row, path, record, claim, own) => {
50
+ if (!hasClaimCover(claim, posix.normalize(path))) return [];
51
+ const message = `${row.id} path ${path} overlaps ${record.path} ${record.id} ${claim.field} ${claim.path}`;
52
+ if (claim.field === OWNS) return [makeFinding(row.line, CODES.owns, message, row.id)];
53
+ if (claim.field !== SHARED) return [];
54
+ if (own && (record.path !== own.path || own.reachable.has(record.id) || record.reachable.has(own.id))) return [];
55
+ return [makeFinding(row.line, CODES.shared, `${message}; ${own ? ORDER_STORIES : NAME_STORY}`, row.id)];
56
+ };
57
+
58
+ const hasPinCover = (path, rows, facts, own) => posix.basename(path) === PIN_FILE && rows.some((row) => {
59
+ const fact = facts.pathFacts?.[row.path];
60
+ return PIN_VERBS.has(row.verb) && fact?.shipped === true && fact.pinTest === path && hasOwnCover(own, row.path);
61
+ });
62
+ const checkScope = (row, path, rows, facts, own) => {
63
+ if (!own || facts.pathFacts?.[path]?.inScope !== true || hasOwnCover(own, path) || hasPinCover(path, rows, facts, own)) return [];
64
+ return [makeFinding(row.line, CODES.scope,
65
+ `${row.id} path ${path} is in the declared practice and no claim of ${own.id} covers it`, row.id)];
66
+ };
67
+
68
+ export const checkStoryOwnership = ({ rows, goalLines, facts, getConcretePaths }) => {
69
+ const { story, findings } = readStoryLine(goalLines);
70
+ if (!Object.hasOwn(facts, STORY_FIELDS)) return { findings };
71
+ if (typeof facts.storeRefusal === STRING_TYPE) {
72
+ return sortFindings([...findings, makeFinding(FIRST_LINE, CODES.store, facts.storeRefusal)]);
73
+ }
74
+ const resolved = findOwnStory(story, facts);
75
+ const own = resolved.own;
76
+ const others = facts.stories.filter((record) => record.state !== LANDED && (!own || !hasIdentity(record, own)));
77
+ const validRows = rows.filter((row) => row.valid);
78
+ const relation = validRows.flatMap((row) => getConcretePaths(row, facts).flatMap((path) => [
79
+ ...others.flatMap((record) => record.claims.flatMap((claim) => checkCollision(row, path, record, claim, own))),
80
+ ...checkScope(row, path, validRows, facts, own),
81
+ ]));
82
+ return sortFindings([...findings, ...resolved.findings, ...relation]);
83
+ };
@@ -1,5 +1,8 @@
1
+ import { posix } from 'node:path';
1
2
  import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
2
3
  import { parseRobustTag } from './robustness-literals.mjs';
4
+ import { unique } from './claim-relation.mjs';
5
+ import { checkStoryOwnership, PIN_FILE, PIN_VERBS } from './plan-shape-ownership.mjs';
3
6
 
4
7
  export const PLAN_TITLE_PREFIX = '# Plan: ';
5
8
  export const PLAN_HEADINGS = Object.freeze([
@@ -31,7 +34,7 @@ const BULLET = /^-\s+\S/;
31
34
  export const getLineCount = (text) => text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
32
35
  export const isSweep = (path) => GLOB_BYTE.test(path);
33
36
  const endsWithPath = (path, suffix) => path === suffix || path.endsWith(`/${suffix}`);
34
- export const unique = (items) => [...new Set(items)];
37
+ export { unique } from './claim-relation.mjs';
35
38
  const getFindingLine = (document, index) => document.frontLines + index + 1;
36
39
  const makeFinding = (line, code, message, rowId = null) => ({ line, code, message, rowId });
37
40
  const getHeading = (document, text) => document.headings.find((heading) => heading.text === text);
@@ -54,7 +57,8 @@ const getFigure = (value) => Number(String(value).replace(/^~/, ''));
54
57
  const getConcretePaths = (row, facts) => isSweep(row.path) ? facts.expansions?.[row.path] ?? [] : [row.path];
55
58
  const getPathFact = (facts, path) => facts.pathFacts?.[path] ?? null;
56
59
  const getAnchorPath = (anchor) => anchor.replace(/:[1-9]\d*$/, '');
57
- const hasPathDefect = (path) => path.startsWith('/') || path.includes('\\') || path.split('/').includes('..');
60
+ const hasPathDefect = (path) => !(!path.includes('\\') &&
61
+ path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'));
58
62
  const hasRaiseBullet = (verificationBullets, path) => verificationBullets.some((bullet) =>
59
63
  bullet.includes('--write-baseline') && bullet.includes('--reason') && bullet.includes(path));
60
64
 
@@ -174,6 +178,22 @@ const checkHeadings = (text, document) => {
174
178
  return findings;
175
179
  };
176
180
 
181
+ const isPinRow = (row, path) => row.verb === 'modify' && row.path === path && posix.basename(row.path) === PIN_FILE;
182
+ const checkPinBindings = (rows, owners, facts) => {
183
+ if (!Object.hasOwn(facts, 'pathFacts')) return [];
184
+ return [...owners].flatMap(([path, group]) => {
185
+ const pins = group.filter((row) => isPinRow(row, path));
186
+ if (pins.length < 2) return [];
187
+ const taken = new Set();
188
+ return pins.flatMap((pin) => {
189
+ const above = rows.slice(0, rows.indexOf(pin)).findLast((row) => row.valid && PIN_VERBS.has(row.verb) && !taken.has(row) &&
190
+ getPathFact(facts, row.path)?.shipped === true && getPathFact(facts, row.path)?.pinTest === path);
191
+ if (above) { taken.add(above); return []; }
192
+ return [makeFinding(pin.line, 'pin-unbound', `${pin.id} is a repeated pin row with no untaken shipped create or delete above it`, pin.id)];
193
+ });
194
+ });
195
+ };
196
+
177
197
  const checkLedgerStructure = (parsed, facts) => {
178
198
  const findings = [];
179
199
  const nonBlank = parsed.entries.filter((entry) => entry.raw.trim() !== '');
@@ -206,11 +226,15 @@ const checkLedgerStructure = (parsed, facts) => {
206
226
  const owners = new Map();
207
227
  for (const row of parsed.rows.filter((item) => item.valid)) {
208
228
  for (const path of getConcretePaths(row, facts)) {
209
- const prior = owners.get(path);
210
- if (prior) findings.push(makeFinding(row.line, 'duplicate-path', `${path} is owned by both ${prior} and ${row.id}`, row.id));
211
- else owners.set(path, row.id);
229
+ const prior = owners.get(path) ?? [];
230
+ const conflicting = prior.find((owner) => !isPinRow(owner, path) || !isPinRow(row, path));
231
+ if (conflicting) {
232
+ findings.push(makeFinding(row.line, 'duplicate-path', `${path} is owned by both ${conflicting.id} and ${row.id}`, row.id));
233
+ }
234
+ owners.set(path, [...prior, row]);
212
235
  }
213
236
  }
237
+ findings.push(...checkPinBindings(parsed.rows, owners, facts));
214
238
  const last = nonBlank.at(-1);
215
239
  if (!parsed.total || (parsed.totals ?? []).length !== 1 || last?.line !== parsed.total?.line) {
216
240
  findings.push(makeFinding(last?.line ?? 1, 'total', 'the exact total line must be the last non-blank ledger line'));
@@ -236,9 +260,9 @@ const getAnchorCandidates = (row, preceding, facts) => {
236
260
  return facts.candidates(suffix, precedingPaths);
237
261
  };
238
262
 
239
- const getCurrentLines = (rows, facts, verbs) => rows
263
+ const getCurrentLines = (rows, facts, verbs) => unique(rows
240
264
  .filter((row) => row.valid && verbs.has(row.verb) && row.budget !== 'n/a')
241
- .flatMap((row) => getConcretePaths(row, facts))
265
+ .flatMap((row) => getConcretePaths(row, facts)))
242
266
  .reduce((sum, path) => sum + (getPathFact(facts, path)?.lines ?? 0), 0);
243
267
 
244
268
  const checkAuthoring = (parsed, facts) => {
@@ -335,7 +359,11 @@ const judge = (text, facts, stateRules) => {
335
359
  return { findings: [makeFinding(line, 'block-model', parsedResult.error.message)], skips: [], parsed: null };
336
360
  }
337
361
  const parsed = parsedResult.parsed;
338
- const structural = [...checkHeadings(String(text ?? ''), parsed.document), ...checkLedgerStructure(parsed, facts)];
362
+ const goal = getHeading(parsed.document, PLAN_HEADINGS[0]);
363
+ const goalLines = goal ? getSectionLines(parsed.document, PLAN_HEADINGS[0])
364
+ .map((text, offset) => ({ text, line: getFindingLine(parsed.document, goal.index + 1 + offset) })) : [];
365
+ const structural = [...checkHeadings(String(text ?? ''), parsed.document), ...checkLedgerStructure(parsed, facts),
366
+ ...checkStoryOwnership({ rows: parsed.rows, goalLines, facts, getConcretePaths }).findings];
339
367
  const state = stateRules ? stateRules(parsed, facts) : { findings: [], skips: [] };
340
368
  const findings = [...structural, ...state.findings].map((finding, index) => ({ ...finding, order: index }))
341
369
  .sort((left, right) => left.line - right.line || left.order - right.order)