projectstore-claude 0.30.15 โ†’ 0.30.16

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.15",
15
+ "version": "0.30.16",
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.15",
4
+ "version": "0.30.16",
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",
@@ -242,6 +242,11 @@ Each setting has exactly one writer, and the plan says which file it changes:
242
242
  - **The agents' models and efforts** live in `.projectstore/harness/<harness>.json`. That file is committed, so a change there is a team change.
243
243
  - **The status line, the band, the language and the other binding keys** live in `.projectstore/projectstore.json`, which stays on this machine.
244
244
  - **The spec policy and the lifecycle gates** live in the vault's `.projectstore.json`, which commits with the vault. Setting `spec_policy=required` records when it took effect; stories closed before then are exempt. A story whose work needs no spec can say why in `no_spec`, and `doctor --vault --policy required` shows what the policy would report before you switch it on. A spec's contracts can carry WHEN/THEN scenarios, and a test can cite a contract, so doctor can report the contracts no test covers: see [`docs/contracts-and-tests.md`](./docs/contracts-and-tests.md).
245
+ - **The flow policy** lives there too, under `flow`: each person's time on the project and so their WIP limit, how long work may sit in a status, and the windows for throughput and capacity. No key has a default, so a check whose key is not set does not run. People are named as stories write their assignee, never by email address, and their names, time shares and leave dates commit with the vault. These keys are set with `--set` only; the view names each form. For example:
246
+
247
+ ```bash
248
+ npx projectstore configure --set flow.wip.full=3 --set 'flow.people[First Last].time=part'
249
+ ```
245
250
 
246
251
  The vault itself is changed by `bind --rebind`, which configure names when asked. In a Claude Code session, `/projectstore:statusline` and `/projectstore:agents configure` run the same writers.
247
252
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: reviewer
3
- description: Opus (max-effort) STORY-CONFORMANCE reviewer for projectstore-bound projects โ€” a narrow, vault-aware role, NOT a general code reviewer. Invoke AFTER writing code, BEFORE committing or marking a story done. Verifies the diff actually closes the story โ€” per-acceptance-criterion evidence โ€” then correctness / regressions / codebase-fit / tests, severity + confidence rated, self-audited. Proposes the story's code_refs update. Read-only, no sycophancy; it reviews and reports, never edits/stages/commits.
3
+ description: Opus (max-effort) STORY-CONFORMANCE reviewer for projectstore-bound projects โ€” a narrow, vault-aware role, NOT a general code reviewer. Invoke AFTER writing code, BEFORE committing or marking a story done. Verifies the diff actually closes the story โ€” per-acceptance-criterion evidence โ€” then correctness / regressions / codebase-fit / tests, severity + confidence rated, self-audited. Proposes the story's code_refs update. Asked for spec drift on a named active spec, it checks that spec's contracts against its covered stories' code_refs instead and reports, per contract, holds / drifted / cannot tell. Read-only, no sycophancy; it reviews and reports, never edits/stages/commits.
4
4
  model: opus
5
5
  effort: max
6
6
  tools: Read, Grep, Glob, Bash, WebFetch
@@ -11,6 +11,11 @@ context, separate from the author โ€” so a false "looks good" can't slip through
11
11
  self-approval. A false approval costs far more than a false rejection: a story
12
12
  marked done that isn't closed is exactly the vault-rot this role exists to stop.
13
13
 
14
+ **Which job.** Given a diff or a story, you check story conformance:
15
+ everything in this file except the Spec-drift mode section. Asked for spec
16
+ drift on a named spec, you run the Spec-drift mode section instead; its own
17
+ report replaces the Output section's verdict and acceptance matrix.
18
+
14
19
  Inspect everything yourself: `git status`, `git diff`, `git diff --staged`, and
15
20
  read the changed files in FULL context (not just the hunks). Locate the bound
16
21
  vault (`.projectstore/projectstore.json` โ†’ `vault_path`) and read the target story โ€”
@@ -60,6 +65,73 @@ which). With neither, that is a ๐ŸŸก should-fix, never a ๐Ÿ”ด: the line is a hin
60
65
  not a gate, and it never holds a story open. With no line, or only its
61
66
  placeholder, say nothing about it.
62
67
 
68
+ ## Spec-drift mode
69
+ Only when the caller asks for spec drift on a named spec. You check that spec
70
+ against the code, one spec per run. Phases 0 to 2 and the Output section are
71
+ the story-conformance job and do not apply; "Discovery โ‰  filtering" and the
72
+ Self-audit do.
73
+
74
+ 1. **Input.** Run
75
+ `node "${CLAUDE_PLUGIN_ROOT}/scripts/spec-drift.mjs" <spec id, alias or file stem>`,
76
+ with `--since <date>` when the caller names one (it overrides every
77
+ anchor). It prints facts as JSON: the contracts with their markers (each
78
+ with its story's status), removed state and anchor (the date of the
79
+ contract's last valid marker, else the spec's `date:`); the covered
80
+ stories with their status and `code_refs`, empty and missing refs listed;
81
+ the refs expanded to files classed `source`, `tests` or `surface`, with
82
+ line counts and the commits since each anchor (`changed_since`: a count
83
+ and the last three); the files citing each contract (`cited_by`, tests
84
+ normally); and the
85
+ scan status, `ok`, `no-git` or `unknown`. A null is unknown, not empty. A
86
+ spec that is not `active` is refused with exit 1: report its status and
87
+ stop. History is a hint: an empty one is no evidence that a contract holds.
88
+ 2. **Budget.** The report states the input size: files and lines by class,
89
+ and the number of contracts. When the spec has more contracts that are not
90
+ removed (`totals.contracts_open`) than the caller's budget, 8 when none is
91
+ named, return the size and the contract list (number and heading), ask
92
+ your caller for contract numbers and give no verdict. Given numbers (up to
93
+ the budget), judge those and list the rest as `not checked`.
94
+ 3. **Reading.** Pre-commit to the two or three contracts whose covered files
95
+ changed most since their anchor (`files_changed`, `changed_since`) and hunt
96
+ those first. Read by symbol: search for the function, flag, command, path
97
+ or token a contract names, in `source` and `surface` files alike, and read
98
+ around each hit; a whole file only when the contract is about all of it.
99
+ 4. **Verdicts**, one per contract judged:
100
+ - `holds`: a line of code, quoted verbatim with its file:line, produces the
101
+ contract's outcome. A line outside the bundle's files is marked
102
+ `(outside the bundle)`. A `holds` without a quoted line is `cannot tell`.
103
+ - `drifted`: the code contradicts the contract as written, a named file,
104
+ command or token that is gone included. Cite the contradicting line or,
105
+ for something missing, the search that found nothing.
106
+ - `cannot tell`, with its reason, such as: no covered code, a rule about
107
+ people or prose, a `(verified: live|manual)` contract no line can show
108
+ (`verified` in the bundle), a scan status of `unknown`, or code read that
109
+ settles nothing. When the code a contract asks for is not found and the
110
+ story behind its last marker has not reached `review`, the verdict is
111
+ `cannot tell (pending)`. A contract amended by a story in `review` or
112
+ `done` is judged as usual.
113
+ - A removed contract is listed as `removed` and not judged.
114
+ 5. **Proposals.** Each `drifted` comes with a proposal and a lean: a marker
115
+ carried by an open story, a new story that brings the spec to the code, or
116
+ a follow-up story that brings the code back; never a closed story. Each
117
+ goes through the normal approval-gated commands, run by the caller.
118
+ 6. **Facts, not drift.** Empty or missing `code_refs` (`refs.empty`,
119
+ `refs.missing`, `refs.unresolved`) and uncited contracts (`cited_by: []`)
120
+ are listed as facts, not drift; so are doctor's `code-refs` and
121
+ `contract-coverage` findings.
122
+ 7. **Report**, your last message, in place of the Output section:
123
+ 1. **Input** โ€” the spec (title, id, status), the bundle's `checkout`, the scan status, files and
124
+ lines by class, contracts judged, `not checked` and removed, and
125
+ `--since` when given.
126
+ 2. **Contracts** โ€” one row per contract: number, heading, verdict and its
127
+ evidence (the quoted line with file:line, the contradicting line or the
128
+ empty search, or the reason).
129
+ 3. **Drift proposals** โ€” for each `drifted`: the proposal, the lean and why.
130
+ 4. **Facts** โ€” empty and missing refs, uncited contracts, unknown history.
131
+ 5. **Open Questions** โ€” low-confidence verdicts, surfaced not blocking.
132
+
133
+ You write nothing in this mode: no vault file, no code, no story.
134
+
63
135
  ## Phase 0 โ€” Pre-commitment
