@sabaiway/agent-workflow-kit 12.4.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 +60 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/claim-relation.mjs +33 -0
- package/tools/epic-shape-cli.mjs +4 -13
- package/tools/epic-shape-ledger.mjs +11 -31
- package/tools/epic-store.mjs +33 -0
- package/tools/plan-shape-facts.mjs +22 -4
- package/tools/plan-shape-ownership.mjs +83 -0
- package/tools/plan-shape.mjs +36 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,66 @@ 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
|
+
|
|
7
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)
|
|
8
68
|
|
|
9
69
|
**MINOR: one new tool, four verbs, four shipped modules; no deployed gate line changes its verdict.** The epic this
|
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: '
|
|
6
|
+
version: '13.0.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "
|
|
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
|
+
});
|
package/tools/epic-shape-cli.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { constants, openSync, closeSync, fstatSync,
|
|
3
|
-
import { basename, dirname,
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, writeSync, ftruncateSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
4
|
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
5
5
|
import { isDirectRun } from './direct-run.mjs';
|
|
6
6
|
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
7
|
+
import { readEpicEntries } from './epic-store.mjs';
|
|
7
8
|
import { checkEpic } from './epic-shape.mjs';
|
|
8
9
|
import { checkClaims, sweepSiblings, judgeClose, STORE_PATH, QUEUE_PATH } from './epic-shape-ledger.mjs';
|
|
9
10
|
import { renderBrief, foldFindings } from './epic-shape-brief.mjs';
|
|
@@ -18,7 +19,6 @@ const HELP_FLAG = '--help';
|
|
|
18
19
|
const HELP_ARITY = 1;
|
|
19
20
|
const VERB_ARITY = 2;
|
|
20
21
|
const FIRST_LINE = 1;
|
|
21
|
-
const MARKDOWN_SUFFIX = '.md';
|
|
22
22
|
const PREFIX = 'epic-shape:';
|
|
23
23
|
const INFO = 'info';
|
|
24
24
|
const STORE_TO_PROJECT = '../../..';
|
|
@@ -60,15 +60,6 @@ const readInput = (path, read, writable) => {
|
|
|
60
60
|
if (result.outcome !== 'ok') throw makeFailure(path, 'read', describeRead(result));
|
|
61
61
|
return result;
|
|
62
62
|
};
|
|
63
|
-
const readDirectory = (root, read) => readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => {
|
|
64
|
-
const name = entry.name;
|
|
65
|
-
if (entry.isDirectory()) return { name, outcome: 'directory' };
|
|
66
|
-
if (!name.endsWith(MARKDOWN_SUFFIX)) return { name, text: '' };
|
|
67
|
-
const result = read(join(root, name));
|
|
68
|
-
if (result.outcome === 'ok') return { name, text: result.content };
|
|
69
|
-
if (result.outcome === 'foreign') return { name, outcome: 'non-regular', kind: result.className };
|
|
70
|
-
return { name, outcome: 'unreadable', reason: describeRead(result) };
|
|
71
|
-
});
|
|
72
63
|
const readQueue = (path, read) => {
|
|
73
64
|
if (!STORE_PATH.test(path)) return { outcome: 'unreadable', reason: 'epic is outside a project store' };
|
|
74
65
|
const queuePath = resolve(dirname(path), STORE_TO_PROJECT, QUEUE_PATH);
|
|
@@ -104,7 +95,7 @@ const runFold = (input, path, io) => {
|
|
|
104
95
|
return EXIT.accept;
|
|
105
96
|
};
|
|
106
97
|
const runEpic = (verb, input, path, io) => {
|
|
107
|
-
const entries =
|
|
98
|
+
const entries = readEpicEntries(dirname(path), io.read).filter((entry) => entry.name !== basename(path));
|
|
108
99
|
const shape = checkEpic(input.content, path);
|
|
109
100
|
const sweep = sweepSiblings(entries, dirname(path));
|
|
110
101
|
const claims = shape.findings.length === 0 && sweep.ok
|
|
@@ -2,7 +2,7 @@ import { posix } from 'node:path';
|
|
|
2
2
|
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
3
3
|
import { parseHeader, checkEpic } from './epic-shape.mjs';
|
|
4
4
|
import { auditQueue } from './queue-audit.mjs';
|
|
5
|
-
import { unique } from './
|
|
5
|
+
import { hasPathOverlap, findReachable, listStories, unique } from './claim-relation.mjs';
|
|
6
6
|
|
|
7
7
|
const FIRST_LINE = 1;
|
|
8
8
|
const ERROR = 'error';
|
|
@@ -11,14 +11,9 @@ const OPEN = 'open';
|
|
|
11
11
|
const LANDED = 'landed';
|
|
12
12
|
const EPIC = 'epic';
|
|
13
13
|
const OWN = 'owns';
|
|
14
|
-
const CLAIM_FIELDS = Object.freeze([OWN, 'shared']);
|
|
15
14
|
const FRONT_DELIMITER = '---';
|
|
16
15
|
const CR_END = /\r$/;
|
|
17
16
|
const MARKDOWN_SUFFIX = '.md';
|
|
18
|
-
const TRAILING_SLASH = /\/$/;
|
|
19
|
-
const SOURCE_EXTENSION = /^(.*[^/])(\.[^./]+)$/;
|
|
20
|
-
const TEST_FILE = /\.test\.[^/]+$/;
|
|
21
|
-
const TEST_SUFFIX = '.test';
|
|
22
17
|
export const STORE_PATH = /^(?:(.*)\/)?docs\/ai\/epics\/([^/]+)\.md$/;
|
|
23
18
|
export const QUEUE_PATH = 'docs/plans/queue.md';
|
|
24
19
|
const TOKEN_EDGE = '[\\p{L}\\p{N}_-]';
|
|
@@ -29,14 +24,10 @@ const finishJudgement = (findings, data = {}) => ({ ...data, ok: !findings.some(
|
|
|
29
24
|
const nameFindings = (findings, path) => findings.map((finding) => makeFinding(path, finding.line, finding.code, finding.message));
|
|
30
25
|
const getIdentity = (story) => JSON.stringify([story.path, story.id]);
|
|
31
26
|
|
|
32
|
-
const
|
|
33
|
-
const unseen = unique(pending.filter((id) => !visited.has(id)));
|
|
34
|
-
if (unseen.length === 0) return visited;
|
|
35
|
-
return findReachable(graph, unseen.flatMap((id) => graph.get(id) ?? []), new Set([...visited, ...unseen]));
|
|
36
|
-
};
|
|
37
|
-
const readGraph = (epic, rows) => {
|
|
27
|
+
const readGraph = (epic, rows, stories) => {
|
|
38
28
|
const graph = new Map(rows.map((row) => [row.id, row.dependsOn]));
|
|
39
|
-
const
|
|
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)]));
|
|
40
31
|
const unknowns = rows.flatMap((row) => row.dependsOn.filter((id) => !graph.has(id))
|
|
41
32
|
.map((id) => makeFinding(epic.path, row.line, 'dependency-id', `${row.id} depends on absent story ${id}`)));
|
|
42
33
|
const cyclic = rows.filter((row) => reachable.get(row.id).has(row.id));
|
|
@@ -49,17 +40,6 @@ const readGraph = (epic, rows) => {
|
|
|
49
40
|
return { reachable, findings: [...unknowns, ...cycles] };
|
|
50
41
|
};
|
|
51
42
|
|
|
52
|
-
const expandClaim = (path) => {
|
|
53
|
-
const source = SOURCE_EXTENSION.exec(path);
|
|
54
|
-
return source && !TEST_FILE.test(path)
|
|
55
|
-
? [path, `${source[1]}${TEST_SUFFIX}${source[2]}`, `${source[1]}${TEST_SUFFIX}/`] : [path];
|
|
56
|
-
};
|
|
57
|
-
const hasPathOverlap = (left, right) => {
|
|
58
|
-
const a = left.replace(TRAILING_SLASH, '');
|
|
59
|
-
const b = right.replace(TRAILING_SLASH, '');
|
|
60
|
-
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
61
|
-
};
|
|
62
|
-
const getClaims = (row) => CLAIM_FIELDS.flatMap((field) => row[field].map((path) => ({ field, path, ground: expandClaim(path) })));
|
|
63
43
|
const hasClaimOverlap = (left, right) => left.ground.some((a) => right.ground.some((b) => hasPathOverlap(a, b)));
|
|
64
44
|
const checkPair = (left, right) => {
|
|
65
45
|
if (getIdentity(left) === getIdentity(right)) return [];
|
|
@@ -79,13 +59,12 @@ const checkPair = (left, right) => {
|
|
|
79
59
|
export const checkClaims = (epics) => {
|
|
80
60
|
const parsed = epics.map((epic) => {
|
|
81
61
|
const rows = epic.rows;
|
|
82
|
-
const
|
|
62
|
+
const stories = listStories([epic]);
|
|
63
|
+
const graph = readGraph(epic, rows, stories);
|
|
83
64
|
const findings = rows.filter((row) => !row.valid)
|
|
84
65
|
.map((row) => makeFinding(epic.path, row.line, 'row-grammar', `${row.id} has invalid story fields or claim tokens`));
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
})) : [];
|
|
88
|
-
return { stories, findings: [...nameFindings(epic.findings, epic.path), ...findings, ...graph.findings] };
|
|
66
|
+
return { stories: stories.filter((story) => story.state !== LANDED),
|
|
67
|
+
findings: [...nameFindings(epic.findings, epic.path), ...findings, ...graph.findings] };
|
|
89
68
|
});
|
|
90
69
|
const stories = parsed.flatMap((epic) => epic.stories);
|
|
91
70
|
const collisions = stories.flatMap((left, index) => stories.slice(index + FIRST_LINE).flatMap((right) => checkPair(left, right)));
|
|
@@ -122,19 +101,20 @@ const readSibling = (entry, root) => {
|
|
|
122
101
|
if (![OPEN, LANDED].includes(header.fields.state)) {
|
|
123
102
|
return { read: 0, skipped: 0, epics: [], findings: [makeFinding(path, FIRST_LINE, 'sibling-state', 'epic state is missing or outside open and landed')] };
|
|
124
103
|
}
|
|
125
|
-
if (header.fields.state === LANDED) return { read: FIRST_LINE, skipped: 0, epics: [], findings: [] };
|
|
104
|
+
if (header.fields.state === LANDED) return { read: FIRST_LINE, skipped: 0, epics: [], findings: [], landedEpics: [entry.name.slice(0, -MARKDOWN_SUFFIX.length)] };
|
|
126
105
|
const { epic, findings } = checkEpic(entry.text, path);
|
|
127
106
|
return { read: FIRST_LINE, skipped: 0, epics: findings.length ? [] : [epic], findings: nameFindings(findings, path) };
|
|
128
107
|
};
|
|
129
108
|
|
|
130
109
|
export const sweepSiblings = (entries, root) => {
|
|
131
110
|
if (!Array.isArray(entries)) {
|
|
132
|
-
return finishJudgement([makeFinding(root, FIRST_LINE, 'sibling-read', 'the direct entry list is unreadable')], { root, epics: [], counts: { epicsRead: 0, skipped: 0 } });
|
|
111
|
+
return finishJudgement([makeFinding(root, FIRST_LINE, 'sibling-read', 'the direct entry list is unreadable')], { root, epics: [], counts: { epicsRead: 0, skipped: 0 }, landedEpics: [] });
|
|
133
112
|
}
|
|
134
113
|
const results = entries.map((entry) => readSibling(entry, root));
|
|
135
114
|
const epics = results.flatMap((result) => result.epics);
|
|
136
115
|
return finishJudgement(results.flatMap((result) => result.findings), {
|
|
137
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 ?? []),
|
|
138
118
|
});
|
|
139
119
|
};
|
|
140
120
|
|
|
@@ -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),
|
|
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
|
+
};
|
package/tools/plan-shape.mjs
CHANGED
|
@@ -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
|
|
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) =>
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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)
|