projectstore-claude 0.30.14 → 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.
@@ -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.14",
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.14",
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 silently.
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 way it records its other amendments before the code is written.
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.14",
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: 1
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) cur.steps.add(contractNumberAscii(step[2]));
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
- for (const c of contracts) {
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
- const contracts = contractsOf.get(s) || parseSpecContracts(s) || [];
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 four)"),
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(["supersedes", "spec-covers", "spec-implements-adr", "epic-contains"]);
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 and epic-contains, both directions, breadth-first,
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
@@ -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 four)", true),
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.14",
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.14"
40
+ "projectstore": "=0.30.15"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"