projectstore-claude 0.30.13 → 0.30.15
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/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/agents/planner.md +4 -1
- package/node_modules/projectstore/agents/reviewer.md +4 -1
- package/node_modules/projectstore/commands/doctor.md +11 -0
- package/node_modules/projectstore/commands/graph.md +5 -1
- package/node_modules/projectstore/commands/story.md +4 -1
- package/node_modules/projectstore/docs/contracts-and-tests.md +55 -2
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scaffold/guides/spec.md +6 -2
- package/node_modules/projectstore/scripts/doctor.mjs +179 -10
- package/node_modules/projectstore/scripts/graph.mjs +12 -0
- package/node_modules/projectstore/scripts/lib.mjs +106 -0
- package/node_modules/projectstore/scripts/mcp.mjs +2 -2
- package/node_modules/projectstore/scripts/query.mjs +8 -2
- package/node_modules/projectstore/scripts/tokens.mjs +25 -16
- package/node_modules/projectstore/scripts/verbs.mjs +1 -1
- package/node_modules/projectstore/templates/de/spec.md.tmpl +5 -1
- package/node_modules/projectstore/templates/en/spec.md.tmpl +5 -1
- package/node_modules/projectstore/templates/es/spec.md.tmpl +5 -1
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +5 -1
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +5 -1
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +5 -1
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.30.
|
|
15
|
+
"version": "0.30.15",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "Evgenii Konev",
|
|
18
18
|
"email": "ekonev@smartandpoint.com",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
3
|
"displayName": "projectstore",
|
|
4
|
-
"version": "0.30.
|
|
4
|
+
"version": "0.30.15",
|
|
5
5
|
"description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Evgenii Konev @ SmartAndPoint",
|
|
@@ -44,7 +44,10 @@ spec, say so FIRST — under spec-first the spec must exist and be `active`
|
|
|
44
44
|
before implementation starts (suggest `/projectstore:spec`). Routing rule:
|
|
45
45
|
spec contracts = durable how; the story's `## Implementation Plan` = per-story
|
|
46
46
|
route (your output feeds it via `/projectstore:story plan`); `## Technical
|
|
47
|
-
Notes` = incidental constraints discovered during work.
|
|
47
|
+
Notes` = incidental constraints discovered during work. When the story changes
|
|
48
|
+
a covering spec's contracts, name each contract it will amend and the marker's
|
|
49
|
+
kind (`ADDED`, `MODIFIED` or `REMOVED`, in a `> amended YYYY-MM-DD by <ref>: …`
|
|
50
|
+
block under the contract; `guide spec` has the rules).
|
|
48
51
|
|
|
49
52
|
**Spec or no spec.** When the story has no covering spec, say whether it
|
|
50
53
|
needs one, and why. Work whose "how" is non-trivial, or that will touch three
|
|
@@ -37,7 +37,10 @@ covering spec: its Acceptance items attributed to this story (`— stories:
|
|
|
37
37
|
verify them exactly like the story's own criteria. A story closes only when
|
|
38
38
|
both sets are green and every covering spec is `active`. Report a covering
|
|
39
39
|
spec still in `draft` as a blocker under `spec_policy: required` (vault's
|
|
40
|
-
`.projectstore.json`).
|
|
40
|
+
`.projectstore.json`). When the story edited a spec's contracts, check that
|
|
41
|
+
each edit carries an amendment marker (`> amended YYYY-MM-DD by <ref>: <KIND> —
|
|
42
|
+
<why>` under the contract): from the vault's git history when the vault has
|
|
43
|
+
one, else from the story's own account; an unmarked edit is a finding.
|
|
41
44
|
|
|
42
45
|
**A declared opt-out, at close.** A story with `no_spec: "<reason>"` has no
|
|
43
46
|
covering spec on purpose. Compute its files (Output 4). If it touches three
|
|
@@ -194,6 +194,17 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
|
|
|
194
194
|
gates that have no date, `review-by` names an ADR due for review, and
|
|
195
195
|
`dry-run` heads a `--policy` report. None of them is repairable by `--fix`:
|
|
196
196
|
each is a judgement, or a `configure` step its message names.
|
|
197
|
+
- The amendment checks read the markers under a spec's contracts
|
|
198
|
+
(`> amended YYYY-MM-DD by <ref>: ADDED|MODIFIED|REMOVED — <why>`) in every
|
|
199
|
+
spec, under every policy: `amendment-form` (a marker that is not the grammar,
|
|
200
|
+
comes before the first contract or after a `REMOVED`; it counts for
|
|
201
|
+
nothing), `amendment-story` (an issue: the ref names no single story),
|
|
202
|
+
`amendment-link` (neither the story nor the spec lists the other) and
|
|
203
|
+
`amendment-date` (not a date, before the spec's `date:`, after tomorrow, or
|
|
204
|
+
before the previous marker under that contract). `citation-removed` is a
|
|
205
|
+
token in the project that cites a removed contract or step, with the why of
|
|
206
|
+
its marker. None of them is repairable by `--fix`: the marker and the test
|
|
207
|
+
are the author's text.
|
|
197
208
|
- The guide checks read the vault's `guides/` folder, the per-kind overrides
|
|
198
209
|
of the authoring guides: `guide-shape` (an override that does not parse, so
|
|
199
210
|
readers fall back to the package default), `guide-unknown` (a guide for a
|
|
@@ -51,6 +51,10 @@ vault-link-graph-derived-view-and-shared-link-resolver).
|
|
|
51
51
|
artifact's full typed neighborhood — outgoing AND incoming edges — in one
|
|
52
52
|
call. Node keys are full vault-relative paths, never short names.
|
|
53
53
|
- Edge kinds: wikilink, mdlink, supersedes, spec-covers, spec-implements-adr,
|
|
54
|
-
epic-contains, dead, ambiguous, out-of-scope. Nothing resolves
|
|
54
|
+
epic-contains, amends, dead, ambiguous, out-of-scope. Nothing resolves
|
|
55
|
+
silently. `amends` runs from a story to a spec one of its amendment markers
|
|
56
|
+
(`> amended YYYY-MM-DD by <ref>: <KIND> — <why>` under a contract) changes:
|
|
57
|
+
one edge per pair; a marker whose ref resolves to no story is a `dead` edge
|
|
58
|
+
from the spec.
|
|
55
59
|
- Hand-edits to graph.md never stick: doctor flags staleness, reconcile
|
|
56
60
|
repairs. Fix the source artifact, then regenerate.
|
|
@@ -118,7 +118,10 @@ the two cannot collide).
|
|
|
118
118
|
today's date, then one bullet per question with its answer; a later plan
|
|
119
119
|
run appends its own dated line to the same subsection. An answer that changes a
|
|
120
120
|
contract of the covering spec says which one, and the spec is amended
|
|
121
|
-
the
|
|
121
|
+
before the code is written: that contract's body is rewritten to the text
|
|
122
|
+
in force, with a marker under it,
|
|
123
|
+
`> amended YYYY-MM-DD by <EPIC-ID>/<story-id>: ADDED|MODIFIED|REMOVED — <why>`
|
|
124
|
+
(see `guide spec`).
|
|
122
125
|
|
|
123
126
|
3. **Fill the section content** in the returned `content` before preview:
|
|
124
127
|
- `plan` — write the Implementation Plan. When the story's `specs:` names a
|
|
@@ -7,7 +7,9 @@ policy, revised: contract scenarios and test citations that doctor counts,
|
|
|
7
7
|
and a declared per-story opt-out from a spec*, and decisions 6 and 7 of the
|
|
8
8
|
ADR *One authoring guide per artifact kind, read by the author before the
|
|
9
9
|
draft and by the critic after, overridable from the vault* (edge and failure
|
|
10
|
-
marks, and test stories)
|
|
10
|
+
marks, and test stories), and the ADR *An active spec is amended by a marked
|
|
11
|
+
block under the contract, naming the story, the date and the kind, which
|
|
12
|
+
doctor checks and the graph draws* (amendment markers).
|
|
11
13
|
|
|
12
14
|
All of it is optional. A spec without scenarios, and a test without a
|
|
13
15
|
citation, stay valid.
|
|
@@ -171,6 +173,52 @@ Two specs may not share an alias, and an alias may not be another spec's id.
|
|
|
171
173
|
A citation is a claim: the reviewer checks that a cited test asserts the
|
|
172
174
|
contract's outcome. An empty test with a token is a review finding.
|
|
173
175
|
|
|
176
|
+
## Amending a contract
|
|
177
|
+
|
|
178
|
+
A later story that changes a contract of an active spec rewrites the
|
|
179
|
+
contract's body to the text in force and puts a marker under it: a
|
|
180
|
+
blockquote after the contract and before the next one, whose first paragraph
|
|
181
|
+
reads
|
|
182
|
+
|
|
183
|
+
```markdown
|
|
184
|
+
4. **Exports.** The export runs nightly and keeps seven copies.
|
|
185
|
+
|
|
186
|
+
> amended 2026-11-10 by example/story-keep-seven: MODIFIED — seven copies, not three.
|
|
187
|
+
>
|
|
188
|
+
> Replaced:
|
|
189
|
+
>
|
|
190
|
+
> > keeps three copies
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `amended`, `by`, the kind and `Removed, see below.` stay English in every
|
|
194
|
+
locale. The ref is any form a spec's `stories:` entry takes
|
|
195
|
+
(`EPIC-ID/<story-id>`, the story part a short id or the full slug; a ref
|
|
196
|
+
without `EPIC-ID/` resolves to no story). The colon may be `:` or `:`, the
|
|
197
|
+
dash `—`, `–` or `-`. A marker may wrap: its first paragraph is read with
|
|
198
|
+
its lines joined.
|
|
199
|
+
- `MODIFIED`: the body holds the new text; the block quotes what it replaced
|
|
200
|
+
and why. A step keeps its number: a removed step stays as
|
|
201
|
+
`2. Removed, see below.`, a new step takes the list's next number.
|
|
202
|
+
- `ADDED`: a new contract takes the highest number in the file, removed ones
|
|
203
|
+
included, plus one. A number is never reused.
|
|
204
|
+
- `REMOVED`: the contract keeps its number and its heading, and its body
|
|
205
|
+
becomes `Removed, see below.` This is a tombstone. Nothing follows a
|
|
206
|
+
`REMOVED` marker; a rule that comes back is `ADDED` under a new number.
|
|
207
|
+
- The story and the spec list each other: the story in the spec's
|
|
208
|
+
`stories:`, the spec in the story's `specs:`. The amending story is a
|
|
209
|
+
covered story.
|
|
210
|
+
- Markers inside a fence or an HTML comment do not count, and neither does a
|
|
211
|
+
marker whose ref starts with `example/` (it is documentation, like the one
|
|
212
|
+
above). Older `> *Amended …` blocks are not read; the first marker under a
|
|
213
|
+
contract folds them into the body and quotes them inside its own block.
|
|
214
|
+
|
|
215
|
+
A tombstone stays forever, so a citation keeps its meaning: a test that
|
|
216
|
+
still cites `[example#4]` after contract 4 was removed is reported as
|
|
217
|
+
`citation-removed`, with the marker's why, and the removed contract leaves
|
|
218
|
+
the coverage count. Evidence in a closed story that names it is not
|
|
219
|
+
reported. `graph.md` and `lineage` show an `amends` edge from the story to
|
|
220
|
+
the spec.
|
|
221
|
+
|
|
174
222
|
## What doctor reports
|
|
175
223
|
|
|
176
224
|
Doctor (`--vault`) scans the bound project with `git grep --untracked`, so a
|
|
@@ -182,11 +230,16 @@ skipped. Tests outside the bound project, or in a submodule, are not seen.
|
|
|
182
230
|
|---|---|---|
|
|
183
231
|
| `contract-coverage` | info | One line per active spec. With no citation: "N contracts, 0 cited". Once a spec has a citation, or sets `coverage: report` in its frontmatter: the contracts no test cites |
|
|
184
232
|
| `citation-unknown` | warn | A token whose key is no spec's id or alias, or whose number is not a contract of that spec |
|
|
233
|
+
| `citation-removed` | warn | A token that cites a removed contract, or a step that reads `Removed, see below.`; the message quotes the marker's why |
|
|
185
234
|
| `citation-superseded` | info | A token that cites a superseded spec |
|
|
186
235
|
| `scenario-shape` | info | A scenario without a WHEN/GIVEN or a THEN |
|
|
187
236
|
| `contract-happy-path-only` | info | One line per active spec: the contracts that carry scenarios and have none marked `(edge)` or `(failure)` |
|
|
188
237
|
| `spec-alias` | issue | A duplicate alias, an alias equal to another spec's id, or the reserved `example` |
|
|
189
|
-
| `evidence-citation` | warn | Under `lifecycle_gates: on`: a story's evidence names a token that no file in the project carries |
|
|
238
|
+
| `evidence-citation` | warn | Under `lifecycle_gates: on`: a story's evidence names a token that no file in the project carries (a removed contract is skipped) |
|
|
239
|
+
| `amendment-form` | warn | A blockquote under the contracts that starts `amended <date> by` but is not the grammar, comes before the first contract, or follows a `REMOVED`; it counts for nothing |
|
|
240
|
+
| `amendment-story` | issue | A marker's ref names no story, or more than one |
|
|
241
|
+
| `amendment-link` | warn | Neither the marker's story nor the spec lists the other (one side only is `spec-links`) |
|
|
242
|
+
| `amendment-date` | warn | A marker's date is not a date, is before the spec's `date:`, after tomorrow, or before the previous marker under the same contract |
|
|
190
243
|
|
|
191
244
|
Each report names the checkout it read. A worktree on an old branch, or a
|
|
192
245
|
second project bound to the same vault, can see different coverage.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.15",
|
|
4
4
|
"description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"project-management",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
guide_for: spec
|
|
3
|
-
guide_version:
|
|
3
|
+
guide_version: 2
|
|
4
4
|
default_review: true
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -18,6 +18,10 @@ The systems analyst who writes the contract a test can be written from before th
|
|
|
18
18
|
- Mandatory sections present (How we solve / Behavioral contracts / Acceptance); Modules-on-disk and Testing may be omitted only for research spikes.
|
|
19
19
|
- For a spike spec: contracts read as questions-to-answer plus exit criteria.
|
|
20
20
|
- No TODOs, no '...', no empty bullets in the final body.
|
|
21
|
+
- A change to an active spec's contract carries a marker under it, `> amended YYYY-MM-DD by <EPIC-ID>/<story-id>: ADDED|MODIFIED|REMOVED — <why>`; no new block is written in the older `> *Amended …` form.
|
|
22
|
+
- No contract or step number is reused: ADDED takes the highest number in the file, removed contracts included, plus one; REMOVED keeps the number and heading with the body `Removed, see below.`, and a removed step stays as `N. Removed, see below.`
|
|
23
|
+
- Under MODIFIED the body is rewritten to the text in force, and the block quotes the text it replaced; a change written only in the block, over an unchanged body, fails.
|
|
24
|
+
- The first marker under a contract folds that contract's older blocks into the body without changing their meaning, and quotes them inside its own block.
|
|
21
25
|
|
|
22
26
|
## Common mistakes
|
|
23
27
|
|
|
@@ -36,4 +40,4 @@ The systems analyst who writes the contract a test can be written from before th
|
|
|
36
40
|
|
|
37
41
|
## Length
|
|
38
42
|
|
|
39
|
-
A contract is one paragraph and at most six scenario items. A spec past twelve contracts or 400 lines covers two subsystems: split it.
|
|
43
|
+
A contract is one paragraph and at most six scenario items. A spec past twelve contracts or 400 lines covers two subsystems: split it. Blocks under contracts (amendment markers and the text they quote) do not count toward this length.
|
|
@@ -52,6 +52,9 @@ import {
|
|
|
52
52
|
storiesAttributionRe,
|
|
53
53
|
citationTokenRe,
|
|
54
54
|
contractNumberAscii,
|
|
55
|
+
parseAmendmentMarkers,
|
|
56
|
+
isCalendarDate,
|
|
57
|
+
TOMBSTONE_BODY,
|
|
55
58
|
CITATION_EXAMPLE_KEY,
|
|
56
59
|
CITATION_PREFILTER,
|
|
57
60
|
slugIdentity,
|
|
@@ -2460,6 +2463,116 @@ export function checkSpecLinks(cfg, layout, artifacts) {
|
|
|
2460
2463
|
return out;
|
|
2461
2464
|
}
|
|
2462
2465
|
|
|
2466
|
+
// Amendment markers (the ADR "An active spec is amended by a marked block
|
|
2467
|
+
// under the contract…", decisions 1, 4 and 5; SPEC-001 contracts 5 and 6),
|
|
2468
|
+
// in every spec whatever its status, under every policy:
|
|
2469
|
+
// amendment-form (warn) a candidate that is not the grammar, a marker
|
|
2470
|
+
// before the first contract or after REMOVED;
|
|
2471
|
+
// such a marker counts for nothing;
|
|
2472
|
+
// amendment-story (issue) the ref resolves to no story, or to several;
|
|
2473
|
+
// amendment-link (warn) neither the story nor the spec lists the other
|
|
2474
|
+
// (one side only is checkSpecLinks' warn);
|
|
2475
|
+
// amendment-date (warn) not a calendar date, before the spec's `date:`,
|
|
2476
|
+
// after tomorrow, or before the previous marker
|
|
2477
|
+
// under the same contract.
|
|
2478
|
+
// One finding per spec and fact: two markers of one unlinked story warn once.
|
|
2479
|
+
export function checkAmendments(layout, artifacts, now = new Date().toISOString()) {
|
|
2480
|
+
const out = [];
|
|
2481
|
+
const epicFolder = folderByKind(layout, "epic");
|
|
2482
|
+
const resolveSpec = buildSpecResolver(artifacts, layout);
|
|
2483
|
+
const t = Date.parse(now);
|
|
2484
|
+
const tomorrow = new Date((Number.isFinite(t) ? t : Date.now()) + 86400000).toISOString().slice(0, 10);
|
|
2485
|
+
for (const spec of artifacts.filter((a) => a.kind === "spec")) {
|
|
2486
|
+
const markers = parseAmendmentMarkers(spec.body);
|
|
2487
|
+
if (!markers.length) continue;
|
|
2488
|
+
const seen = new Set();
|
|
2489
|
+
const once = (key, f) => {
|
|
2490
|
+
if (seen.has(key)) return;
|
|
2491
|
+
seen.add(key);
|
|
2492
|
+
out.push(f);
|
|
2493
|
+
};
|
|
2494
|
+
const specDate = String(spec.fm.date || "").slice(0, 10);
|
|
2495
|
+
const prevDate = new Map();
|
|
2496
|
+
for (const m of markers) {
|
|
2497
|
+
const where = m.contract === null ? `line ${m.line}` : `contract ${m.contract} (line ${m.line})`;
|
|
2498
|
+
if (!m.valid) {
|
|
2499
|
+
out.push(
|
|
2500
|
+
finding(
|
|
2501
|
+
"vault",
|
|
2502
|
+
"warn",
|
|
2503
|
+
"amendment-form",
|
|
2504
|
+
`Amendment marker at ${where} counts for nothing: ${m.form}.`,
|
|
2505
|
+
spec.rel,
|
|
2506
|
+
),
|
|
2507
|
+
);
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
if (!isCalendarDate(m.date)) {
|
|
2511
|
+
out.push(
|
|
2512
|
+
finding(
|
|
2513
|
+
"vault",
|
|
2514
|
+
"warn",
|
|
2515
|
+
"amendment-date",
|
|
2516
|
+
`Amendment marker at ${where}: ${m.date} is not a date.`,
|
|
2517
|
+
spec.rel,
|
|
2518
|
+
),
|
|
2519
|
+
);
|
|
2520
|
+
} else {
|
|
2521
|
+
const prev = prevDate.get(m.index);
|
|
2522
|
+
let why = null;
|
|
2523
|
+
if (isCalendarDate(specDate) && m.date < specDate) why = `before the spec's date ${specDate}`;
|
|
2524
|
+
else if (m.date > tomorrow) why = `after tomorrow (${tomorrow})`;
|
|
2525
|
+
else if (prev && m.date < prev) why = `before the previous marker under the same contract (${prev})`;
|
|
2526
|
+
if (why) {
|
|
2527
|
+
out.push(
|
|
2528
|
+
finding(
|
|
2529
|
+
"vault",
|
|
2530
|
+
"warn",
|
|
2531
|
+
"amendment-date",
|
|
2532
|
+
`Amendment marker at ${where} is dated ${m.date}, ${why}.`,
|
|
2533
|
+
spec.rel,
|
|
2534
|
+
),
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
prevDate.set(m.index, m.date);
|
|
2538
|
+
}
|
|
2539
|
+
const r = epicFolder
|
|
2540
|
+
? resolveSpecStory(m.ref, artifacts, epicFolder.path)
|
|
2541
|
+
: { error: "the layout has no epic folder" };
|
|
2542
|
+
if (r.error) {
|
|
2543
|
+
once(
|
|
2544
|
+
`story ${m.ref}`,
|
|
2545
|
+
finding(
|
|
2546
|
+
"vault",
|
|
2547
|
+
"issue",
|
|
2548
|
+
"amendment-story",
|
|
2549
|
+
`Amendment marker at ${where} names "${m.ref}", which resolves to no single story: ${r.error}.`,
|
|
2550
|
+
spec.rel,
|
|
2551
|
+
),
|
|
2552
|
+
);
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
const storyListsSpec = listOf(r.story.fm, "specs").some((ref) => resolveSpec(ref) === spec);
|
|
2556
|
+
const specListsStory = listOf(spec.fm, "stories").some(
|
|
2557
|
+
(e) => resolveSpecStory(e, artifacts, epicFolder.path).story === r.story,
|
|
2558
|
+
);
|
|
2559
|
+
if (!storyListsSpec && !specListsStory) {
|
|
2560
|
+
once(
|
|
2561
|
+
`link ${r.story.rel}`,
|
|
2562
|
+
finding(
|
|
2563
|
+
"vault",
|
|
2564
|
+
"warn",
|
|
2565
|
+
"amendment-link",
|
|
2566
|
+
`Amendment marker at ${where} names ${r.story.rel}, but neither lists the other: add the spec to the story's \`specs:\` and the story to the spec's \`stories:\`.`,
|
|
2567
|
+
spec.rel,
|
|
2568
|
+
),
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
return out;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2463
2576
|
// In-scope story = status beyond planned, not legacy-exempt.
|
|
2464
2577
|
function specScopeStatus(fm) {
|
|
2465
2578
|
const s = String(fm.status || "").toLowerCase();
|
|
@@ -2691,6 +2804,11 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
|
|
|
2691
2804
|
// is not a contract. Scenario items are `- WHEN|GIVEN|AND|THEN …` list items
|
|
2692
2805
|
// under a contract; the `(verified: live|manual)` marker is looked for in the
|
|
2693
2806
|
// contract's first paragraph, which a wrapped bold heading may span.
|
|
2807
|
+
//
|
|
2808
|
+
// Amendment markers (SPEC-001 contract 5) set `removed` on a contract whose
|
|
2809
|
+
// last valid marker is REMOVED (a tombstone, with that marker's `why`), and a
|
|
2810
|
+
// step is removed when its body, trimmed, is exactly `Removed, see below.`
|
|
2811
|
+
// (`removedSteps`). `why` is the latest valid marker's, whatever its kind.
|
|
2694
2812
|
export function parseSpecContracts(spec) {
|
|
2695
2813
|
const sec = sectionOf(spec.body || "", "spec_contracts");
|
|
2696
2814
|
if (sec === null) return null;
|
|
@@ -2701,6 +2819,8 @@ export function parseSpecContracts(spec) {
|
|
|
2701
2819
|
// The scenario item a continuation line belongs to (SPEC-001 contract 13):
|
|
2702
2820
|
// a line indented deeper than its dash, not a bullet, before a blank line.
|
|
2703
2821
|
let open = null;
|
|
2822
|
+
// The step a wrapped line belongs to, for the tombstone test.
|
|
2823
|
+
let stepOpen = null;
|
|
2704
2824
|
for (const line of lines) {
|
|
2705
2825
|
const top = line.match(/^(\d+[′″']*)\.(\s|$)/);
|
|
2706
2826
|
if (top) {
|
|
@@ -2708,6 +2828,7 @@ export function parseSpecContracts(spec) {
|
|
|
2708
2828
|
n: contractNumberAscii(top[1]),
|
|
2709
2829
|
steps: new Set(),
|
|
2710
2830
|
stepIndent: null,
|
|
2831
|
+
stepText: new Map(),
|
|
2711
2832
|
scenarios: [],
|
|
2712
2833
|
marker: null,
|
|
2713
2834
|
marks: [],
|
|
@@ -2716,22 +2837,34 @@ export function parseSpecContracts(spec) {
|
|
|
2716
2837
|
contracts.push(cur);
|
|
2717
2838
|
inFirstPara = true;
|
|
2718
2839
|
open = null;
|
|
2840
|
+
stepOpen = null;
|
|
2719
2841
|
continue;
|
|
2720
2842
|
}
|
|
2721
2843
|
if (!cur) continue;
|
|
2722
2844
|
if (/^\s*$/.test(line)) {
|
|
2723
2845
|
inFirstPara = false;
|
|
2724
2846
|
open = null;
|
|
2847
|
+
stepOpen = null;
|
|
2725
2848
|
continue;
|
|
2726
2849
|
}
|
|
2727
2850
|
if (inFirstPara) cur.first += " " + line.trim();
|
|
2728
2851
|
const step = line.match(/^(\s+)(\d+[′″']*)\.(\s|$)/);
|
|
2729
2852
|
if (step) {
|
|
2730
2853
|
if (cur.stepIndent === null) cur.stepIndent = step[1].length;
|
|
2731
|
-
if (step[1].length === cur.stepIndent)
|
|
2854
|
+
if (step[1].length === cur.stepIndent) {
|
|
2855
|
+
const n = contractNumberAscii(step[2]);
|
|
2856
|
+
cur.steps.add(n);
|
|
2857
|
+
cur.stepText.set(n, line.slice(step[0].length));
|
|
2858
|
+
stepOpen = n;
|
|
2859
|
+
} else stepOpen = null;
|
|
2732
2860
|
open = null;
|
|
2733
2861
|
continue;
|
|
2734
2862
|
}
|
|
2863
|
+
if (stepOpen !== null) {
|
|
2864
|
+
if (line.match(/^\s*/)[0].length > cur.stepIndent && !/^\s*([-*+>]|\d+[′″']*\.)(\s|$)/.test(line))
|
|
2865
|
+
cur.stepText.set(stepOpen, cur.stepText.get(stepOpen) + " " + line.trim());
|
|
2866
|
+
else stepOpen = null;
|
|
2867
|
+
}
|
|
2735
2868
|
const sc = line.match(/^(\s*)-\s+(WHEN|GIVEN|AND|THEN)\b/);
|
|
2736
2869
|
if (sc) {
|
|
2737
2870
|
cur.scenarios.push(line.trim());
|
|
@@ -2742,14 +2875,20 @@ export function parseSpecContracts(spec) {
|
|
|
2742
2875
|
cur.scenarios[open.i] += " " + line.trim();
|
|
2743
2876
|
else open = null;
|
|
2744
2877
|
}
|
|
2745
|
-
|
|
2878
|
+
const markers = parseAmendmentMarkers(spec.body || "").filter((m) => m.valid);
|
|
2879
|
+
contracts.forEach((c, i) => {
|
|
2746
2880
|
const mk = c.first.match(/\(verified\s*[::]\s*(live|manual)\)/);
|
|
2747
2881
|
c.marker = mk ? mk[1] : null;
|
|
2748
2882
|
// An (edge) or (failure) mark closes a WHEN or GIVEN item; nowhere else counts.
|
|
2749
2883
|
c.marks = c.scenarios.map((l) => /^-\s+(WHEN|GIVEN)\b.*\((edge|failure)\)\s*$/.exec(l)?.[2]).filter(Boolean);
|
|
2884
|
+
const last = markers.filter((m) => m.index === i).at(-1) ?? null;
|
|
2885
|
+
c.removed = last?.kind === "REMOVED";
|
|
2886
|
+
c.why = last?.why ?? null;
|
|
2887
|
+
c.removedSteps = new Set([...c.stepText].filter(([, t]) => t.trim() === TOMBSTONE_BODY).map(([n]) => n));
|
|
2750
2888
|
delete c.first;
|
|
2751
2889
|
delete c.stepIndent;
|
|
2752
|
-
|
|
2890
|
+
delete c.stepText;
|
|
2891
|
+
});
|
|
2753
2892
|
return contracts;
|
|
2754
2893
|
}
|
|
2755
2894
|
|
|
@@ -2826,11 +2965,12 @@ export function checkSpecAliases(artifacts) {
|
|
|
2826
2965
|
|
|
2827
2966
|
// Happy path only (SPEC-001 contract 13; the authoring-guides ADR, decision
|
|
2828
2967
|
// 7): the contracts of an active spec that carry scenarios and have none
|
|
2829
|
-
// marked (edge) or (failure). Counted, not judged; no git.
|
|
2968
|
+
// marked (edge) or (failure). Counted, not judged; no git. A removed
|
|
2969
|
+
// contract (contract 5) is not reported, whatever its body still holds.
|
|
2830
2970
|
export function checkHappyPathOnly(artifacts) {
|
|
2831
2971
|
const out = [];
|
|
2832
2972
|
for (const s of artifacts.filter((a) => a.kind === "spec" && String(a.fm.status || "") === "active")) {
|
|
2833
|
-
const withScenarios = (parseSpecContracts(s) || []).filter((c) => c.scenarios.length);
|
|
2973
|
+
const withScenarios = (parseSpecContracts(s) || []).filter((c) => !c.removed && c.scenarios.length);
|
|
2834
2974
|
const bare = withScenarios.filter((c) => !c.marks.length).map((c) => c.n);
|
|
2835
2975
|
if (!bare.length) continue;
|
|
2836
2976
|
out.push(
|
|
@@ -2847,12 +2987,12 @@ export function checkHappyPathOnly(artifacts) {
|
|
|
2847
2987
|
}
|
|
2848
2988
|
|
|
2849
2989
|
// Scenario shape (Decision 3): a contract's scenario items need a WHEN (or
|
|
2850
|
-
// GIVEN) and a THEN.
|
|
2990
|
+
// GIVEN) and a THEN. Removed contracts are skipped.
|
|
2851
2991
|
export function checkScenarioShape(artifacts) {
|
|
2852
2992
|
const out = [];
|
|
2853
2993
|
for (const s of artifacts.filter((a) => a.kind === "spec")) {
|
|
2854
2994
|
for (const c of parseSpecContracts(s) || []) {
|
|
2855
|
-
if (!c.scenarios.length) continue;
|
|
2995
|
+
if (c.removed || !c.scenarios.length) continue;
|
|
2856
2996
|
const when = c.scenarios.some((l) => /^-\s+(WHEN|GIVEN)\b/.test(l));
|
|
2857
2997
|
const then = c.scenarios.some((l) => /^-\s+THEN\b/.test(l));
|
|
2858
2998
|
if (!when || !then) {
|
|
@@ -2961,6 +3101,21 @@ export function checkContractCitations(artifacts, scan) {
|
|
|
2961
3101
|
if (!contractsOf.has(spec)) contractsOf.set(spec, parseSpecContracts(spec) || []);
|
|
2962
3102
|
const [top, step] = t.n.split("/");
|
|
2963
3103
|
const c = contractsOf.get(spec).find((x) => x.n === top);
|
|
3104
|
+
if (c && (c.removed || (step && c.removedSteps.has(step)))) {
|
|
3105
|
+
// A tombstone (SPEC-001 contract 8): reported instead of unknown, with
|
|
3106
|
+
// the why of the contract's latest valid marker, and no coverage.
|
|
3107
|
+
const what = c.removed ? `contract ${top}` : `step ${t.n}`;
|
|
3108
|
+
out.push(
|
|
3109
|
+
finding(
|
|
3110
|
+
"vault",
|
|
3111
|
+
"warn",
|
|
3112
|
+
"citation-removed",
|
|
3113
|
+
`[${t.key}#${t.n}] cites ${what} of ${spec.rel}, which is removed${c.why ? `: "${c.why}"` : ""} — move the test to the rule that replaced it, or drop the token.`,
|
|
3114
|
+
where,
|
|
3115
|
+
),
|
|
3116
|
+
);
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
2964
3119
|
if (!c || (step && !c.steps.has(step))) {
|
|
2965
3120
|
out.push(
|
|
2966
3121
|
finding("vault", "warn", "citation-unknown", `[${t.key}#${t.n}] — ${spec.rel} has no contract ${t.n}.`, where),
|
|
@@ -2992,7 +3147,8 @@ export function checkContractCitations(artifacts, scan) {
|
|
|
2992
3147
|
return f;
|
|
2993
3148
|
};
|
|
2994
3149
|
for (const s of artifacts.filter((a) => a.kind === "spec" && String(a.fm.status || "") === "active")) {
|
|
2995
|
-
|
|
3150
|
+
// Tombstones are not counted (SPEC-001 contract 8).
|
|
3151
|
+
const contracts = (contractsOf.get(s) || parseSpecContracts(s) || []).filter((c) => !c.removed);
|
|
2996
3152
|
if (!contracts.length) continue;
|
|
2997
3153
|
const got = cited.get(s) || new Set();
|
|
2998
3154
|
if (!got.size && String(s.fm.coverage || "") !== "report") {
|
|
@@ -3029,11 +3185,23 @@ export function checkContractCitations(artifacts, scan) {
|
|
|
3029
3185
|
// Evidence that cites a contract (Decision 10), behind lifecycle_gates: on.
|
|
3030
3186
|
// A token not found in the project is a warn for a story in progress or in
|
|
3031
3187
|
// review, and for a done story that is not legacy. Free-text evidence is
|
|
3032
|
-
// never checked, and doctor never runs a test.
|
|
3188
|
+
// never checked, and doctor never runs a test. Evidence naming a removed
|
|
3189
|
+
// contract or step is skipped (SPEC-001 contract 8): a closed story's record
|
|
3190
|
+
// does not warn when a later story removes what it cited.
|
|
3033
3191
|
export function checkEvidenceCitations(artifacts, vaultCfg, scan) {
|
|
3034
3192
|
if (!lifecycleGatesOn(vaultCfg) || !scan || scan.status !== "ok") return [];
|
|
3035
3193
|
const since = legacySince(vaultCfg);
|
|
3036
3194
|
const present = new Set(scan.tokens.map((t) => `${t.key}#${t.n}`));
|
|
3195
|
+
const keys = citationKeys(artifacts);
|
|
3196
|
+
const contractsOf = new Map();
|
|
3197
|
+
const tombstone = (key, n) => {
|
|
3198
|
+
const spec = keys.get(key);
|
|
3199
|
+
if (!spec) return false;
|
|
3200
|
+
if (!contractsOf.has(spec)) contractsOf.set(spec, parseSpecContracts(spec) || []);
|
|
3201
|
+
const [top, step] = n.split("/");
|
|
3202
|
+
const c = contractsOf.get(spec).find((x) => x.n === top);
|
|
3203
|
+
return Boolean(c && (c.removed || (step && c.removedSteps.has(step))));
|
|
3204
|
+
};
|
|
3037
3205
|
const evidenceRe = evidenceSuffixRe();
|
|
3038
3206
|
const out = [];
|
|
3039
3207
|
for (const story of artifacts.filter((a) => a.kind === "story")) {
|
|
@@ -3047,7 +3215,7 @@ export function checkEvidenceCitations(artifacts, vaultCfg, scan) {
|
|
|
3047
3215
|
const at = item.text.search(evidenceRe);
|
|
3048
3216
|
if (at === -1) continue;
|
|
3049
3217
|
for (const t of item.text.slice(at).matchAll(citationTokenRe())) {
|
|
3050
|
-
if (t[1] === CITATION_EXAMPLE_KEY || present.has(`${t[1]}#${t[2]}`)) continue;
|
|
3218
|
+
if (t[1] === CITATION_EXAMPLE_KEY || present.has(`${t[1]}#${t[2]}`) || tombstone(t[1], t[2])) continue;
|
|
3051
3219
|
out.push(
|
|
3052
3220
|
finding(
|
|
3053
3221
|
"vault",
|
|
@@ -3883,6 +4051,7 @@ export function runVaultChecks(
|
|
|
3883
4051
|
() => checkIndexHeaders(cfg, layout),
|
|
3884
4052
|
() => checkStoriesAndEpics(artifacts),
|
|
3885
4053
|
() => checkSpecLinks(cfg, layout, artifacts),
|
|
4054
|
+
() => checkAmendments(layout, artifacts, now),
|
|
3886
4055
|
() => checkSpecCoverage(artifacts, vaultCfg, layout),
|
|
3887
4056
|
() => checkSpecAcceptance(layout, artifacts, vaultCfg),
|
|
3888
4057
|
() => checkLifecycleGates(artifacts, vaultCfg),
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
extractLinks,
|
|
25
25
|
resolveLinkTarget,
|
|
26
26
|
storyMatchesEntry,
|
|
27
|
+
parseAmendmentMarkers,
|
|
27
28
|
isMain,
|
|
28
29
|
commandForm,
|
|
29
30
|
} from "./lib.mjs";
|
|
@@ -115,6 +116,17 @@ export function buildGraph(cfg, layout, { files = null } = {}) {
|
|
|
115
116
|
if (r.outcome === "node") addEdge(n.path, "spec-implements-adr", r.node.path);
|
|
116
117
|
else addResolved(n.path, r, entry);
|
|
117
118
|
}
|
|
119
|
+
// Amendment markers in the contracts section (spec contract 3, the
|
|
120
|
+
// third edge source; SPEC-001 contract 5), whatever the spec's status:
|
|
121
|
+
// a valid marker's ref resolves as a `stories:` entry does, and gives
|
|
122
|
+
// one `amends` edge story → spec per pair (dedup on the triple), or
|
|
123
|
+
// the dead or ambiguous edge its ref resolves to.
|
|
124
|
+
for (const m of parseAmendmentMarkers(n.body)) {
|
|
125
|
+
if (!m.valid) continue;
|
|
126
|
+
const r = epicsPath ? resolveStoryEntry(m.ref, index, epicsPath) : { outcome: "dead" };
|
|
127
|
+
if (r.outcome === "node") addEdge(r.node.path, "amends", n.path);
|
|
128
|
+
else addResolved(n.path, r, m.ref);
|
|
129
|
+
}
|
|
118
130
|
}
|
|
119
131
|
if (n.type === "story") {
|
|
120
132
|
for (const entry of refsOf(n.fm, "specs")) {
|
|
@@ -1885,6 +1885,112 @@ export function contractNumberAscii(n) {
|
|
|
1885
1885
|
return String(n).replace(/″/g, "''").replace(/′/g, "'");
|
|
1886
1886
|
}
|
|
1887
1887
|
|
|
1888
|
+
// Amendment markers (the ADR "An active spec is amended by a marked block
|
|
1889
|
+
// under the contract…", decisions 1 to 3; SPEC-001 contract 5). A marker is a
|
|
1890
|
+
// blockquote in the Behavioral contracts section, after a top-level contract
|
|
1891
|
+
// and before the next one, whose first paragraph (wrapped lines joined) reads
|
|
1892
|
+
// amended YYYY-MM-DD by <ref>: <KIND> — <why>
|
|
1893
|
+
// `amended`, `by`, the kinds and the tombstone body are machine tokens:
|
|
1894
|
+
// English in every locale. The colon is `:` or `:`, the separator `—`, `–` or
|
|
1895
|
+
// `-`. A first paragraph that starts `amended <date> by` in any case is a
|
|
1896
|
+
// candidate; one that is not the grammar is an `amendment-form` warn, and so
|
|
1897
|
+
// is a marker before the first contract or after a REMOVED marker under the
|
|
1898
|
+
// same contract. A ref whose epic part is the reserved example key is
|
|
1899
|
+
// documentation and is skipped whole. Fences and HTML comments do not count.
|
|
1900
|
+
export const AMENDMENT_KINDS = Object.freeze(["ADDED", "MODIFIED", "REMOVED"]);
|
|
1901
|
+
export const TOMBSTONE_BODY = "Removed, see below.";
|
|
1902
|
+
export const AMENDMENT_GRAMMAR = "amended YYYY-MM-DD by <ref>: ADDED|MODIFIED|REMOVED — <why>";
|
|
1903
|
+
const AMENDMENT_CANDIDATE_RE = /^amended\s+\S+\s+by(?:\s|$)/i;
|
|
1904
|
+
const AMENDMENT_REF_RE = /^amended\s+\S+\s+by\s+([^\s::]+)/i;
|
|
1905
|
+
const AMENDMENT_RE = /^amended (\d{4}-\d{2}-\d{2}) by ([^\s::]+)\s*[::]\s*(ADDED|MODIFIED|REMOVED)\s*[—–-]\s*(\S.*)$/;
|
|
1906
|
+
|
|
1907
|
+
// The markers of one spec body, in file order. Each is
|
|
1908
|
+
// { contract: "<n>" | null, index: <contract's position> | -1, line,
|
|
1909
|
+
// date, ref, kind, why, form: null | "<what is wrong>", valid }
|
|
1910
|
+
// where `line` is 1-based in the body, and `valid` means no form warn (a date
|
|
1911
|
+
// or link finding does not make a marker invalid).
|
|
1912
|
+
export function parseAmendmentMarkers(body) {
|
|
1913
|
+
const text = String(body || "");
|
|
1914
|
+
const m = text.match(headingLineRe("spec_contracts"));
|
|
1915
|
+
if (!m) return [];
|
|
1916
|
+
const start = m.index + m[0].length;
|
|
1917
|
+
const rest = text.slice(start);
|
|
1918
|
+
const next = rest.search(/^## /m);
|
|
1919
|
+
const sec = next === -1 ? rest : rest.slice(0, next);
|
|
1920
|
+
const lineBase = text.slice(0, start).split("\n").length; // 1-based line of sec's first line
|
|
1921
|
+
// Comments are blanked to their newlines, so line numbers survive.
|
|
1922
|
+
const lines = sec.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, "")).split("\n");
|
|
1923
|
+
const out = [];
|
|
1924
|
+
let index = -1;
|
|
1925
|
+
let contract = null;
|
|
1926
|
+
let fenced = false;
|
|
1927
|
+
let run = null;
|
|
1928
|
+
const flush = () => {
|
|
1929
|
+
if (!run) return;
|
|
1930
|
+
const quoted = run.lines.map((l) => l.replace(/^\s*>\s?/, ""));
|
|
1931
|
+
const firstPara = [];
|
|
1932
|
+
for (const q of quoted) {
|
|
1933
|
+
if (/^\s*$/.test(q)) break;
|
|
1934
|
+
firstPara.push(q.trim());
|
|
1935
|
+
}
|
|
1936
|
+
const para = firstPara.join(" ").trim();
|
|
1937
|
+
const at = run.line;
|
|
1938
|
+
run = null;
|
|
1939
|
+
if (!AMENDMENT_CANDIDATE_RE.test(para)) return;
|
|
1940
|
+
const refHit = para.match(AMENDMENT_REF_RE);
|
|
1941
|
+
if (refHit && refHit[1].split("/")[0] === CITATION_EXAMPLE_KEY) return;
|
|
1942
|
+
const g = para.match(AMENDMENT_RE);
|
|
1943
|
+
const entry = {
|
|
1944
|
+
contract,
|
|
1945
|
+
index,
|
|
1946
|
+
line: lineBase + at,
|
|
1947
|
+
date: g ? g[1] : null,
|
|
1948
|
+
ref: g ? g[2] : refHit ? refHit[1] : null,
|
|
1949
|
+
kind: g ? g[3] : null,
|
|
1950
|
+
why: g ? g[4].trim() : null,
|
|
1951
|
+
form: null,
|
|
1952
|
+
valid: false,
|
|
1953
|
+
};
|
|
1954
|
+
if (!g) entry.form = `the first paragraph is not \`${AMENDMENT_GRAMMAR}\``;
|
|
1955
|
+
else if (contract === null) entry.form = "it comes before the first contract";
|
|
1956
|
+
else if (out.some((x) => x.index === index && x.valid && x.kind === "REMOVED"))
|
|
1957
|
+
entry.form = `it follows a REMOVED marker under contract ${contract} (a restored rule is ADDED under a new number)`;
|
|
1958
|
+
entry.valid = entry.form === null;
|
|
1959
|
+
out.push(entry);
|
|
1960
|
+
};
|
|
1961
|
+
lines.forEach((line, i) => {
|
|
1962
|
+
if (/^\s*```/.test(line)) {
|
|
1963
|
+
flush();
|
|
1964
|
+
fenced = !fenced;
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (fenced) return;
|
|
1968
|
+
const top = line.match(/^(\d+[′″']*)\.(\s|$)/);
|
|
1969
|
+
if (top) {
|
|
1970
|
+
flush();
|
|
1971
|
+
index++;
|
|
1972
|
+
contract = contractNumberAscii(top[1]);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
if (/^\s*>/.test(line)) {
|
|
1976
|
+
if (!run) run = { line: i, lines: [] };
|
|
1977
|
+
run.lines.push(line);
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
flush();
|
|
1981
|
+
});
|
|
1982
|
+
flush();
|
|
1983
|
+
return out;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
// A date the calendar has (2026-02-30 is not one).
|
|
1987
|
+
export function isCalendarDate(s) {
|
|
1988
|
+
const m = String(s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
1989
|
+
if (!m) return false;
|
|
1990
|
+
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
1991
|
+
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3];
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1888
1994
|
// The body footer (`*Last updated: 2026-01-01*`) is content rather than a heading,
|
|
1889
1995
|
// but the lifecycle gates keep it in step with frontmatter `updated:`. Matching
|
|
1890
1996
|
// accepts every registered language; the rewrite preserves the file's OWN prefix
|
|
@@ -174,11 +174,11 @@ export const TOOLS = Object.freeze({
|
|
|
174
174
|
lineage: {
|
|
175
175
|
verb: "graph",
|
|
176
176
|
description:
|
|
177
|
-
"The typed ancestry and descent of one artifact: supersedes, spec-covers, spec-implements-adr, epic-contains, breadth-first to a depth (default 3).",
|
|
177
|
+
"The typed ancestry and descent of one artifact: supersedes, spec-covers, spec-implements-adr, epic-contains, amends, breadth-first to a depth (default 3).",
|
|
178
178
|
inputSchema: schema(
|
|
179
179
|
{
|
|
180
180
|
path: str("vault-relative path of the artifact"),
|
|
181
|
-
kind: strs("only these lineage kinds (a subset of the
|
|
181
|
+
kind: strs("only these lineage kinds (a subset of the five)"),
|
|
182
182
|
depth: int("how far to walk (default 3, maximum 10)"),
|
|
183
183
|
},
|
|
184
184
|
["path"],
|
|
@@ -67,7 +67,13 @@ export const SEARCH_PER_FILE_CAP = 3;
|
|
|
67
67
|
export const SEARCH_DEADLINE_MS = 5000;
|
|
68
68
|
export const SNIPPET_CELL = 160;
|
|
69
69
|
export const GRAPH_EDGE_CAP = 100;
|
|
70
|
-
export const LINEAGE_KINDS = Object.freeze([
|
|
70
|
+
export const LINEAGE_KINDS = Object.freeze([
|
|
71
|
+
"supersedes",
|
|
72
|
+
"spec-covers",
|
|
73
|
+
"spec-implements-adr",
|
|
74
|
+
"epic-contains",
|
|
75
|
+
"amends",
|
|
76
|
+
]);
|
|
71
77
|
export const LINEAGE_DEFAULT_DEPTH = 3;
|
|
72
78
|
export const LINEAGE_NODE_CAP = 50;
|
|
73
79
|
export const CODE_REFS_CAP = 100;
|
|
@@ -663,7 +669,7 @@ export function renderNeighbors(r) {
|
|
|
663
669
|
}
|
|
664
670
|
|
|
665
671
|
// The typed ancestry and descent of one artifact: supersedes, spec-covers,
|
|
666
|
-
// spec-implements-adr
|
|
672
|
+
// spec-implements-adr, epic-contains and amends, both directions, breadth-first,
|
|
667
673
|
// depth- and node-capped, cycle-safe. Body wikilinks are deliberately not
|
|
668
674
|
// lineage — walking them returns most of the vault, so `kinds` is validated
|
|
669
675
|
// against LINEAGE_KINDS — and containment is not walked from an epic down to
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
// false "1 call/turn" for every turn (each record holds ≤1 tool_use).
|
|
25
25
|
//
|
|
26
26
|
// COST is computed at current API list prices (PRICING below) with the cache
|
|
27
|
-
// multipliers: read 0.1× input
|
|
28
|
-
// carry the 5m/1h split). Under
|
|
29
|
-
//
|
|
27
|
+
// multipliers: read 0.1× input unless the model has its own read rate, 5m
|
|
28
|
+
// write 1.25×, 1h write 2× (the usage records carry the 5m/1h split). Under
|
|
29
|
+
// a subscription plan these dollars are a proxy for rate-limit consumption,
|
|
30
|
+
// not an invoice — the RATIOS hold either way.
|
|
30
31
|
// Historical price changes are not modeled; everything is priced at today's
|
|
31
32
|
// list. Unknown models are priced at 0 and reported.
|
|
32
33
|
//
|
|
@@ -94,28 +95,36 @@ const STAGE_OF = {
|
|
|
94
95
|
};
|
|
95
96
|
const STAGE_ORDER = ["authoring", "critique", "planning", "code-review", "curation", "upkeep", "other"];
|
|
96
97
|
|
|
97
|
-
// USD per MTok [input, output], API list prices as of 2026-
|
|
98
|
-
// (platform.claude.com pricing docs).
|
|
99
|
-
//
|
|
98
|
+
// USD per MTok [input, output, cache read?], API list prices as of 2026-11-10
|
|
99
|
+
// (platform.claude.com pricing docs). The third value is the cache-read rate
|
|
100
|
+
// where it is not 0.1× input (Fable 5.1 and 5.2: 0.025×; Opus 5.5 and
|
|
101
|
+
// Sonnet 5.5: 0.05×); writes stay 1.25× / 2× input. The `--json` output
|
|
102
|
+
// carries these arrays as they are, with two or three values.
|
|
100
103
|
export const PRICING = {
|
|
101
|
-
"claude-fable-5-
|
|
104
|
+
"claude-fable-5-2": [8, 40, 0.2],
|
|
105
|
+
"claude-fable-5-1": [10, 50, 0.25],
|
|
102
106
|
"claude-fable-5": [10, 50],
|
|
107
|
+
"claude-opus-5-5": [4, 20, 0.2],
|
|
103
108
|
"claude-opus-5": [5, 25],
|
|
104
109
|
"claude-opus-4-8": [5, 25],
|
|
105
110
|
"claude-opus-4-7": [5, 25],
|
|
106
|
-
"claude-sonnet-5": [2, 10
|
|
111
|
+
"claude-sonnet-5-5": [2, 10, 0.1],
|
|
112
|
+
"claude-sonnet-5": [2, 10],
|
|
107
113
|
"claude-sonnet-4-6": [3, 15],
|
|
114
|
+
// reads at the default 0.1× ($0.01); prompts up to 100K, beyond [0.5, 2.5] (not modeled)
|
|
115
|
+
"claude-haiku-5-5": [0.1, 0.5],
|
|
108
116
|
"claude-haiku-4-5": [1, 5],
|
|
109
117
|
};
|
|
110
118
|
|
|
111
119
|
// Transcripts carry variant ids ("claude-haiku-4-5-20251001", "opus",
|
|
112
120
|
// "claude-opus-5[1m]") — normalize before the PRICING lookup so they don't
|
|
113
|
-
// silently price at $0.
|
|
121
|
+
// silently price at $0. A bare alias names the newest model of its line, so
|
|
122
|
+
// an old record written as "haiku" prices at today's Haiku, not the one that ran.
|
|
114
123
|
const MODEL_ALIASES = {
|
|
115
|
-
fable: "claude-fable-5-
|
|
116
|
-
opus: "claude-opus-5",
|
|
117
|
-
sonnet: "claude-sonnet-5",
|
|
118
|
-
haiku: "claude-haiku-
|
|
124
|
+
fable: "claude-fable-5-2",
|
|
125
|
+
opus: "claude-opus-5-5",
|
|
126
|
+
sonnet: "claude-sonnet-5-5",
|
|
127
|
+
haiku: "claude-haiku-5-5",
|
|
119
128
|
};
|
|
120
129
|
|
|
121
130
|
export function normModel(model) {
|
|
@@ -126,9 +135,9 @@ export function normModel(model) {
|
|
|
126
135
|
export function rowCost(row) {
|
|
127
136
|
const p = PRICING[normModel(row.model)];
|
|
128
137
|
if (!p) return 0;
|
|
129
|
-
const [inP, outP] = p;
|
|
138
|
+
const [inP, outP, readP = 0.1 * inP] = p;
|
|
130
139
|
return (
|
|
131
|
-
(row.input * inP + row.cw5m * 1.25 * inP + row.cw1h * 2 * inP + row.cacheRead *
|
|
140
|
+
(row.input * inP + row.cw5m * 1.25 * inP + row.cw1h * 2 * inP + row.cacheRead * readP + row.output * outP) / 1e6
|
|
132
141
|
);
|
|
133
142
|
}
|
|
134
143
|
|
|
@@ -359,7 +368,7 @@ const n = (v) => v.toLocaleString("en-US");
|
|
|
359
368
|
|
|
360
369
|
// `t/turn` — avg distinct tool calls per tool-using turn (batching level; 1.0
|
|
361
370
|
// means fully sequential). `cost` — USD at current list prices; cache reads
|
|
362
|
-
// bill at
|
|
371
|
+
// bill at a small fraction of input, so token counts and dollars rank differently.
|
|
363
372
|
const usd = (v) => `$${v.toFixed(2)}`;
|
|
364
373
|
|
|
365
374
|
function table(groups, label, { runs = false } = {}) {
|
|
@@ -380,7 +380,7 @@ export const VERBS = Object.freeze([
|
|
|
380
380
|
requiresBinding: true,
|
|
381
381
|
mcp: Object.freeze(["neighbors", "lineage"]),
|
|
382
382
|
options: [
|
|
383
|
-
opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its
|
|
383
|
+
opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its five)", true),
|
|
384
384
|
opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"),
|
|
385
385
|
opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`),
|
|
386
386
|
opt("limit", "<n>", `neighbors: cap per direction (at most ${GRAPH_EDGE_CAP})`),
|
|
@@ -42,7 +42,11 @@ Stories — eine Story ist erst fertig, wenn beides grün ist (ADR-007).
|
|
|
42
42
|
(verified: live) oder (verified: manual)
|
|
43
43
|
Ein Test zitiert einen Vertrag als [example#1]: id oder alias der Spec, dann
|
|
44
44
|
die Vertragsnummer. Das Frontmatter kann `alias: <kurzer-schlüssel>` und
|
|
45
|
-
`coverage: report` ergänzen. Siehe docs/contracts-and-tests.md.
|
|
45
|
+
`coverage: report` ergänzen. Siehe docs/contracts-and-tests.md.
|
|
46
|
+
Eine spätere Änderung schreibt den Text des Vertrags neu und setzt darunter
|
|
47
|
+
eine Markierung (ADDED, MODIFIED oder REMOVED; ein entfernter Vertrag behält
|
|
48
|
+
Nummer und Überschrift, sein Text wird "Removed, see below."):
|
|
49
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
46
50
|
|
|
47
51
|
1.
|
|
48
52
|
2.
|
|
@@ -41,7 +41,11 @@ Spec acceptance is **additive** to the covered stories' own acceptance criteria
|
|
|
41
41
|
(verified: live) or (verified: manual)
|
|
42
42
|
A test cites a contract as [example#1]: the spec's id or alias, then the
|
|
43
43
|
contract number. Frontmatter may add `alias: <short-key>` and
|
|
44
|
-
`coverage: report`. See docs/contracts-and-tests.md.
|
|
44
|
+
`coverage: report`. See docs/contracts-and-tests.md.
|
|
45
|
+
A later change to a contract rewrites its body and puts a marker under it
|
|
46
|
+
(ADDED, MODIFIED or REMOVED; a removed contract keeps its number and its
|
|
47
|
+
heading, and its body becomes "Removed, see below."):
|
|
48
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
45
49
|
|
|
46
50
|
1.
|
|
47
51
|
2.
|
|
@@ -42,7 +42,11 @@ cubiertas: una historia se cierra cuando ambos están en verde (ADR-007).
|
|
|
42
42
|
(verified: live) o (verified: manual)
|
|
43
43
|
Una prueba cita un contrato así: [example#1] — el id o alias de la spec y
|
|
44
44
|
el número del contrato. El frontmatter puede añadir `alias: <clave-corta>`
|
|
45
|
-
y `coverage: report`. Ver docs/contracts-and-tests.md.
|
|
45
|
+
y `coverage: report`. Ver docs/contracts-and-tests.md.
|
|
46
|
+
Un cambio posterior reescribe el texto del contrato y pone una marca debajo
|
|
47
|
+
(ADDED, MODIFIED o REMOVED; un contrato eliminado conserva su número y su
|
|
48
|
+
título, y su texto pasa a ser "Removed, see below."):
|
|
49
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
46
50
|
|
|
47
51
|
1.
|
|
48
52
|
2.
|
|
@@ -42,7 +42,11 @@ couvertes : une story n'est finie que si les deux sont au vert (ADR-007).
|
|
|
42
42
|
(verified: live) ou (verified: manual)
|
|
43
43
|
Un test cite un contrat ainsi : [example#1] — l'id ou l'alias de la spec,
|
|
44
44
|
puis le numéro du contrat. Le frontmatter peut ajouter `alias: <clé-courte>`
|
|
45
|
-
et `coverage: report`. Voir docs/contracts-and-tests.md.
|
|
45
|
+
et `coverage: report`. Voir docs/contracts-and-tests.md.
|
|
46
|
+
Une modification ultérieure réécrit le texte du contrat et place une marque
|
|
47
|
+
dessous (ADDED, MODIFIED ou REMOVED ; un contrat supprimé garde son numéro et
|
|
48
|
+
son titre, et son texte devient "Removed, see below.") :
|
|
49
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
46
50
|
|
|
47
51
|
1.
|
|
48
52
|
2.
|
|
@@ -41,7 +41,11 @@ reviewed_at: null
|
|
|
41
41
|
(verified: live) или (verified: manual)
|
|
42
42
|
Тест ссылается на контракт так: [example#1] — id или alias спеки и номер
|
|
43
43
|
контракта. Во frontmatter можно добавить `alias: <короткий-ключ>` и
|
|
44
|
-
`coverage: report`. Подробнее: docs/contracts-and-tests.md.
|
|
44
|
+
`coverage: report`. Подробнее: docs/contracts-and-tests.md.
|
|
45
|
+
Поздняя правка контракта переписывает его текст и ставит под ним метку
|
|
46
|
+
(ADDED, MODIFIED или REMOVED; удалённый контракт сохраняет номер и
|
|
47
|
+
заголовок, а текст становится "Removed, see below."):
|
|
48
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
45
49
|
|
|
46
50
|
1.
|
|
47
51
|
2.
|
|
@@ -41,7 +41,11 @@ story 才算完成(ADR-007)。
|
|
|
41
41
|
(verified: live) 或 (verified: manual)
|
|
42
42
|
测试这样引用契约:[example#1],即 spec 的 id 或 alias,加上契约编号。
|
|
43
43
|
frontmatter 可以加 `alias: <短键>` 和 `coverage: report`。
|
|
44
|
-
详见 docs/contracts-and-tests.md。
|
|
44
|
+
详见 docs/contracts-and-tests.md。
|
|
45
|
+
之后修改契约时,重写契约正文,并在其下方加一条标记
|
|
46
|
+
(ADDED、MODIFIED 或 REMOVED;被删除的契约保留编号和标题,
|
|
47
|
+
正文改为 "Removed, see below."):
|
|
48
|
+
> amended YYYY-MM-DD by EPIC-ID/story-id: MODIFIED — <why> -->
|
|
45
49
|
|
|
46
50
|
1.
|
|
47
51
|
2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.15",
|
|
4
4
|
"description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"projectstore",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"projectstore": "=0.30.
|
|
40
|
+
"projectstore": "=0.30.15"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|