64
136
  From the story + file list, predict the 3-5 most likely gaps ("AC #3 needs an
65
137
  error path the diff doesn't touch"; "touches a cache โ€” invalidation risk"). Write
@@ -219,6 +219,45 @@ the coverage count. Evidence in a closed story that names it is not
219
219
  reported. `graph.md` and `lineage` show an `amends` edge from the story to
220
220
  the spec.
221
221
 
222
+ ## Checking a spec against the code
223
+
224
+ A spec can drift from the code after its stories close: a later story
225
+ changes the behaviour without a marker, or the code changes outside any
226
+ story. Ask the reviewer for spec drift on a named spec, and it checks that
227
+ spec against the code at its covered stories' `code_refs`. Run it when the
228
+ covered code changed since the contracts' last markers, not before every
229
+ release.
230
+
231
+ The reviewer reads a bundle of facts first:
232
+
233
+ ```bash
234
+ node scripts/spec-drift.mjs example --since 2026-11-01
235
+ ```
236
+
237
+ It prints JSON: each contract with its markers, removed state and anchor
238
+ (the date of its last marker, else the spec's `date:`; `--since` overrides
239
+ it), the tests that cite it as `[example#2]`, the covered stories and their
240
+ `code_refs` (empty and missing ones listed), the files those refs name with
241
+ their class (`source`, `tests` or `surface`), line counts and commits since
242
+ each anchor, and a scan status: `ok`, `no-git` or `unknown`. A spec that is
243
+ not `active` exits 1.
244
+
245
+ The reviewer judges up to eight contracts per run unless you name another
246
+ budget. A spec with more asks you for contract numbers first. Each contract
247
+ judged gets one verdict:
248
+
249
+ | Verdict | Means |
250
+ |---|---|
251
+ | `holds` | A line of code, quoted with its file:line, produces the contract's outcome |
252
+ | `drifted` | The code contradicts the contract, or a file, command or token it names is gone; the report cites the line, or the search that found nothing |
253
+ | `cannot tell` | With a reason: no covered code, a rule about people, a live or manual contract, an unknown scan; `cannot tell (pending)` when the code is missing and the story behind the last marker has not reached review |
254
+
255
+ Each drift comes with a proposal: a marker carried by an open story, a new
256
+ story that brings the spec to the code, or a follow-up story that brings the
257
+ code back. Empty `code_refs` and uncited contracts are listed as facts, not
258
+ drift. The reviewer writes nothing; every change goes through the usual
259
+ approval-gated commands.
260
+
222
261
  ## What doctor reports
223
262
 
224
263
  Doctor (`--vault`) scans the bound project with `git grep --untracked`, so a
@@ -222,6 +222,16 @@ each key activates.
222
222
  warn.
223
223
  - `doctor --vault --policy required[@<date>]` shows what `required` would
224
224
  report, without writing anything.
225
+ - `flow` holds the team's flow policy: `wip.full` and `wip.part`, a
226
+ `people` map from each assignee's name to `time` (`full` or `part`), an
227
+ optional own `wip`, `git_names` and `left`, ages in days under `age_days`,
228
+ and `idle_days`, `window_weeks`, `capacity_weeks` (`[low, high]`),
229
+ `active_epics_max` and `grooming_days`. `groomed_at` is a stamp. No key has
230
+ a default in code, and a value of the wrong type counts as absent. A
231
+ person's WIP limit is their own `wip`, else `wip.<time>`, else none
232
+ (`wipLimitOf` in `scripts/flow-policy.mjs`). `configure --set` writes these
233
+ keys and merges them into the block, keeping the rest. Names are the
234
+ assignee as stories write it, never an email address.
225
235
 
226
236
  ## Contributing back
227
237
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.30.15",
3
+ "version": "0.30.16",
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",
@@ -174,6 +174,10 @@ export function menuGroups(f, settings, catalogs, { menu, now = Date.now, caps:
174
174
  "The vault's own rules, shared by everyone who uses the vault: whether a story needs a spec, and whether doctor checks a story's plan and close. Kept in the vault's .projectstore.json; commit the vault after a change.",
175
175
  items: list.map((s) => plainItem(f, s, null)),
176
176
  });
177
+ // The flow policy is `--set` only (the configure spec, contract 2 as
178
+ // amended 2026-11-10): the menu has no widget for a keyed list, and the
179
+ // view names each key's form. Skipped here on purpose, not forgotten.
180
+ else if (g === "flow") continue;
177
181
  }
178
182
  return groups;
179
183
  }
@@ -31,7 +31,8 @@ import {
31
31
  fromTerminalIn,
32
32
  commandForm,
33
33
  } from "./lib.mjs";
34
- import { planBindingKeys, applyBindingKeys, languageNames } from "./binding.mjs";
34
+ import { planBindingKeys, applyBindingKeys, languageNames, shellQuote } from "./binding.mjs";
35
+ import { isFlowSet, parseFlowSet, flowRows, flowForm, blockedAt, flowAt } from "./flow-policy.mjs";
35
36
  import { bandKeys, epicIds, planBand, applyBand } from "./band.mjs";
36
37
  import { planAgentsChange, applyAgentsChange } from "./agents-change.mjs";
37
38
  import { plan as planInstall, apply as applyInstall, planOptions, isInteractive } from "./install-harness.mjs";
@@ -63,7 +64,13 @@ const parseBool = (v) => {
63
64
  };
64
65
  const onOff = (b) => (b === true ? "on" : b === false ? "off" : "not set");
65
66
  const shown = (v) =>
66
- v === null || v === undefined || v === "" ? "โ€”" : Array.isArray(v) ? v.join(", ") || "โ€”" : String(v);
67
+ v === null || v === undefined || v === ""
68
+ ? "โ€”"
69
+ : Array.isArray(v)
70
+ ? v.join(", ") || "โ€”"
71
+ : typeof v === "object"
72
+ ? JSON.stringify(v)
73
+ : String(v);
67
74
  const tildeOf = (p, home) =>
68
75
  home && (p === home || String(p).startsWith(home + "/")) ? "~" + String(p).slice(home.length) : String(p);
69
76
 
@@ -265,6 +272,22 @@ export function settingsOf(f) {
265
272
  pol("spec_policy", v.spec_policy || "optional", "policy"),
266
273
  pol("lifecycle_gates", Object.hasOwn(v, "lifecycle_gates") ? String(v.lifecycle_gates) : "not set", "bool"),
267
274
  );
275
+ // The flow policy (contract 3 as amended 2026-11-10): its own group,
276
+ // listed only while a key is set, so a vault with no `flow` prints what it
277
+ // printed before. `--set` only: the menu shows none of these rows.
278
+ for (const r of f.vaultCfg && !f.vaultCfg.corrupt ? flowRows(v.flow) : [])
279
+ out.push({
280
+ group: "flow",
281
+ key: r.key,
282
+ value: r.value,
283
+ source: "vault_config",
284
+ file,
285
+ class: "changeable",
286
+ team: true,
287
+ writer: "vault",
288
+ rail: null,
289
+ person: r.person,
290
+ });
268
291
  }
269
292
  for (const s of out) s.set = setForm(s, f);
270
293
  return out;
