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.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +5 -0
- package/node_modules/projectstore/agents/reviewer.md +73 -1
- package/node_modules/projectstore/docs/contracts-and-tests.md +39 -0
- package/node_modules/projectstore/docs/extending.md +10 -0
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/configure-menu.mjs +4 -0
- package/node_modules/projectstore/scripts/configure.mjs +80 -7
- package/node_modules/projectstore/scripts/diff-refs.mjs +15 -10
- package/node_modules/projectstore/scripts/doctor.mjs +2 -2
- package/node_modules/projectstore/scripts/flow-policy.mjs +264 -0
- package/node_modules/projectstore/scripts/lib.mjs +25 -2
- package/node_modules/projectstore/scripts/spec-drift.mjs +513 -0
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "๐ Your agent runs the project through a verified loop: task โ artifact (ADR ยท spec ยท epic ยท story) โ adversarial critic โ backlog โ planner โ reviewer โ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.30.
|
|
15
|
+
"version": "0.30.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.
|
|
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.
|
|
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 === ""
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
480
|
-
|
|
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)
|
|
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 =
|
|
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.
|
|
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.
|
|
40
|
+
"projectstore": "=0.30.16"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|