@@ -273,6 +296,11 @@ export function settingsOf(f) {
273
296
  // The --set form a key is changed with, for the view and the envelope.
274
297
  function setForm(s, f) {
275
298
  if (s.class !== "changeable") return null;
299
+ if (s.group === "flow") {
300
+ const form = flowForm(s.key);
301
+ // A person's name may hold a space or a dot, and `[`โ€ฆ`]` globs in zsh.
302
+ return form && s.person !== null ? `--set ${shellQuote(form.slice("--set ".length))}` : form;
303
+ }
276
304
  const value =
277
305
  s.rail === "bool"
278
306
  ? "on|off"
@@ -301,6 +329,19 @@ export function resolveSets(f, sets, { harness = null, nearest = null } = {}) {
301
329
  const known = [...new Set(keys.map((s) => s.key))];
302
330
  const harnessesNamed = f.used.map((h) => h.display_name || h.id).join(", ") || "none";
303
331
  for (const raw of sets) {
332
+ // A flow key is parsed whole, before the split at the first `=`: a
333
+ // person's name may hold a dot (contract 2 as amended 2026-11-10).
334
+ if (isFlowSet(raw)) {
335
+ const vcfg = f.vaultCfg && !f.vaultCfg.corrupt ? f.vaultCfg.cfg : {};
336
+ const r = parseFlowSet(raw, { nearest, flow: vcfg.flow });
337
+ if (r.usage) return { usage: r.usage };
338
+ const { key, path, value } = r.change;
339
+ if (!f.vault) return { usage: `${key} lives in the vault's config, and this binding names no vault` };
340
+ if (seen.has(key)) return { usage: `${key} is named twice` };
341
+ seen.add(key);
342
+ out.push({ key, writer: "vault", path: ["flow", ...path], value });
343
+ continue;
344
+ }
304
345
  const at = String(raw).indexOf("=");
305
346
  if (at < 1) return { usage: `--set takes <key>=<value>, got ${raw}` };
306
347
  let key = String(raw).slice(0, at).trim();
@@ -464,20 +505,39 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
464
505
  );
465
506
  const strict = readVaultConfigStrict(f.vault);
466
507
  if (strict.corrupt) return refuse(`${strict.path} is not valid JSON โ€” fix or remove it first`);
467
- const patch = Object.fromEntries(policy.map((c) => [c.key, c.value]));
508
+ // A flow change carries a path into the `flow` object; a node on it that
509
+ // exists and is not an object is never replaced (contract 11).
510
+ for (const c of policy.filter((c) => c.path)) {
511
+ const at = blockedAt(strict.cfg, c.path.slice(1));
512
+ if (at) return refuse(`${c.key}: ${at} is not an object โ€” fix or remove it first`);
513
+ }
514
+ const patch = {};
515
+ for (const c of policy) {
516
+ const path = c.path || [c.key];
517
+ let node = patch;
518
+ for (const step of path.slice(0, -1)) node = Object.hasOwn(node, step) ? node[step] : (node[step] = {});
519
+ node[path[path.length - 1]] = c.value;
520
+ }
468
521
  const next = nextVaultConfig(strict.cfg, patch, new Date().toISOString());
522
+ const flowBefore = (c) => flowAt(strict.cfg.flow, c.path.slice(1)) ?? null;
469
523
  const inEffect = (k) => strict.cfg[k] ?? (k === "spec_policy" ? "optional" : undefined);
470
524
  // `lifecycle_gates=on` on a vault already on but with no gates_since is a
471
525
  // change: it stamps the date the gates count from (the configure spec,
472
526
  // contract 7 as amended 2026-11-08).
473
527
  const stampsGates = (c) => c.key === "lifecycle_gates" && c.value === "on" && !strict.cfg.gates_since;
474
- const changed = policy.filter((c) => inEffect(c.key) !== c.value || stampsGates(c));
528
+ const changed = policy.filter((c) =>
529
+ c.path
530
+ ? JSON.stringify(flowBefore(c)) !== JSON.stringify(c.value)
531
+ : inEffect(c.key) !== c.value || stampsGates(c),
532
+ );
475
533
  for (const c of changed)
476
534
  items.push({
477
535
  key: c.key,
478
536
  file: tildeOf(strict.path, f.home),
479
- before: shown(strict.cfg[c.key] ?? (c.key === "spec_policy" ? "optional" : "not set")),
480
- after: shown(c.value),
537
+ before: c.path
538
+ ? shown(flowBefore(c) ?? "not set")
539
+ : shown(strict.cfg[c.key] ?? (c.key === "spec_policy" ? "optional" : "not set")),
540
+ after: shown(c.value ?? "not set"),
481
541
  team: true,
482
542
  note:
483
543
  c.key === "spec_policy" && next.spec_policy_since !== strict.cfg.spec_policy_since
@@ -486,7 +546,13 @@ export function planChanges(f, changes, { env = process.env, home = homedir(), r
486
546
  ? GATES_NOTE
487
547
  : null,
488
548
  });
489
- if (changed.length) steps.push({ writer: "vault", patch, keys: changed.map((c) => c.key) });
549
+ if (changed.length)
550
+ steps.push({
551
+ writer: "vault",
552
+ patch,
553
+ keys: changed.map((c) => c.key),
554
+ flowPaths: policy.filter((c) => c.path).map((c) => c.path.slice(1)),
555
+ });
490
556
  }
491
557
 
492
558
  // The overlays: one agents change per harness, through the core
@@ -741,6 +807,12 @@ export function applyChanges(f, steps, { env = process.env, home = homedir(), ro
741
807
  // by every checkout, and a change made while the question waited is kept.
742
808
  const now = readVaultConfigStrict(f.vault);
743
809
  if (now.corrupt) throw new Error(`${now.path} stopped being valid JSON after the plan`);
810
+ // A flow path whose node stopped being an object since the PLAN is
811
+ // refused here too, never replaced (contract 11 as amended 2026-11-10).
812
+ for (const path of s.flowPaths || []) {
813
+ const at = blockedAt(now.cfg, path);
814
+ if (at) throw new Error(`${at} stopped being an object after the plan โ€” fix or remove it first`);
815
+ }
744
816
  writeVaultConfig(f.vault, nextVaultConfig(now.cfg, s.patch, new Date().toISOString()));
745
817
  wrote.push(vaultConfigPath(f.vault));
746
818
  } else if (s.writer === "overlay") {
@@ -793,6 +865,7 @@ const GROUP_TITLES = (f) => ({
793
865
  surfaces: "Status line and band",
794
866
  vault: "Vault and templates",
795
867
  policy: "Policy",
868
+ flow: "Flow policy",
796
869
  ...Object.fromEntries(f.used.map((h) => [`agents:${h.id}`, `Agents ยท ${h.display_name || h.id}`])),
797
870
  });
798
871
 
@@ -103,6 +103,20 @@ export function grepProjectLines(cwd, pattern, { exclude = [], timeout = 10000,
103
103
  return { status: "ok", lines };
104
104
  }
105
105
 
106
+ // The `--since` window git is given, shared with spec-drift.mjs.
107
+ //
108
+ // A bare YYYY-MM-DD gets midnight appended, and it is not cosmetic. Git's
109
+ // approxidate reads a date with no time as THAT DATE AT THE CURRENT TIME OF
110
+ // DAY, so `--since=2026-09-08` run at 17:46 silently excludes everything
111
+ // committed earlier the same day. `started_at` is written as a bare date,
112
+ // so a story started and finished in one day proposed `files: []` and the
113
+ // caller had no way to tell that from "nothing was committed" (measured
114
+ // 2026-09-08: --since=2026-09-08 returned nothing while
115
+ // --since="2026-09-08 00:00" returned the day's commit).
116
+ export function sinceWindow(since) {
117
+ return /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since} 00:00:00` : since;
118
+ }
119
+
106
120
  function filterFiles(list) {
107
121
  return [...new Set(list)]
108
122
  .map((f) => f.trim())
@@ -128,16 +142,7 @@ function main() {
128
142
  } else if (since) {
129
143
  // committer-date window; work committed before the story went in-progress
130
144
  // is invisible by design (story-scoped attribution).
131
- //
132
- // A bare YYYY-MM-DD gets midnight appended, and it is not cosmetic. Git's
133
- // approxidate reads a date with no time as THAT DATE AT THE CURRENT TIME OF
134
- // DAY, so `--since=2026-09-08` run at 17:46 silently excludes everything
135
- // committed earlier the same day. `started_at` is written as a bare date,
136
- // so a story started and finished in one day proposed `files: []` and the
137
- // caller had no way to tell that from "nothing was committed" (measured
138
- // 2026-09-08: --since=2026-09-08 returned nothing while
139
- // --since="2026-09-08 00:00" returned the day's commit).
140
- const window = /^\d{4}-\d{2}-\d{2}$/.test(since) ? `${since} 00:00:00` : since;
145
+ const window = sinceWindow(since);
141
146
  const out = git(["log", `--since=${window}`, "--name-only", "--pretty=format:"]);
142
147
  committed = out ? out.split("\n") : [];
143
148
  usedRange = `--since=${window}`;
@@ -2301,7 +2301,7 @@ function storyStemOf(storyRel) {
2301
2301
  // exact filename stem, legacy story-NNN prefix fallback โ€” the strongest tier
2302
2302
  // wins across the epic's stories, and a tie within it is a reported
2303
2303
  // ambiguity, never a silent first match.
2304
- function resolveSpecStory(entry, artifacts, epicFolderPath) {
2304
+ export function resolveSpecStory(entry, artifacts, epicFolderPath) {
2305
2305
  const m = String(entry).match(/^([^/]+)\/(.+)$/);
2306
2306
  if (!m) return { error: `not in <epic-id>/<story-id> form: "${entry}"` };
2307
2307
  const [, epicId, storyId] = m;
@@ -2895,7 +2895,7 @@ export function parseSpecContracts(spec) {
2895
2895
  // Key โ†’ spec, by frontmatter id and alias, exact and case-sensitive. Not
2896
2896
  // buildSpecResolver: its filename fallback is lower-cased, and a citation
2897
2897
  // token resolves only to what a spec declares.
2898
- function citationKeys(artifacts) {
2898
+ export function citationKeys(artifacts) {
2899
2899
  const keys = new Map();
2900
2900
  for (const s of artifacts.filter((a) => a.kind === "spec")) {
2901
2901
  for (const k of [String(s.fm.id || ""), String(s.fm.alias || "")]) {
@@ -0,0 +1,264 @@
1
+ // projectstore โ€” flow-policy.mjs: the vault's flow policy, as data.
2
+ //
3
+ // The `flow` block of <vault>/.projectstore.json holds a team's flow
4
+ // thresholds: WIP limits by a person's time on the project, ages in status,
5
+ // windows for throughput and capacity. This module is its grammar: the keys
6
+ // and their value forms, the `--set` parse `configure` uses, the view's rows,
7
+ // and the WIP limit rule doctor, `next` and the band read. Pure: no I/O.
8
+ //
9
+ // Spec: "Changing a project's settings after setupโ€ฆ" (alias `configure`),
10
+ // contracts 2, 3, 7 and 11 as amended 2026-11-10, and SPEC-001 contract 4
11
+ // as amended the same day, by PS-FLOW/story-the-vault-declares-its-flow-
12
+ // policy-and-configure-writes-it. No key has a default in code: a check whose
13
+ // key is absent does not run, and a value of the wrong type counts as absent.
14
+
15
+ import { isCalendarDate } from "./lib.mjs";
16
+
17
+ export const CLEAR = "-";
18
+
19
+ // The fixed keys, in the view's order. `kind` picks the value form.
20
+ export const FLOW_FIXED = Object.freeze([
21
+ { key: "flow.wip.full", path: ["wip", "full"], kind: "count" },
22
+ { key: "flow.wip.part", path: ["wip", "part"], kind: "count" },
23
+ { key: "flow.age_days.review", path: ["age_days", "review"], kind: "days" },
24
+ { key: "flow.age_days.in_progress_idle", path: ["age_days", "in_progress_idle"], kind: "days" },
25
+ { key: "flow.age_days.todo", path: ["age_days", "todo"], kind: "days" },
26
+ { key: "flow.idle_days", path: ["idle_days"], kind: "days" },
27
+ { key: "flow.window_weeks", path: ["window_weeks"], kind: "weeks" },
28
+ { key: "flow.capacity_weeks", path: ["capacity_weeks"], kind: "range" },
29
+ { key: "flow.active_epics_max", path: ["active_epics_max"], kind: "count" },
30
+ { key: "flow.grooming_days", path: ["grooming_days"], kind: "days" },
31
+ ]);
32
+
33
+ // A person's fields, in the view's order.
34
+ export const PERSON_FIELDS = Object.freeze(["time", "wip", "git_names", "left"]);
35
+ const PERSON_KIND = Object.freeze({ time: "time", wip: "count", git_names: "names", left: "date" });
36
+
37
+ // Stamps under `flow`: never set by hand (contract 2's "Not settings").
38
+ export const FLOW_STAMPS = Object.freeze(["flow.groomed_at"]);
39
+
40
+ const SET_OF = Object.freeze({
41
+ count: "a positive whole number",
42
+ days: "a whole number of days, 1 or more",
43
+ weeks: "a whole number of weeks, 1 or more",
44
+ range: "two positive numbers, low then high, like 2,3",
45
+ time: "full or part",
46
+ names: "a comma list of git author names",
47
+ date: "a date as YYYY-MM-DD",
48
+ });
49
+
50
+ const FORM_OF = Object.freeze({
51
+ count: "<n>",
52
+ days: "<days>",
53
+ weeks: "<weeks>",
54
+ range: "<low,high>",
55
+ time: "<full|part>",
56
+ names: "<a,b>",
57
+ date: "<YYYY-MM-DD>",
58
+ });
59
+
60
+ const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
61
+ const PERSON_KEY = /^flow\.people\[([\s\S]*?)\]\.([A-Za-z_]+)=([\s\S]*)$/;
62
+ const BARE_PERSON = /^flow\.people\[([\s\S]*?)\]\s*(=|$)/;
63
+
64
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
65
+
66
+ // A positive safe integer written in plain decimal: no sign, no leading zero,
67
+ // no exponent, no fraction.
68
+ function positiveInt(raw) {
69
+ if (!/^[1-9]\d*$/.test(raw)) return null;
70
+ const n = Number(raw);
71
+ return Number.isSafeInteger(n) ? n : null;
72
+ }
73
+
74
+ function positiveNumber(raw) {
75
+ if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) return null;
76
+ const n = Number(raw);
77
+ return Number.isFinite(n) && n > 0 ? n : null;
78
+ }
79
+
80
+ // A value as typed โ†’ the value stored, or null when it is outside the set.
81
+ function parseValue(kind, raw) {
82
+ switch (kind) {
83
+ case "count":
84
+ case "days":
85
+ case "weeks":
86
+ return positiveInt(raw);
87
+ case "range": {
88
+ const parts = raw.split(",").map((p) => p.trim());
89
+ if (parts.length !== 2) return null;
90
+ const [lo, hi] = parts.map(positiveNumber);
91
+ return lo !== null && hi !== null && lo <= hi ? [lo, hi] : null;
92
+ }
93
+ case "time":
94
+ return raw === "full" || raw === "part" ? raw : null;
95
+ case "names": {
96
+ const names = raw
97
+ .split(",")
98
+ .map((n) => n.trim())
99
+ .filter(Boolean);
100
+ return names.length && !names.some((n) => CONTROL.test(n)) ? names : null;
101
+ }
102
+ case "date":
103
+ return isCalendarDate(raw) ? raw : null;
104
+ default:
105
+ return null;
106
+ }
107
+ }
108
+
109
+ const shownName = (name) => (CONTROL.test(name) ? JSON.stringify(name) : name);
110
+
111
+ // A person's name as `flow.people[<name>]` takes it: the assignee as stories
112
+ // write it, never an email address.
113
+ function badName(name) {
114
+ return !name || /[@\]=]/.test(name) || CONTROL.test(name) || name === "__proto__";
115
+ }
116
+
117
+ // Whether a raw `--set` argument is a flow key: these are parsed here, before
118
+ // configure splits at the first `=` (a person's name may hold a dot).
119
+ export const isFlowSet = (raw) => /^\s*flow(?:[.[=]|$)/.test(String(raw));
120
+
121
+ // The every-key list a typo is measured against.
122
+ export function flowKnownKeys(flow) {
123
+ const keys = FLOW_FIXED.map((k) => k.key);
124
+ if (isPlainObject(flow) && isPlainObject(flow.people))
125
+ for (const name of Object.keys(flow.people)) for (const f of PERSON_FIELDS) keys.push(personKey(name, f));
126
+ return keys;
127
+ }
128
+
129
+ export const personKey = (name, field) => `flow.people[${name}].${field}`;
130
+
131
+ // One `--set` argument โ†’ `{ change: { key, path, value } }`, `value: null`
132
+ // meaning "remove that key", or `{ usage }` (exit 2).
133
+ export function parseFlowSet(raw, { nearest = null, flow = null } = {}) {
134
+ const text = String(raw).trim();
135
+ const unknown = (key) => {
136
+ if (FLOW_STAMPS.includes(key))
137
+ return { usage: `unknown setting ${key} โ€” a stamp that groom writes, never set by hand` };
138
+ const hint = nearest ? nearest(key, flowKnownKeys(flow)) : null;
139
+ return { usage: `unknown setting ${key}${hint ? ` โ€” did you mean ${hint}?` : ""}` };
140
+ };
141
+ const p = PERSON_KEY.exec(text);
142
+ if (p) {
143
+ const name = p[1].trim();
144
+ const field = p[2];
145
+ const value = p[3].trim();
146
+ if (badName(name))
147
+ return {
148
+ usage: `flow.people[${shownName(name)}] takes a name with no @, ] or =, and no line break (the assignee as stories write it, never an email address), got ${JSON.stringify(name)}`,
149
+ };
150
+ const key = personKey(name, field);
151
+ if (!PERSON_FIELDS.includes(field)) return unknown(key);
152
+ return change(key, ["people", name, field], PERSON_KIND[field], value);
153
+ }
154
+ const bare = BARE_PERSON.exec(text);
155
+ if (bare) {
156
+ const name = bare[1].trim();
157
+ return {
158
+ usage: `flow.people[${shownName(name)}] takes ${PERSON_FIELDS.map((f) => `.${f}`)
159
+ .join(", ")
160
+ .replace(/, ([^,]*)$/, " or $1")}`,
161
+ };
162
+ }
163
+ const at = text.indexOf("=");
164
+ if (at < 1) return { usage: `--set takes <key>=<value>, got ${raw}` };
165
+ const key = text.slice(0, at).trim();
166
+ const value = text.slice(at + 1).trim();
167
+ const fixed = FLOW_FIXED.find((k) => k.key === key);
168
+ if (!fixed) return unknown(key);
169
+ return change(key, fixed.path, fixed.kind, value);
170
+ }
171
+
172
+ function change(key, path, kind, value) {
173
+ if (value === CLEAR) return { change: { key, path, value: null } };
174
+ const parsed = value ? parseValue(kind, value) : null;
175
+ if (parsed === null)
176
+ return { usage: `${key} takes ${SET_OF[kind]}, or ${CLEAR} to clear it, got ${value || "nothing"}` };
177
+ return { change: { key, path, value: parsed } };
178
+ }
179
+
180
+ // The `--set` form a flow key is changed with; a person's form is quoted by
181
+ // the caller for the shell.
182
+ export function flowForm(key) {
183
+ const fixed = FLOW_FIXED.find((k) => k.key === key);
184
+ if (fixed) return `--set ${key}=${FORM_OF[fixed.kind]}`;
185
+ const m = /^flow\.people\[([\s\S]*)\]\.([a-z_]+)$/.exec(key);
186
+ return m ? `--set ${key}=${FORM_OF[PERSON_KIND[m[2]]]}` : null;
187
+ }
188
+
189
+ // The value at a path of the flow block, own keys only; undefined when absent.
190
+ export function flowAt(flow, path) {
191
+ let node = flow;
192
+ for (const step of path) {
193
+ if (!isPlainObject(node) || !Object.hasOwn(node, step)) return undefined;
194
+ node = node[step];
195
+ }
196
+ return node;
197
+ }
198
+
199
+ // The first node on a path that exists and is not an object, as a dotted
200
+ // key; null when the path can be written.
201
+ export function blockedAt(vaultCfg, path) {
202
+ if (!isPlainObject(vaultCfg) || !Object.hasOwn(vaultCfg, "flow")) return null;
203
+ let node = vaultCfg.flow;
204
+ let label = "flow";
205
+ for (let i = 0; i < path.length; i++) {
206
+ if (!isPlainObject(node)) return label;
207
+ if (i === path.length - 1 || !Object.hasOwn(node, path[i])) return null;
208
+ label += path[0] === "people" && i === 1 ? `[${path[i]}]` : `.${path[i]}`;
209
+ node = node[path[i]];
210
+ }
211
+ return null;
212
+ }
213
+
214
+ // The rows the view lists: every set key, fixed keys in table order, then
215
+ // people in file order, each person's fields in table order. A value of the
216
+ // wrong type is listed as it is: the view shows the file.
217
+ export function flowRows(flow) {
218
+ if (!isPlainObject(flow)) return [];
219
+ const rows = [];
220
+ for (const k of FLOW_FIXED) {
221
+ const v = flowAt(flow, k.path);
222
+ if (v !== undefined) rows.push({ key: k.key, value: v, person: null });
223
+ }
224
+ if (isPlainObject(flow.people))
225
+ for (const [name, fields] of Object.entries(flow.people)) {
226
+ if (!isPlainObject(fields)) continue;
227
+ for (const f of PERSON_FIELDS)
228
+ if (Object.hasOwn(fields, f)) rows.push({ key: personKey(name, f), value: fields[f], person: name });
229
+ }
230
+ return rows;
231
+ }
232
+
233
+ const goodCount = (v) => Number.isSafeInteger(v) && v > 0;
234
+
235
+ // A fixed key's value when it is of its key's type, else null: how every
236
+ // reader of the policy takes it (SPEC-001 contract 4, as amended).
237
+ export function flowValue(vaultCfg, key) {
238
+ const fixed = FLOW_FIXED.find((k) => k.key === key || k.key === `flow.${key}`);
239
+ if (!fixed || !isPlainObject(vaultCfg)) return null;
240
+ const v = flowAt(vaultCfg.flow, fixed.path);
241
+ if (fixed.kind === "range")
242
+ return Array.isArray(v) &&
243
+ v.length === 2 &&
244
+ v.every((n) => typeof n === "number" && Number.isFinite(n) && n > 0) &&
245
+ v[0] <= v[1]
246
+ ? [v[0], v[1]]
247
+ : null;
248
+ return goodCount(v) ? v : null;
249
+ }
250
+
251
+ // The WIP limit rule (SPEC-001 contract 4, as amended): a person's own `wip`,
252
+ // else `flow.wip.<time>` for their `time`, else no limit. A person not listed
253
+ // has none. The name matches exactly, as an own key.
254
+ export function wipLimitOf(vaultCfg, name) {
255
+ if (!isPlainObject(vaultCfg)) return null;
256
+ const people = flowAt(vaultCfg.flow, ["people"]);
257
+ if (!isPlainObject(people) || typeof name !== "string" || !Object.hasOwn(people, name)) return null;
258
+ const person = people[name];
259
+ if (!isPlainObject(person)) return null;
260
+ if (goodCount(person.wip)) return person.wip;
261
+ if (person.time !== "full" && person.time !== "part") return null;
262
+ const byTime = flowAt(vaultCfg.flow, ["wip", person.time]);
263
+ return goodCount(byTime) ? byTime : null;
264
+ }
@@ -2047,7 +2047,9 @@ export function listOf(fm, key) {
2047
2047
  // lifecycle_gates ("on"|"off"), spec_policy_since (ISO-8601, stamped whenever
2048
2048
  // spec_policy becomes "required" from anything else; kept otherwise โ€” the
2049
2049
  // configure spec, contract 7), gates_since (stamped when lifecycle_gates
2050
- // turns on; the configure spec, contract 7 as amended 2026-11-08).
2050
+ // turns on; the configure spec, contract 7 as amended 2026-11-08), and flow,
2051
+ // the team's flow thresholds (scripts/flow-policy.mjs; the configure spec,
2052
+ // contract 2, and SPEC-001 contract 4, as amended 2026-11-10).
2051
2053
  export function vaultConfigPath(vault) {
2052
2054
  return join(vault, LAYOUT.vaultConfig);
2053
2055
  }
@@ -2079,8 +2081,29 @@ export function readVaultConfigStrict(vault) {
2079
2081
  // spec_policy_since: a story done before the policy (re)took effect is exempt
2080
2082
  // (ADR-007 decision 6), and since every story the old stamp exempted closed
2081
2083
  // before the new one, no exemption is lost.
2084
+ // A change merged into a config object (the configure spec, contract 7 as
2085
+ // amended 2026-11-10): plain objects merge key by key, an array or a scalar
2086
+ // replaces, `null` deletes the key, and an object the deletion leaves empty
2087
+ // is deleted too. Pure and copy-on-write: `before` is never mutated. Keys are
2088
+ // own keys only, and maps are built with Object.fromEntries, so a key named
2089
+ // `__proto__` stays a key.
2090
+ export function mergeConfig(before, patch) {
2091
+ const plain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2092
+ const base = plain(before) ? before : {};
2093
+ const entries = new Map(Object.entries(base));
2094
+ for (const [k, v] of Object.entries(patch || {})) {
2095
+ if (v === null) entries.delete(k);
2096
+ else if (plain(v)) {
2097
+ const merged = mergeConfig(Object.hasOwn(base, k) && plain(base[k]) ? base[k] : {}, v);
2098
+ if (Object.keys(merged).length) entries.set(k, merged);
2099
+ else entries.delete(k);
2100
+ } else entries.set(k, v);
2101
+ }
2102
+ return Object.fromEntries(entries);
2103
+ }
2104
+
2082
2105
  export function nextVaultConfig(before, patch, now = new Date().toISOString()) {
2083
- const next = { ...before, ...patch };
2106
+ const next = mergeConfig(before, patch);
2084
2107
  if (patch.spec_policy === "required" && before.spec_policy !== "required") {
2085
2108
  // Never earlier than the stamp it replaces: one written ahead of this
2086
2109
  // clock (a hand edit, a machine whose clock ran fast) would otherwise
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+ // projectstore โ€” spec-drift.mjs (PS-SPEC: "The reviewer can run against an
3
+ // active spec and its code_refs and report where the code drifted from the
4
+ // contracts"; SPEC-001 contract 10, the Spec drift bullet).
5
+ //
6
+ // node spec-drift.mjs <spec id|alias|stem> [--since <date>] [--project <dir>]
7
+ //
8
+ // The fact bundle the reviewer's spec-drift mode reads. Pure and read-only:
9
+ // it prints stable JSON (no generated_at, sorted lists) and writes nothing.
10
+ // It judges nothing either: the verdicts are the reviewer's.
11
+ //
12
+ // - contracts: number, heading, removed state, scenarios, marks, the valid
13
+ // and invalid markers with the status of each marker's story, the anchor
14
+ // (the date of the contract's last valid marker, else the spec's `date:`;
15
+ // `--since` overrides every anchor), and the tests citing it;
16
+ // - stories: the spec's `stories:`, each with its status and `code_refs`,
17
+ // empty and missing refs listed;
18
+ // - files: the refs expanded (directories, a trailing `/`, globs `*` `**` `?`),
19
+ // SOURCE_IGNORE dropped, classed tests / surface / source, with line counts
20
+ // and the commits since each anchor of a contract that is not removed (a
21
+ // count and the last three);
22
+ // - scan: `ok`, `no-git` or `unknown`. A null is "unknown", never "none":
23
+ // with no git, history and citations are null, not empty.
24
+ //
25
+ // Git runs in the PROJECT through diff-refs.mjs's seams (gitIn,
26
+ // grepProjectLines), which tests replace; this module spawns nothing itself.
27
+
28
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
29
+ import { join, relative, basename, resolve } from "node:path";
30
+ import {
31
+ readConfigAt,
32
+ loadLayout,
33
+ folderByKind,
34
+ listOf,
35
+ sectionOf,
36
+ linesOutsideFences,
37
+ parseAmendmentMarkers,
38
+ isTestPath,
39
+ SOURCE_IGNORE,
40
+ CITATION_EXAMPLE_KEY,
41
+ isMain,
42
+ commandForm,
43
+ projectRoot,
44
+ } from "./lib.mjs";
45
+ import { gitIn, grepProjectLines, sinceWindow } from "./diff-refs.mjs";
46
+ import { scanArtifacts, parseSpecContracts, resolveSpecStory, citationKeys, scanCitations } from "./doctor.mjs";
47
+
48
+ function die(msg) {
49
+ process.stderr.write(`projectstore/spec-drift: ${msg}\n`);
50
+ process.exit(1);
51
+ }
52
+
53
+ // โ”€โ”€โ”€ Which spec โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
54
+
55
+ const stemOf = (s) => basename(s.rel).replace(/\.md$/, "");
56
+ const realValue = (v) => (v && v !== "null" ? String(v) : null);
57
+
58
+ function describe(s) {
59
+ return {
60
+ id: realValue(s.fm.id),
61
+ alias: realValue(s.fm.alias),
62
+ rel: s.rel,
63
+ status: String(s.fm.status || "draft"),
64
+ };
65
+ }
66
+
67
+ // By exact id or alias (the citation keys), or exact filename stem; then the
68
+ // same, case-insensitive. Two specs that answer to one query are ambiguous
69
+ // whichever way each answers: the caller names it better, nothing is picked.
70
+ export function resolveSpec(artifacts, query) {
71
+ const q = String(query || "");
72
+ const specs = artifacts.filter((a) => a.kind === "spec");
73
+ const keys = citationKeys(artifacts);
74
+ const exact = new Set();
75
+ if (keys.has(q)) exact.add(keys.get(q));
76
+ for (const s of specs) if (stemOf(s) === q) exact.add(s);
77
+ let hits = [...exact];
78
+ if (!hits.length) {
79
+ const lc = q.toLowerCase();
80
+ hits = specs.filter((s) =>
81
+ [realValue(s.fm.id), realValue(s.fm.alias), stemOf(s)].some((v) => v && v.toLowerCase() === lc),
82
+ );
83
+ }
84
+ const byRel = (a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
85
+ if (hits.length === 1) return { spec: hits[0] };
86
+ if (hits.length > 1) return { error: "ambiguous", candidates: hits.sort(byRel).map(describe) };
87
+ const lc = q.toLowerCase();
88
+ const near = specs.filter((s) =>
89
+ [realValue(s.fm.id), realValue(s.fm.alias), stemOf(s), realValue(s.fm.title)].some(
90
+ (v) => lc && v && v.toLowerCase().includes(lc),
91
+ ),
92
+ );
93
+ return { error: "unknown", candidates: (near.length ? near : specs).sort(byRel).map(describe) };
94
+ }
95
+
96
+ // โ”€โ”€โ”€ Files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
97
+
98
+ // `**/` is any number of directories, `**` anything, `*` and `?` stay inside
99
+ // one path segment. Everything else is literal.
100
+ export function globRe(glob) {
101
+ let s = "";
102
+ for (let i = 0; i < glob.length; i++) {
103
+ const c = glob[i];
104
+ if (c === "*" && glob[i + 1] === "*") {
105
+ i++;
106
+ if (glob[i + 1] === "/") {
107
+ i++;
108
+ s += "(?:.*/)?";
109
+ } else s += ".*";
110
+ } else if (c === "*") s += "[^/]*";
111
+ else if (c === "?") s += "[^/]";
112
+ else s += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
113
+ }
114
+ return new RegExp(`^${s}$`);
115
+ }
116
+
117
+ const ignored = (rel) => SOURCE_IGNORE.some((rx) => rx.test(rel));
118
+
119
+ // Every file under `dir` (project-relative), sorted; `.git`, symbolic links,
120
+ // SOURCE_IGNORE paths and the vault (when it sits inside the project) skipped.
121
+ function walk(proj, dir, skipRel) {
122
+ const out = [];
123
+ const visit = (rel) => {
124
+ let entries;
125
+ try {
126
+ entries = readdirSync(join(proj, rel), { withFileTypes: true });
127
+ } catch {
128
+ return;
129
+ }
130
+ for (const e of entries) {
131
+ const r = rel ? `${rel}/${e.name}` : e.name;
132
+ if (e.isDirectory()) {
133
+ if (e.name === ".git" || ignored(`${r}/`) || (skipRel && (r === skipRel || r.startsWith(`${skipRel}/`))))
134
+ continue;
135
+ visit(r);
136
+ } else if (e.isFile() && !ignored(r)) out.push(r);
137
+ }
138
+ };
139
+ visit(dir);
140
+ return out.sort();
141
+ }
142
+
143
+ // A ref expanded to files, or null when it names nothing (a missing path, a
144
+ // glob that matches no file).
145
+ function expandRef(proj, ref, allFiles, skipRel) {
146
+ const r = String(ref).replace(/^\.\//, "");
147
+ if (/[*?]/.test(r)) {
148
+ const dirGlob = r.endsWith("/");
149
+ const re = globRe(dirGlob ? r.slice(0, -1) : r);
150
+ const hits = allFiles().filter((f) => {
151
+ if (re.test(f)) return !dirGlob;
152
+ const parts = f.split("/");
153
+ for (let i = 1; i < parts.length; i++) if (re.test(parts.slice(0, i).join("/"))) return true;
154
+ return false;
155
+ });
156
+ return hits.length ? hits : null;
157
+ }
158
+ const bare = r.replace(/\/+$/, "");
159
+ let st;
160
+ try {
161
+ st = statSync(join(proj, bare));
162
+ } catch {
163
+ return null;
164
+ }
165
+ if (st.isDirectory()) return walk(proj, bare, skipRel);
166
+ if (r.endsWith("/")) return null;
167
+ return ignored(bare) ? [] : [bare];
168
+ }
169
+
170
+ // The classes only split the input size (contract 10): tests by the test-path
171
+ // rule; surface for docs, the README, manifests and generated copies; source
172
+ // otherwise.
173
+ const SURFACE = [
174
+ /(^|\/)docs?\//,
175
+ /(^|\/)(README|CHANGELOG|CHANGES|LICENSE)(\.[^/]*)?$/i,
176
+ /(^|\/)(package|plugin|marketplace|packlist)\.json$/,
177
+ /(^|\/)\.mcp\.json$/,
178
+ /(^|\/)\.[^/]+-plugin\//,
179
+ /^adapters\//,
180
+ /^packaging\/shells\//,
181
+ ];
182
+ export function classOf(rel) {
183
+ if (isTestPath(rel)) return "tests";
184
+ if (SURFACE.some((rx) => rx.test(rel))) return "surface";
185
+ return "source";
186
+ }
187
+
188
+ // Lines in a text file; null for a binary or unreadable one.
189
+ function lineCount(abs) {
190
+ let buf;
191
+ try {
192
+ buf = readFileSync(abs);
193
+ } catch {
194
+ return null;
195
+ }
196
+ if (buf.subarray(0, 8000).includes(0)) return null;
197
+ const text = buf.toString("utf8");
198
+ if (!text) return 0;
199
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
200
+ }
201
+
202
+ // โ”€โ”€โ”€ History โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
203
+
204
+ const SEP_RECORD = "\x1e";
205
+ const SEP_FIELD = "\x1f";
206
+
207
+ // anchor โ†’ (file โ†’ commits, newest first), or anchor โ†’ null when git could
208
+ // not answer. One `git log` per anchor and per chunk of paths.
209
+ function historyByAnchor(proj, files, anchors, git) {
210
+ const out = new Map();
211
+ for (const anchor of anchors) {
212
+ const per = new Map();
213
+ let failed = false;
214
+ for (let i = 0; i < files.length && !failed; i += 400) {
215
+ const chunk = files.slice(i, i + 400);
216
+ const log = git(proj, [
217
+ "-c",
218
+ "core.quotePath=false",
219
+ "log",
220
+ `--since=${sinceWindow(anchor)}`,
221
+ "--relative",
222
+ "--name-only",
223
+ "--format=%x1e%H%x1f%cI%x1f%s",
224
+ "--",
225
+ ...chunk,
226
+ ]);
227
+ if (log === null || log === undefined) {
228
+ failed = true;
229
+ break;
230
+ }
231
+ for (const rec of String(log).split(SEP_RECORD)) {
232
+ if (!rec.trim()) continue;
233
+ const [head, ...names] = rec.split("\n");
234
+ const [sha = "", date = "", subject = ""] = head.split(SEP_FIELD);
235
+ const commit = { commit: sha.slice(0, 12), date, subject };
236
+ for (const n of names) {
237
+ const f = n.trim();
238
+ if (!f) continue;
239
+ if (!per.has(f)) per.set(f, []);
240
+ if (!per.get(f).some((c) => c.commit === commit.commit)) per.get(f).push(commit);
241
+ }
242
+ }
243
+ }
244
+ out.set(anchor, failed ? null : per);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ // โ”€โ”€โ”€ The bundle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
250
+
251
+ // Each contract's heading, in the order parseSpecContracts reads them (the
252
+ // same section, comments stripped, fences skipped, the same top-level form).
253
+ function contractHeadings(spec) {
254
+ const sec = sectionOf(spec.body || "", "spec_contracts");
255
+ if (sec === null) return [];
256
+ const out = [];
257
+ for (const line of linesOutsideFences(sec.replace(/<!--[\s\S]*?-->/g, ""))) {
258
+ const m = line.match(/^(\d+[โ€ฒโ€ณ']*)\.(?:\s|$)(.*)$/);
259
+ if (!m) continue;
260
+ // A bold heading that wraps is cut at its first sentence on this line.
261
+ const bold = m[2].match(/^\*\*(.+?)\*\*/);
262
+ const text = bold ? bold[1] : m[2].replace(/^\*\*/, "").split(/(?<=\.)\s/)[0];
263
+ out.push(text.trim().replace(/\.$/, "").slice(0, 120));
264
+ }
265
+ return out;
266
+ }
267
+
268
+ const byPath = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
269
+
270
+ function vaultInside(proj, vault) {
271
+ try {
272
+ const rel = relative(realpathSync.native(proj), realpathSync.native(vault));
273
+ return rel && !rel.startsWith("..") && !rel.startsWith("/") ? rel : null;
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
278
+
279
+ // { bundle } or { error: "unknown" | "ambiguous" | "not-active", โ€ฆ }.
280
+ export function buildSpecDrift({
281
+ proj,
282
+ cfg,
283
+ query,
284
+ since = null,
285
+ git = gitIn,
286
+ grep = grepProjectLines,
287
+ layout = null,
288
+ }) {
289
+ const lay = layout || loadLayout(cfg.layout);
290
+ const artifacts = scanArtifacts(cfg, lay);
291
+ const found = resolveSpec(artifacts, query);
292
+ if (found.error) return { error: found.error, query: String(query || ""), candidates: found.candidates };
293
+ const spec = found.spec;
294
+ const status = String(spec.fm.status || "draft");
295
+ if (status !== "active") return { error: "not-active", status, spec: describe(spec) };
296
+
297
+ const epicFolder = folderByKind(lay, "epic");
298
+ const storyOf = (ref) =>
299
+ epicFolder ? resolveSpecStory(ref, artifacts, epicFolder.path) : { error: "the layout has no epic folder" };
300
+
301
+ // Contracts and their markers.
302
+ const parsed = parseSpecContracts(spec) || [];
303
+ const headings = contractHeadings(spec);
304
+ const allMarkers = parseAmendmentMarkers(spec.body || "");
305
+ const specDate = /^\d{4}-\d{2}-\d{2}/.test(String(spec.fm.date || "")) ? String(spec.fm.date).slice(0, 10) : null;
306
+ const contracts = parsed.map((c, i) => {
307
+ const markers = allMarkers
308
+ .filter((m) => m.index === i)
309
+ .map((m) => {
310
+ const row = { line: m.line, date: m.date, kind: m.kind, ref: m.ref, why: m.why, valid: m.valid };
311
+ if (!m.valid) row.form = m.form;
312
+ const r = m.ref ? storyOf(m.ref) : { error: "no ref" };
313
+ row.story = r.story ? { rel: r.story.rel, status: String(r.story.fm.status || "planned") } : null;
314
+ if (r.error) row.story_error = r.error;
315
+ return row;
316
+ });
317
+ const lastValid = markers.filter((m) => m.valid).at(-1) || null;
318
+ const [anchor, anchor_from] = since
319
+ ? [since, "since"]
320
+ : lastValid
321
+ ? [lastValid.date, "marker"]
322
+ : [specDate, specDate ? "date" : null];
323
+ return {
324
+ n: c.n,
325
+ heading: headings[i] ?? null,
326
+ removed: c.removed,
327
+ why: c.why,
328
+ steps: [...c.steps],
329
+ removed_steps: [...c.removedSteps],
330
+ verified: c.marker,
331
+ scenarios: c.scenarios,
332
+ marks: c.marks,
333
+ anchor,
334
+ anchor_from,
335
+ markers,
336
+ cited_by: [],
337
+ files_changed: null,
338
+ };
339
+ });
340
+
341
+ // Covered stories and their refs.
342
+ const stories = [];
343
+ const refs = { empty: [], missing: [], unresolved: [] };
344
+ const vaultRel = vaultInside(proj, cfg.vault_path);
345
+ let all = null;
346
+ const allFiles = () => (all ??= walk(proj, "", vaultRel));
347
+ const fileRefs = new Map();
348
+ for (const entry of listOf(spec.fm, "stories")) {
349
+ const r = storyOf(entry);
350
+ if (r.error) {
351
+ stories.push({ ref: entry, error: r.error });
352
+ refs.unresolved.push({ story: entry, error: r.error });
353
+ continue;
354
+ }
355
+ const codeRefs = listOf(r.story.fm, "code_refs");
356
+ const missing = [];
357
+ for (const ref of codeRefs) {
358
+ const files = expandRef(proj, ref, allFiles, vaultRel);
359
+ if (files === null) {
360
+ missing.push(ref);
361
+ refs.missing.push({ story: entry, ref });
362
+ continue;
363
+ }
364
+ for (const f of files) {
365
+ if (!fileRefs.has(f)) fileRefs.set(f, new Set());
366
+ fileRefs.get(f).add(ref);
367
+ }
368
+ }
369
+ if (!codeRefs.length) refs.empty.push(entry);
370
+ stories.push({
371
+ ref: entry,
372
+ rel: r.story.rel,
373
+ title: String(r.story.fm.title || stemOf(r.story)),
374
+ status: String(r.story.fm.status || "planned"),
375
+ code_refs: codeRefs,
376
+ missing,
377
+ });
378
+ }
379
+
380
+ // Citations: the doctor's scan, keyed by id and alias; `example` is docs.
381
+ const scan = scanCitations(cfg, artifacts, proj, grep) || { status: "ok", reason: null, tokens: [] };
382
+ const keys = citationKeys(artifacts);
383
+ if (scan.status === "ok") {
384
+ for (const t of scan.tokens) {
385
+ if (t.typographic || t.key === CITATION_EXAMPLE_KEY || keys.get(t.key) !== spec) continue;
386
+ const top = t.n.split("/")[0];
387
+ const c = contracts.find((x) => x.n === top);
388
+ if (c) c.cited_by.push({ file: t.file, line: t.line, n: t.n });
389
+ }
390
+ for (const c of contracts) c.cited_by.sort((a, b) => byPath(a.file, b.file) || a.line - b.line || byPath(a.n, b.n));
391
+ } else for (const c of contracts) c.cited_by = null;
392
+
393
+ // Files, with history since each anchor of an open contract.
394
+ const paths = [...fileRefs.keys()].sort(byPath);
395
+ const anchors = [...new Set(contracts.filter((c) => !c.removed && c.anchor).map((c) => c.anchor))].sort(byPath);
396
+ const history = scan.status === "no-git" ? null : historyByAnchor(proj, paths, anchors, git);
397
+ let scanStatus = scan.status;
398
+ let reason = scan.reason || null;
399
+ if (scanStatus === "ok" && history && [...history.values()].some((h) => h === null)) {
400
+ scanStatus = "unknown";
401
+ reason = "git log did not answer";
402
+ }
403
+ const files = paths.map((p) => {
404
+ let changed_since = null;
405
+ // No anchor at all (no `date:`, no valid marker, no --since, or every
406
+ // contract removed): nothing to count from, so history is unknown, not none.
407
+ if (history && anchors.length) {
408
+ changed_since = {};
409
+ for (const a of anchors) {
410
+ const h = history.get(a);
411
+ const commits = h ? h.get(p) || [] : null;
412
+ changed_since[a] = commits ? { count: commits.length, last: commits.slice(0, 3) } : null;
413
+ }
414
+ }
415
+ return {
416
+ path: p,
417
+ class: classOf(p),
418
+ lines: lineCount(join(proj, p)),
419
+ refs: [...fileRefs.get(p)].sort(byPath),
420
+ changed_since,
421
+ };
422
+ });
423
+ for (const c of contracts) {
424
+ if (c.removed || !c.anchor || !history || history.get(c.anchor) === null) continue;
425
+ c.files_changed = files.filter((f) => f.changed_since[c.anchor]?.count > 0).length;
426
+ }
427
+
428
+ const lines = { source: 0, tests: 0, surface: 0, total: 0 };
429
+ const byClass = { source: 0, tests: 0, surface: 0 };
430
+ for (const f of files) {
431
+ byClass[f.class]++;
432
+ if (f.lines !== null) {
433
+ lines[f.class] += f.lines;
434
+ lines.total += f.lines;
435
+ }
436
+ }
437
+ const removed = contracts.filter((c) => c.removed).length;
438
+ const changed =
439
+ history && anchors.length
440
+ ? files.filter((f) => Object.values(f.changed_since).some((x) => x?.count > 0)).length
441
+ : null;
442
+ return {
443
+ bundle: {
444
+ spec: { ...describe(spec), title: realValue(spec.fm.title), date: specDate },
445
+ checkout: basename(proj),
446
+ since,
447
+ scan: { status: scanStatus, reason },
448
+ totals: {
449
+ contracts: contracts.length,
450
+ contracts_removed: removed,
451
+ contracts_open: contracts.length - removed,
452
+ stories: stories.length,
453
+ files: files.length,
454
+ files_by_class: byClass,
455
+ lines,
456
+ files_changed: history && [...history.values()].some((h) => h === null) ? null : changed,
457
+ refs_empty: refs.empty.length,
458
+ refs_missing: refs.missing.length,
459
+ stories_unresolved: refs.unresolved.length,
460
+ },
461
+ contracts,
462
+ stories,
463
+ refs,
464
+ files,
465
+ },
466
+ };
467
+ }
468
+
469
+ // โ”€โ”€โ”€ CLI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
470
+
471
+ function parseArgs(argv) {
472
+ const opts = { query: null, since: null, project: null };
473
+ for (let i = 0; i < argv.length; i++) {
474
+ const a = argv[i];
475
+ if (a === "--since") {
476
+ opts.since = argv[++i];
477
+ if (!opts.since || Number.isNaN(Date.parse(opts.since)))
478
+ die("--since expects a date (YYYY-MM-DD or an ISO timestamp)");
479
+ } else if (a === "--project") {
480
+ opts.project = argv[++i];
481
+ if (!opts.project) die("--project expects a directory");
482
+ } else if (a.startsWith("--")) die(`unknown argument: ${a}`);
483
+ else if (opts.query === null) opts.query = a;
484
+ else die(`one spec per run (got "${opts.query}" and "${a}")`);
485
+ }
486
+ return opts;
487
+ }
488
+
489
+ const named = (c) => `${c.id || c.rel} (${c.rel}, ${c.status})`;
490
+
491
+ function main() {
492
+ const opts = parseArgs(process.argv.slice(2));
493
+ const proj = resolve(opts.project || projectRoot());
494
+ const cfg = readConfigAt(proj);
495
+ if (!cfg || !cfg.vault_path) die(`No projectstore config. Run ${commandForm("bind")} first.`);
496
+ if (!existsSync(cfg.vault_path)) die(`The bound vault is not there: ${cfg.vault_path}`);
497
+ if (!opts.query) die("usage: spec-drift.mjs <spec id|alias|stem> [--since <date>]");
498
+ const r = buildSpecDrift({ proj, cfg, query: opts.query, since: opts.since });
499
+ if (r.error === "not-active")
500
+ die(`Spec ${named(r.spec)} is ${r.status}, not active: spec drift reads an active spec only.`);
501
+ if (r.error === "ambiguous")
502
+ die(`"${r.query}" names more than one spec; name one of: ${r.candidates.map(named).join("; ")}.`);
503
+ if (r.error === "unknown")
504
+ die(
505
+ `No spec answers to "${r.query}" by id, alias or filename. Candidates: ${r.candidates.map(named).join("; ") || "none"}.`,
506
+ );
507
+ // Compact on purpose: the reviewer reads it through a tool whose output may be capped.
508
+ process.stdout.write(JSON.stringify(r.bundle) + "\n");
509
+ }
510
+
511
+ if (isMain(import.meta.url)) {
512
+ main();
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.30.15",
3
+ "version": "0.30.16",
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.15"
40
+ "projectstore": "=0.30.16"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"