projectstore-claude 0.30.8 → 0.30.9
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/.mcp.json +1 -5
- package/node_modules/projectstore/README.md +3 -3
- package/node_modules/projectstore/agents/planner.md +6 -0
- package/node_modules/projectstore/agents/reviewer.md +11 -1
- package/node_modules/projectstore/docs/contracts-and-tests.md +107 -0
- package/node_modules/projectstore/harnesses/claude-code.json +3 -3
- package/node_modules/projectstore/harnesses/codex.json +1 -1
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/diff-refs.mjs +39 -0
- package/node_modules/projectstore/scripts/doctor.mjs +360 -5
- package/node_modules/projectstore/scripts/lib.mjs +29 -0
- package/node_modules/projectstore/scripts/mcp-server.mjs +11 -4
- package/node_modules/projectstore/scripts/version-guard.mjs +5 -10
- package/node_modules/projectstore/templates/de/spec.md.tmpl +9 -1
- package/node_modules/projectstore/templates/en/spec.md.tmpl +8 -1
- package/node_modules/projectstore/templates/es/spec.md.tmpl +9 -1
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +9 -1
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +8 -1
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +8 -1
- package/package.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"name": "projectstore",
|
|
13
13
|
"displayName": "projectstore",
|
|
14
14
|
"description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
|
|
15
|
-
"version": "0.30.
|
|
15
|
+
"version": "0.30.9",
|
|
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.9",
|
|
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",
|
|
@@ -240,7 +240,7 @@ Each setting has exactly one writer, and the plan says which file it changes:
|
|
|
240
240
|
|
|
241
241
|
- **The agents' models and efforts** live in `.projectstore/harness/<harness>.json`. That file is committed, so a change there is a team change.
|
|
242
242
|
- **The status line, the band, the language and the other binding keys** live in `.projectstore/projectstore.json`, which stays on this machine.
|
|
243
|
-
- **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.
|
|
243
|
+
- **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 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).
|
|
244
244
|
|
|
245
245
|
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.
|
|
246
246
|
|
|
@@ -398,7 +398,7 @@ ProjectStore has no server, no account and no telemetry. It collects nothing abo
|
|
|
398
398
|
### What it runs
|
|
399
399
|
|
|
400
400
|
- **Node.js.** The hooks, the MCP server, the status line and the `projectstore` command are Node.js scripts in the plugin. They start other scripts of the plugin through the same Node. When setup keeps a project on its git-marketplace copy, it runs that copy's own `bin/projectstore.mjs` to read the copy's plan and, once you confirm, to apply it.
|
|
401
|
-
- **git**, on this machine only. ProjectStore never commits, pushes, pulls or fetches. The SessionStart hook runs `git rev-parse` in a project that is not bound, to see whether it is a worktree of a bound checkout; no other hook starts a program. Bind, init and setup run `git rev-parse --show-toplevel` to find the repository and `git init` to give a new vault its own repository. They run `git config --get user.name` to propose the default author; the binding keeps it, and each new artifact names it as its author. Doctor runs `git check-ignore` and `git ls-files` to check what git ignores and tracks,
|
|
401
|
+
- **git**, on this machine only. ProjectStore never commits, pushes, pulls or fetches. The SessionStart hook runs `git rev-parse` in a project that is not bound, to see whether it is a worktree of a bound checkout; no other hook starts a program. Bind, init and setup run `git rev-parse --show-toplevel` to find the repository and `git init` to give a new vault its own repository. They run `git config --get user.name` to propose the default author; the binding keeps it, and each new artifact names it as its author. Doctor runs `git check-ignore` and `git ls-files` to check what git ignores and tracks, `git diff --name-only` and `git log` to see what has changed, and `git grep --untracked` to find the tests that cite a spec's contracts. The reviewer's list of the files a story touched comes from `git diff` and `git log` too. The reviewer and archaeologist agents read the project's git history (`git status` and `git diff` or `git log` among others) through the agent's own tools, as any command the agent runs.
|
|
402
402
|
- **The agent CLIs, `claude` and `codex`.** Setup, install, upgrade and uninstall run their plugin commands, such as `plugin marketplace add` and `plugin install` to register ProjectStore or `plugin uninstall` to remove it. Each one runs after you confirm the plan, which shows it verbatim. The model menu of `configure` also runs `codex debug models` to read Codex's model list, which Codex refreshes over the network with its own login.
|
|
403
403
|
- **npm**, from setup, install and upgrade only. They run `npm config get registry` to learn which registry your npm uses. When they set up Codex, they also run `npm install` with `--ignore-scripts` to fetch the `projectstore-codex` package at the running version into a folder in ProjectStore's cache, because the Codex plugin ships in that package. No install script of that package runs.
|
|
404
404
|
- **The band above the prompt** (Claude Code) is a module that runs inside Claude Code's own process, not in Node. It reads the binding, the session's pointer and its epic's files, and writes a heartbeat, `.band-<session id>.json` under `.projectstore/state/sessions/`, while it draws; it starts no program and makes no request. It watches agent starts (`agent.spawn`), each agent's requests and tool calls, and each turn's end, only to show a ProjectStore agent's row. It passes each of these on unchanged, and it changes no agent, permission or setting.
|
|
@@ -488,7 +488,7 @@ npx projectstore bind ~/vaults/other --rebind
|
|
|
488
488
|
|
|
489
489
|
## Extending
|
|
490
490
|
|
|
491
|
-
See [`docs/extending.md`](./docs/extending.md) for adding layouts, templates, and skills, and [`docs/harnesses.md`](./docs/harnesses.md) for which coding agents projectstore runs on — what "experimental" means there, and what adding one takes.
|
|
491
|
+
See [`docs/extending.md`](./docs/extending.md) for adding layouts, templates, and skills, [`docs/contracts-and-tests.md`](./docs/contracts-and-tests.md) for linking a spec's contracts to tests, and [`docs/harnesses.md`](./docs/harnesses.md) for which coding agents projectstore runs on — what "experimental" means there, and what adding one takes.
|
|
492
492
|
|
|
493
493
|
## Contributing
|
|
494
494
|
|
|
@@ -69,6 +69,12 @@ generic best practice — cite the real files and patterns you found.
|
|
|
69
69
|
state, ordering, prior fixes this change could regress.
|
|
70
70
|
6. **Tests** — where new tests go, the harness/fixtures to reuse (cite), the
|
|
71
71
|
cases that matter — mapped to the story's acceptance criteria when given.
|
|
72
|
+
A covering spec's contract may carry `- WHEN … THEN …` scenario items: list
|
|
73
|
+
them as the test cases to write, and say each new test cites its contract
|
|
74
|
+
with a token `[<spec id or alias>#<n>]` in its name or a comment beside it
|
|
75
|
+
(written here as `[example#3]`; never put a real spec's token in a plan or
|
|
76
|
+
doc — doctor would count it). A contract marked `(verified: live)` or
|
|
77
|
+
`(verified: manual)` is checked outside the test suite: name how.
|
|
72
78
|
7. **Plan** — a short ordered step list the engineer can follow.
|
|
73
79
|
8. **Proposed `code_refs`** — the paths/globs this epic (and story) will own once
|
|
74
80
|
implemented, ready for `/projectstore:codemap set`. You PROPOSE; the command
|
|
@@ -62,6 +62,15 @@ Code that satisfies no criterion: flag as scope creep, gently. A story is
|
|
|
62
62
|
or accidental?
|
|
63
63
|
- **Tests** — do new/changed paths have tests that ASSERT the behavior? Run them
|
|
64
64
|
cheaply when checkable (read-only).
|
|
65
|
+
- **Contract citations** — a new test that covers a covering spec's contract
|
|
66
|
+
cites it with `[<spec id or alias>#<n>]` (written here as `[example#3]`).
|
|
67
|
+
Each of these is a finding:
|
|
68
|
+
- a new test that covers a contract and cites nothing;
|
|
69
|
+
- a citation on a test that does not assert the contract's observable
|
|
70
|
+
outcome — an empty or trivially passing test with a token is worse than no
|
|
71
|
+
token;
|
|
72
|
+
- a real token outside the tests (docs, README, plans, code that is not a
|
|
73
|
+
test), because doctor counts it as coverage. Docs use only `example`.
|
|
65
74
|
|
|
66
75
|
## Discovery ≠ filtering
|
|
67
76
|
Report every finding, severity + confidence annotated; recall is your job,
|
|
@@ -79,7 +88,8 @@ say so plainly.
|
|
|
79
88
|
2. **Acceptance matrix** — one line per criterion: status + evidence. Include
|
|
80
89
|
the covering specs' attributed + unattributed acceptance items (additive).
|
|
81
90
|
Format each evidence value so it can be persisted verbatim into the story
|
|
82
|
-
file at close: `— evidence: <test | command | file:line>`
|
|
91
|
+
file at close: `— evidence: <test | command | file:line>` (a test may be
|
|
92
|
+
named by its contract token, `[example#3]`, which doctor checks) — the close gate
|
|
83
93
|
(`/projectstore:story close`) copies your matrix into the checkboxes.
|
|
84
94
|
3. **Findings** — severity-rated: `🔴 blocker` / `🟡 should-fix` / `🟢 nit`; each
|
|
85
95
|
with file:line, confidence, why it matters, and a specific fix.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Contracts and tests
|
|
2
|
+
|
|
3
|
+
A spec's behavioral contracts are numbered and testable. This page shows how
|
|
4
|
+
to write a contract so a test follows from it, how a test cites the contract
|
|
5
|
+
it covers, and what doctor reports. It describes Part A of the ADR *Spec
|
|
6
|
+
policy, revised: contract scenarios and test citations that doctor counts,
|
|
7
|
+
and a declared per-story opt-out from a spec*.
|
|
8
|
+
|
|
9
|
+
All of it is optional. A spec without scenarios, and a test without a
|
|
10
|
+
citation, stay valid.
|
|
11
|
+
|
|
12
|
+
Every example on this page uses the key `example`. It is reserved: no spec
|
|
13
|
+
may use it as an id or alias, so a token written with it never resolves and
|
|
14
|
+
never counts as coverage. A real token in a doc would count, so docs use only
|
|
15
|
+
`example`.
|
|
16
|
+
|
|
17
|
+
## Scenarios under a contract
|
|
18
|
+
|
|
19
|
+
A contract may carry list items that start with `WHEN`, `GIVEN`, `AND` or
|
|
20
|
+
`THEN`:
|
|
21
|
+
|
|
22
|
+
```markdown
|
|
23
|
+
3. **A stale index is reported.** Doctor compares each folder index with the
|
|
24
|
+
artifacts on disk and reports a row that no longer matches.
|
|
25
|
+
- GIVEN an index row whose status differs from the artifact's frontmatter
|
|
26
|
+
- WHEN doctor runs with --vault
|
|
27
|
+
- THEN it reports an index finding that names the folder
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The items are a list, so Markdown keeps one step per line. The keywords stay
|
|
31
|
+
in English in every language, like `— stories:`: a tool matches them
|
|
32
|
+
literally.
|
|
33
|
+
|
|
34
|
+
Doctor reads scenarios for one thing only. If a contract's scenario has no
|
|
35
|
+
`WHEN` (or `GIVEN`), or no `THEN`, it reports an info.
|
|
36
|
+
|
|
37
|
+
## A contract no test can cover
|
|
38
|
+
|
|
39
|
+
Some contracts are checked on a real host after install, or by a person.
|
|
40
|
+
Mark them in the contract's first paragraph:
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
7. **The band draws in a live session.** (verified: live)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use `(verified: live)` or `(verified: manual)`. Doctor does not count a
|
|
47
|
+
marked contract as uncovered.
|
|
48
|
+
|
|
49
|
+
## Citing a contract from a test
|
|
50
|
+
|
|
51
|
+
A test cites a contract with a bracketed token, `[<key>#<n>]`, in its name or
|
|
52
|
+
in a comment beside it:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// [example#3]
|
|
56
|
+
test("doctor reports a stale index row", () => {
|
|
57
|
+
// … assert the finding names the folder
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `<key>` is the spec's frontmatter `id`, or its `alias`. Matching is
|
|
62
|
+
case-sensitive.
|
|
63
|
+
- `<n>` is the contract number in ASCII: `0`, `4`, `4'` for `4′`, `4''` for
|
|
64
|
+
`4″`. A step nested under contract 4 is `4/1''`. A cited step does not
|
|
65
|
+
cover its parent.
|
|
66
|
+
- The closing bracket ends the token, so `[example#4]` never matches
|
|
67
|
+
contract `4'` or `40`. A token followed by `(` is a Markdown link, and a
|
|
68
|
+
`[[…]]` is a wikilink. Neither is a citation.
|
|
69
|
+
|
|
70
|
+
A slug id runs to about 60 characters, so give a spec a short alias in its
|
|
71
|
+
frontmatter:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
alias: billing
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Its tests then cite its contracts with `billing` as the key. (This page
|
|
78
|
+
writes that out in words rather than as a token, because a token in a doc
|
|
79
|
+
is scanned like one in a test.)
|
|
80
|
+
|
|
81
|
+
An alias is frozen like a slug, because renaming it breaks every citation.
|
|
82
|
+
Two specs may not share an alias, and an alias may not be another spec's id.
|
|
83
|
+
|
|
84
|
+
A citation is a claim: the reviewer checks that a cited test asserts the
|
|
85
|
+
contract's outcome. An empty test with a token is a review finding.
|
|
86
|
+
|
|
87
|
+
## What doctor reports
|
|
88
|
+
|
|
89
|
+
Doctor (`--vault`) scans the bound project with `git grep --untracked`, so a
|
|
90
|
+
test you have not added yet is found too. `.gitignore`d files, a vault
|
|
91
|
+
inside the project, `node_modules/` and the other generated paths are
|
|
92
|
+
skipped. Tests outside the bound project, or in a submodule, are not seen.
|
|
93
|
+
|
|
94
|
+
| Finding | Level | When |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `contract-coverage` | info | One line per active spec. With no citation: "N contracts, 0 cited". Once a spec has a citation, or sets `coverage: report` in its frontmatter: the contracts no test cites |
|
|
97
|
+
| `citation-unknown` | warn | A token whose key is no spec's id or alias, or whose number is not a contract of that spec |
|
|
98
|
+
| `citation-superseded` | info | A token that cites a superseded spec |
|
|
99
|
+
| `scenario-shape` | info | A scenario without a WHEN/GIVEN or a THEN |
|
|
100
|
+
| `spec-alias` | issue | A duplicate alias, an alias equal to another spec's id, or the reserved `example` |
|
|
101
|
+
| `evidence-citation` | warn | Under `lifecycle_gates: on`: a story's evidence names a token that no file in the project carries |
|
|
102
|
+
|
|
103
|
+
Each report names the checkout it read. A worktree on an old branch, or a
|
|
104
|
+
second project bound to the same vault, can see different coverage.
|
|
105
|
+
|
|
106
|
+
Doctor never runs a test. CI and `npm test` do that. Doctor checks the
|
|
107
|
+
record: which contracts are cited, and whether a cited token still exists.
|
|
@@ -311,9 +311,9 @@
|
|
|
311
311
|
"dir": ".",
|
|
312
312
|
"file": ".mcp.json",
|
|
313
313
|
"entry": "scripts/mcp-server.mjs",
|
|
314
|
-
"entry_reason": "The script .mcp.json runs, under the plugin root, with
|
|
314
|
+
"entry_reason": "The script .mcp.json runs, under the plugin root, with no other argument: the project comes from CLAUDE_PROJECT_DIR, which the host exports to the server (launch.project_dir_in_env), since 0.30.9 (PS-DIRECTORY, the story \"The MCP server's entry names no variable but ${CLAUDE_PLUGIN_ROOT}…\"). It lives under scripts/, never bin/ (a plugin's top-level bin/ is on the host's Bash PATH), and imports nothing from cli.mjs, so the server's imports reach no install verb (PS-DIRECTORY, the story \"The MCP server's entry reaches no install verb through a read-verb runner that shares the CLI's verb table…\"; the MCP ADR's decision 1 as amended). The CLI's `mcp` verb serves the same way, for a host without a plugin channel.",
|
|
315
315
|
"scope": "plugin",
|
|
316
|
-
"scope_reason": "Claude Code loads a plugin's .mcp.json from the plugin installation itself and expands ${CLAUDE_PLUGIN_ROOT} and
|
|
316
|
+
"scope_reason": "Claude Code loads a plugin's .mcp.json from the plugin installation itself and expands ${CLAUDE_PLUGIN_ROOT} in it and exports CLAUDE_PROJECT_DIR to the server per session, so the one shipped file binds each server to one project (MCP ADR decision 6, amended 2026-09-05); nothing is installed and nothing is derived — the host kind install-harness skips, which the install spec's contract 0 does not yet name.",
|
|
317
317
|
"launch": {
|
|
318
318
|
"cwd": "project",
|
|
319
319
|
"project_dir_in_env": true,
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
],
|
|
327
327
|
"default_syntax": "${VAR:-default}",
|
|
328
328
|
"unset_placeholder_in_env": "literal",
|
|
329
|
-
"unset_placeholder_in_args": "inferred literal — the
|
|
329
|
+
"unset_placeholder_in_args": "inferred literal — measured through the 0.30.8 form, which the server refused as a placeholder",
|
|
330
330
|
"entrypoint": "sdk-cli (headless -p), plugin loaded with --plugin-dir; the marketplace install and an interactive session are A9's",
|
|
331
331
|
"protocol_era": "initialize",
|
|
332
332
|
"protocol_version_seen": "2025-11-25",
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"kind": "host",
|
|
241
241
|
"scope": "project",
|
|
242
242
|
"scope_reason": "Codex auto-discovers a plugin-root .mcp.json even when the manifest does not declare mcpServers — measured.",
|
|
243
|
-
"why_unsupported": "Our .mcp.json is in Claude Code's dialect: its argv
|
|
243
|
+
"why_unsupported": "Our .mcp.json is in Claude Code's dialect: its argv needs ${CLAUDE_PLUGIN_ROOT} expanded, which nothing substitutes there, and the server needs CLAUDE_PROJECT_DIR in its environment, which Codex does not export. With its earlier argv, which named the project variable too, the server failed its handshake and printed a red error in every session. Ships only once the Codex dialect is rendered and the spike's criterion (h) says what cwd and environment the server receives."
|
|
244
244
|
},
|
|
245
245
|
"skills": {
|
|
246
246
|
"supported": true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.9",
|
|
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",
|
|
@@ -64,6 +64,45 @@ export function lastCommitMs(cwd = projectRoot()) {
|
|
|
64
64
|
return Number.isFinite(t) ? t : null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The citation scan (the spec policy ADR, Decision 7): every line in the
|
|
68
|
+
// project, untracked files included, that looks like a contract citation.
|
|
69
|
+
// `.gitignore` is honoured, `exclude` paths (the vault, when it sits inside
|
|
70
|
+
// the project) are skipped, and SOURCE_IGNORE paths are dropped. Unlike gitIn
|
|
71
|
+
// this tells "no match" (status ok, no lines) from "not a repository"
|
|
72
|
+
// (no-git) and from every other failure — no git binary, a timeout, an exit
|
|
73
|
+
// git explains on stderr (unknown, with that reason): only the first is an
|
|
74
|
+
// answer.
|
|
75
|
+
export function grepProjectLines(cwd, pattern, { exclude = [], timeout = 10000, ignore = IGNORE } = {}) {
|
|
76
|
+
const args = [
|
|
77
|
+
"grep",
|
|
78
|
+
"--untracked",
|
|
79
|
+
"-n",
|
|
80
|
+
"-I",
|
|
81
|
+
"-E",
|
|
82
|
+
"-e",
|
|
83
|
+
pattern,
|
|
84
|
+
"--",
|
|
85
|
+
".",
|
|
86
|
+
...exclude.map((p) => `:(exclude)${p}`),
|
|
87
|
+
];
|
|
88
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout, maxBuffer: 64 * 1024 * 1024 });
|
|
89
|
+
if (r.error?.code === "ENOENT") return { status: "unknown", reason: "git is not installed", lines: [] };
|
|
90
|
+
if (r.error || r.signal) return { status: "unknown", reason: "the scan timed out", lines: [] };
|
|
91
|
+
if (r.status === 1) return { status: "ok", lines: [] };
|
|
92
|
+
if (/not a git repository/i.test(r.stderr || "")) return { status: "no-git", lines: [] };
|
|
93
|
+
if (r.status !== 0) {
|
|
94
|
+
const why = (r.stderr || "").trim().split("\n")[0] || `git exited ${r.status}`;
|
|
95
|
+
return { status: "unknown", reason: why, lines: [] };
|
|
96
|
+
}
|
|
97
|
+
const lines = [];
|
|
98
|
+
for (const raw of r.stdout.split("\n")) {
|
|
99
|
+
const m = raw.match(/^(.*?):(\d+):(.*)$/);
|
|
100
|
+
if (!m || ignore.some((rx) => rx.test(m[1]))) continue;
|
|
101
|
+
lines.push({ file: m[1], line: Number(m[2]), text: m[3] });
|
|
102
|
+
}
|
|
103
|
+
return { status: "ok", lines };
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
function filterFiles(list) {
|
|
68
107
|
return [...new Set(list)]
|
|
69
108
|
.map((f) => f.trim())
|
|
@@ -40,6 +40,10 @@ import {
|
|
|
40
40
|
indexHeaderRe,
|
|
41
41
|
evidenceSuffixRe,
|
|
42
42
|
storiesAttributionRe,
|
|
43
|
+
citationTokenRe,
|
|
44
|
+
contractNumberAscii,
|
|
45
|
+
CITATION_EXAMPLE_KEY,
|
|
46
|
+
CITATION_PREFILTER,
|
|
43
47
|
slugIdentity,
|
|
44
48
|
isLegacyNumberedId,
|
|
45
49
|
storyMatchesEntry,
|
|
@@ -124,7 +128,7 @@ import { effortSpec } from "./model-spec.mjs";
|
|
|
124
128
|
// story "The prompt surface asks the shell for a variable the host would have
|
|
125
129
|
// substituted"; harness neutrality is unaffected, an absolute path carries no
|
|
126
130
|
// brand, and --harness still carries the target.
|
|
127
|
-
import { uncommittedProjectFiles, lastCommitMs } from "./diff-refs.mjs";
|
|
131
|
+
import { uncommittedProjectFiles, lastCommitMs, grepProjectLines } from "./diff-refs.mjs";
|
|
128
132
|
import { resolveBinding } from "./worktree.mjs";
|
|
129
133
|
import { missingScaffoldRequirements } from "./scaffold.mjs";
|
|
130
134
|
|
|
@@ -2675,6 +2679,351 @@ export function checkLifecycleGates(artifacts, vaultCfg) {
|
|
|
2675
2679
|
return out;
|
|
2676
2680
|
}
|
|
2677
2681
|
|
|
2682
|
+
// ─── Contract scenarios and citations (the spec policy ADR, Part A) ───
|
|
2683
|
+
|
|
2684
|
+
// A spec's Behavioral contracts as data. A contract is a top-level numbered
|
|
2685
|
+
// item (`4.`, `4′.`, `0.`) in the contracts section; its first nested
|
|
2686
|
+
// numbered level is its steps; deeper levels belong to their parent. HTML
|
|
2687
|
+
// comments are stripped first, so a template's example `1.` inside a comment
|
|
2688
|
+
// is not a contract. Scenario items are `- WHEN|GIVEN|AND|THEN …` list items
|
|
2689
|
+
// under a contract; the `(verified: live|manual)` marker is looked for in the
|
|
2690
|
+
// contract's first paragraph, which a wrapped bold heading may span.
|
|
2691
|
+
export function parseSpecContracts(spec) {
|
|
2692
|
+
const sec = sectionOf(spec.body || "", "spec_contracts");
|
|
2693
|
+
if (sec === null) return null;
|
|
2694
|
+
const lines = linesOutsideFences(sec.replace(/<!--[\s\S]*?-->/g, ""));
|
|
2695
|
+
const contracts = [];
|
|
2696
|
+
let cur = null;
|
|
2697
|
+
let inFirstPara = false;
|
|
2698
|
+
for (const line of lines) {
|
|
2699
|
+
const top = line.match(/^(\d+[′″']*)\.(\s|$)/);
|
|
2700
|
+
if (top) {
|
|
2701
|
+
cur = {
|
|
2702
|
+
n: contractNumberAscii(top[1]),
|
|
2703
|
+
steps: new Set(),
|
|
2704
|
+
stepIndent: null,
|
|
2705
|
+
scenarios: [],
|
|
2706
|
+
marker: null,
|
|
2707
|
+
first: line,
|
|
2708
|
+
};
|
|
2709
|
+
contracts.push(cur);
|
|
2710
|
+
inFirstPara = true;
|
|
2711
|
+
continue;
|
|
2712
|
+
}
|
|
2713
|
+
if (!cur) continue;
|
|
2714
|
+
if (/^\s*$/.test(line)) {
|
|
2715
|
+
inFirstPara = false;
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
2718
|
+
if (inFirstPara) cur.first += " " + line.trim();
|
|
2719
|
+
const step = line.match(/^(\s+)(\d+[′″']*)\.(\s|$)/);
|
|
2720
|
+
if (step) {
|
|
2721
|
+
if (cur.stepIndent === null) cur.stepIndent = step[1].length;
|
|
2722
|
+
if (step[1].length === cur.stepIndent) cur.steps.add(contractNumberAscii(step[2]));
|
|
2723
|
+
continue;
|
|
2724
|
+
}
|
|
2725
|
+
const sc = line.match(/^\s*-\s+(WHEN|GIVEN|AND|THEN)\b/);
|
|
2726
|
+
if (sc) cur.scenarios.push(line.trim());
|
|
2727
|
+
}
|
|
2728
|
+
for (const c of contracts) {
|
|
2729
|
+
const mk = c.first.match(/\(verified\s*[::]\s*(live|manual)\)/);
|
|
2730
|
+
c.marker = mk ? mk[1] : null;
|
|
2731
|
+
delete c.first;
|
|
2732
|
+
delete c.stepIndent;
|
|
2733
|
+
}
|
|
2734
|
+
return contracts;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
// Key → spec, by frontmatter id and alias, exact and case-sensitive. Not
|
|
2738
|
+
// buildSpecResolver: its filename fallback is lower-cased, and a citation
|
|
2739
|
+
// token resolves only to what a spec declares.
|
|
2740
|
+
function citationKeys(artifacts) {
|
|
2741
|
+
const keys = new Map();
|
|
2742
|
+
for (const s of artifacts.filter((a) => a.kind === "spec")) {
|
|
2743
|
+
for (const k of [String(s.fm.id || ""), String(s.fm.alias || "")]) {
|
|
2744
|
+
if (k && k !== "null" && !keys.has(k)) keys.set(k, s);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
return keys;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// Alias rules (Decision 5): unique across specs, never another spec's id,
|
|
2751
|
+
// never the reserved example key. An alias is frozen like a slug.
|
|
2752
|
+
export function checkSpecAliases(artifacts) {
|
|
2753
|
+
const out = [];
|
|
2754
|
+
const specs = artifacts.filter((a) => a.kind === "spec");
|
|
2755
|
+
const ids = new Map(specs.map((s) => [String(s.fm.id || ""), s]));
|
|
2756
|
+
const seen = new Map();
|
|
2757
|
+
for (const s of specs) {
|
|
2758
|
+
const id = String(s.fm.id || "");
|
|
2759
|
+
if (id === CITATION_EXAMPLE_KEY) {
|
|
2760
|
+
out.push(
|
|
2761
|
+
finding(
|
|
2762
|
+
"vault",
|
|
2763
|
+
"issue",
|
|
2764
|
+
"spec-alias",
|
|
2765
|
+
`Spec id "${id}" is the reserved citation key — docs use it for tokens that never resolve.`,
|
|
2766
|
+
s.rel,
|
|
2767
|
+
),
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
const alias = s.fm.alias && s.fm.alias !== "null" ? String(s.fm.alias) : null;
|
|
2771
|
+
if (!alias) continue;
|
|
2772
|
+
if (alias === CITATION_EXAMPLE_KEY) {
|
|
2773
|
+
out.push(
|
|
2774
|
+
finding(
|
|
2775
|
+
"vault",
|
|
2776
|
+
"issue",
|
|
2777
|
+
"spec-alias",
|
|
2778
|
+
`Spec alias "${alias}" is the reserved citation key — pick another.`,
|
|
2779
|
+
s.rel,
|
|
2780
|
+
),
|
|
2781
|
+
);
|
|
2782
|
+
} else if (ids.has(alias) && ids.get(alias) !== s) {
|
|
2783
|
+
out.push(
|
|
2784
|
+
finding(
|
|
2785
|
+
"vault",
|
|
2786
|
+
"issue",
|
|
2787
|
+
"spec-alias",
|
|
2788
|
+
`Spec alias "${alias}" is another spec's id (${ids.get(alias).rel}) — a citation would be ambiguous.`,
|
|
2789
|
+
s.rel,
|
|
2790
|
+
),
|
|
2791
|
+
);
|
|
2792
|
+
} else if (seen.has(alias)) {
|
|
2793
|
+
out.push(
|
|
2794
|
+
finding(
|
|
2795
|
+
"vault",
|
|
2796
|
+
"issue",
|
|
2797
|
+
"spec-alias",
|
|
2798
|
+
`Spec alias "${alias}" is also the alias of ${seen.get(alias).rel} — a citation would be ambiguous.`,
|
|
2799
|
+
s.rel,
|
|
2800
|
+
),
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
if (!seen.has(alias)) seen.set(alias, s);
|
|
2804
|
+
}
|
|
2805
|
+
return out;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// Scenario shape (Decision 3): a contract's scenario items need a WHEN (or
|
|
2809
|
+
// GIVEN) and a THEN. That is the only reading of scenarios.
|
|
2810
|
+
export function checkScenarioShape(artifacts) {
|
|
2811
|
+
const out = [];
|
|
2812
|
+
for (const s of artifacts.filter((a) => a.kind === "spec")) {
|
|
2813
|
+
for (const c of parseSpecContracts(s) || []) {
|
|
2814
|
+
if (!c.scenarios.length) continue;
|
|
2815
|
+
const when = c.scenarios.some((l) => /^-\s+(WHEN|GIVEN)\b/.test(l));
|
|
2816
|
+
const then = c.scenarios.some((l) => /^-\s+THEN\b/.test(l));
|
|
2817
|
+
if (!when || !then) {
|
|
2818
|
+
out.push(
|
|
2819
|
+
finding(
|
|
2820
|
+
"vault",
|
|
2821
|
+
"info",
|
|
2822
|
+
"scenario-shape",
|
|
2823
|
+
`Contract ${c.n}'s scenario has no ${!when ? "WHEN (or GIVEN)" : "THEN"} step.`,
|
|
2824
|
+
s.rel,
|
|
2825
|
+
),
|
|
2826
|
+
);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
return out;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// The one scan per doctor run: every citation-shaped line in the bound
|
|
2834
|
+
// project, skipping the vault when it sits inside it. null = no active spec
|
|
2835
|
+
// with contracts, so nothing was scanned (and git never ran).
|
|
2836
|
+
export function scanCitations(cfg, artifacts, proj, grep = grepProjectLines) {
|
|
2837
|
+
const active = artifacts.filter((a) => a.kind === "spec" && String(a.fm.status || "") === "active");
|
|
2838
|
+
if (!active.some((s) => (parseSpecContracts(s) || []).length)) return null;
|
|
2839
|
+
const exclude = [];
|
|
2840
|
+
try {
|
|
2841
|
+
const p = realpathSync.native(proj);
|
|
2842
|
+
const v = realpathSync.native(cfg.vault_path);
|
|
2843
|
+
const rel = relative(p, v);
|
|
2844
|
+
if (rel && !rel.startsWith("..") && !rel.startsWith("/")) exclude.push(rel);
|
|
2845
|
+
} catch {}
|
|
2846
|
+
const r = grep(proj, CITATION_PREFILTER, { exclude });
|
|
2847
|
+
const tokens = [];
|
|
2848
|
+
// A token written with the spec's own primes (`#4′`) is not a citation; it
|
|
2849
|
+
// is kept, flagged, so the check can say "write it in ASCII" instead of
|
|
2850
|
+
// dropping it silently.
|
|
2851
|
+
const typographic = /(?<!\[)\[([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+[′″']*(?:\/\d+[′″']*)?)\](?![(\]])/g;
|
|
2852
|
+
for (const { file, line, text } of r.lines) {
|
|
2853
|
+
for (const m of text.matchAll(citationTokenRe())) tokens.push({ key: m[1], n: m[2], file, line });
|
|
2854
|
+
for (const m of text.matchAll(typographic)) {
|
|
2855
|
+
if (/[′″]/.test(m[2])) tokens.push({ key: m[1], n: m[2], file, line, typographic: true });
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
return { status: r.status, reason: r.reason || null, tokens, checkout: basename(proj) };
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
// Unknown tokens and coverage (Decisions 8 and 9). Coverage is quiet: a spec
|
|
2862
|
+
// nothing cites gets one line; the uncited list appears only once a spec has
|
|
2863
|
+
// a citation, or sets `coverage: report`. A marked contract is not counted,
|
|
2864
|
+
// and a cited step does not cover its parent.
|
|
2865
|
+
export function checkContractCitations(artifacts, scan) {
|
|
2866
|
+
if (!scan) return [];
|
|
2867
|
+
if (scan.status === "no-git") {
|
|
2868
|
+
return [
|
|
2869
|
+
finding(
|
|
2870
|
+
"vault",
|
|
2871
|
+
"info",
|
|
2872
|
+
"contract-coverage",
|
|
2873
|
+
"Contract coverage not checked — the bound project is not a git repository.",
|
|
2874
|
+
),
|
|
2875
|
+
];
|
|
2876
|
+
}
|
|
2877
|
+
if (scan.status === "unknown") {
|
|
2878
|
+
return [
|
|
2879
|
+
finding(
|
|
2880
|
+
"vault",
|
|
2881
|
+
"info",
|
|
2882
|
+
"contract-coverage",
|
|
2883
|
+
`Contract coverage not checked — ${scan.reason || "the citation scan did not finish"}.`,
|
|
2884
|
+
),
|
|
2885
|
+
];
|
|
2886
|
+
}
|
|
2887
|
+
const out = [];
|
|
2888
|
+
const keys = citationKeys(artifacts);
|
|
2889
|
+
const contractsOf = new Map();
|
|
2890
|
+
const cited = new Map();
|
|
2891
|
+
const citedIn = new Map();
|
|
2892
|
+
for (const t of scan.tokens) {
|
|
2893
|
+
if (t.key === CITATION_EXAMPLE_KEY) continue;
|
|
2894
|
+
const where = `${t.file}:${t.line}`;
|
|
2895
|
+
if (t.typographic) {
|
|
2896
|
+
out.push(
|
|
2897
|
+
finding(
|
|
2898
|
+
"vault",
|
|
2899
|
+
"warn",
|
|
2900
|
+
"citation-unknown",
|
|
2901
|
+
`[${t.key}#${t.n}] uses typographic primes — write the number in ASCII: [${t.key}#${contractNumberAscii(t.n)}].`,
|
|
2902
|
+
where,
|
|
2903
|
+
),
|
|
2904
|
+
);
|
|
2905
|
+
continue;
|
|
2906
|
+
}
|
|
2907
|
+
const spec = keys.get(t.key);
|
|
2908
|
+
if (!spec) {
|
|
2909
|
+
out.push(
|
|
2910
|
+
finding(
|
|
2911
|
+
"vault",
|
|
2912
|
+
"warn",
|
|
2913
|
+
"citation-unknown",
|
|
2914
|
+
`[${t.key}#${t.n}] names no spec id or alias (a typo, or a renamed alias?).`,
|
|
2915
|
+
where,
|
|
2916
|
+
),
|
|
2917
|
+
);
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
if (!contractsOf.has(spec)) contractsOf.set(spec, parseSpecContracts(spec) || []);
|
|
2921
|
+
const [top, step] = t.n.split("/");
|
|
2922
|
+
const c = contractsOf.get(spec).find((x) => x.n === top);
|
|
2923
|
+
if (!c || (step && !c.steps.has(step))) {
|
|
2924
|
+
out.push(
|
|
2925
|
+
finding("vault", "warn", "citation-unknown", `[${t.key}#${t.n}] — ${spec.rel} has no contract ${t.n}.`, where),
|
|
2926
|
+
);
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2929
|
+
if (String(spec.fm.status || "") === "superseded") {
|
|
2930
|
+
out.push(
|
|
2931
|
+
finding(
|
|
2932
|
+
"vault",
|
|
2933
|
+
"info",
|
|
2934
|
+
"citation-superseded",
|
|
2935
|
+
`[${t.key}#${t.n}] cites a superseded spec (${spec.rel}) — move it to the successor.`,
|
|
2936
|
+
where,
|
|
2937
|
+
),
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
if (!step) {
|
|
2941
|
+
if (!cited.has(spec)) cited.set(spec, new Set());
|
|
2942
|
+
cited.get(spec).add(top);
|
|
2943
|
+
}
|
|
2944
|
+
if (!citedIn.has(spec)) citedIn.set(spec, new Set());
|
|
2945
|
+
citedIn.get(spec).add(t.file);
|
|
2946
|
+
}
|
|
2947
|
+
// `--json` carries the files that cite each spec, so a valid token in a doc
|
|
2948
|
+
// (which counts as coverage) is easy to find (Decision 8).
|
|
2949
|
+
const withCites = (f, s) => {
|
|
2950
|
+
if (citedIn.has(s)) f.cites = [...citedIn.get(s)].sort();
|
|
2951
|
+
return f;
|
|
2952
|
+
};
|
|
2953
|
+
for (const s of artifacts.filter((a) => a.kind === "spec" && String(a.fm.status || "") === "active")) {
|
|
2954
|
+
const contracts = contractsOf.get(s) || parseSpecContracts(s) || [];
|
|
2955
|
+
if (!contracts.length) continue;
|
|
2956
|
+
const got = cited.get(s) || new Set();
|
|
2957
|
+
if (!got.size && String(s.fm.coverage || "") !== "report") {
|
|
2958
|
+
out.push(
|
|
2959
|
+
finding(
|
|
2960
|
+
"vault",
|
|
2961
|
+
"info",
|
|
2962
|
+
"contract-coverage",
|
|
2963
|
+
`${contracts.length} contracts, 0 cited (checkout ${scan.checkout}).`,
|
|
2964
|
+
s.rel,
|
|
2965
|
+
),
|
|
2966
|
+
);
|
|
2967
|
+
continue;
|
|
2968
|
+
}
|
|
2969
|
+
const open = contracts.filter((c) => !c.marker && !got.has(c.n)).map((c) => c.n);
|
|
2970
|
+
if (open.length) {
|
|
2971
|
+
out.push(
|
|
2972
|
+
withCites(
|
|
2973
|
+
finding(
|
|
2974
|
+
"vault",
|
|
2975
|
+
"info",
|
|
2976
|
+
"contract-coverage",
|
|
2977
|
+
`${open.length} of ${contracts.length} contracts uncited (checkout ${scan.checkout}): ${open.join(", ")}.`,
|
|
2978
|
+
s.rel,
|
|
2979
|
+
),
|
|
2980
|
+
s,
|
|
2981
|
+
),
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
return out;
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// Evidence that cites a contract (Decision 10), behind lifecycle_gates: on.
|
|
2989
|
+
// A token not found in the project is a warn for a story in progress or in
|
|
2990
|
+
// review, and for a done story that is not legacy. Free-text evidence is
|
|
2991
|
+
// never checked, and doctor never runs a test.
|
|
2992
|
+
export function checkEvidenceCitations(artifacts, vaultCfg, scan) {
|
|
2993
|
+
const gates = String(vaultCfg.lifecycle_gates || "off").toLowerCase();
|
|
2994
|
+
if (!["on", "true"].includes(gates) || !scan || scan.status !== "ok") return [];
|
|
2995
|
+
const since = vaultCfg.spec_policy_since || null;
|
|
2996
|
+
const present = new Set(scan.tokens.map((t) => `${t.key}#${t.n}`));
|
|
2997
|
+
const evidenceRe = evidenceSuffixRe();
|
|
2998
|
+
const out = [];
|
|
2999
|
+
for (const story of artifacts.filter((a) => a.kind === "story")) {
|
|
3000
|
+
const st = String(story.fm.status || "").toLowerCase();
|
|
3001
|
+
if (!["in-progress", "in_progress", "review", "done"].includes(st)) continue;
|
|
3002
|
+
if (st === "done" && isLegacyStory(story.fm, since)) continue;
|
|
3003
|
+
const acc = sectionOf(story.body, "acceptance");
|
|
3004
|
+
if (acc === null) continue;
|
|
3005
|
+
for (const line of linesOutsideFences(acc)) {
|
|
3006
|
+
const m = line.match(/^\s*-\s*\[(x|X)\]\s*(.*)$/);
|
|
3007
|
+
if (!m) continue;
|
|
3008
|
+
const at = m[2].search(evidenceRe);
|
|
3009
|
+
if (at === -1) continue;
|
|
3010
|
+
for (const t of m[2].slice(at).matchAll(citationTokenRe())) {
|
|
3011
|
+
if (t[1] === CITATION_EXAMPLE_KEY || present.has(`${t[1]}#${t[2]}`)) continue;
|
|
3012
|
+
out.push(
|
|
3013
|
+
finding(
|
|
3014
|
+
"vault",
|
|
3015
|
+
"warn",
|
|
3016
|
+
"evidence-citation",
|
|
3017
|
+
`Evidence cites [${t[1]}#${t[2]}], which no file in checkout ${scan.checkout} carries (a renamed or removed test, or an older branch?).`,
|
|
3018
|
+
story.rel,
|
|
3019
|
+
),
|
|
3020
|
+
);
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
return out;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
2678
3027
|
// Suggestion for existing binds: specs exist but no vault policy declared.
|
|
2679
3028
|
export function checkVaultPolicy(cfg, layout, artifacts, vaultCfg) {
|
|
2680
3029
|
const out = [];
|
|
@@ -3237,7 +3586,7 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
|
|
|
3237
3586
|
return foldIntoMove(out);
|
|
3238
3587
|
}
|
|
3239
3588
|
|
|
3240
|
-
export function runVaultChecks(cfg) {
|
|
3589
|
+
export function runVaultChecks(cfg, { proj = projectRoot(), grep = grepProjectLines } = {}) {
|
|
3241
3590
|
let layout;
|
|
3242
3591
|
try {
|
|
3243
3592
|
layout = loadLayout(cfg.layout);
|
|
@@ -3258,6 +3607,12 @@ export function runVaultChecks(cfg) {
|
|
|
3258
3607
|
() => checkSpecCoverage(artifacts, vaultCfg, layout),
|
|
3259
3608
|
() => checkSpecAcceptance(layout, artifacts, vaultCfg),
|
|
3260
3609
|
() => checkLifecycleGates(artifacts, vaultCfg),
|
|
3610
|
+
() => checkSpecAliases(artifacts),
|
|
3611
|
+
() => checkScenarioShape(artifacts),
|
|
3612
|
+
() => {
|
|
3613
|
+
const scan = scanCitations(cfg, artifacts, proj, grep);
|
|
3614
|
+
return [...checkContractCitations(artifacts, scan), ...checkEvidenceCitations(artifacts, vaultCfg, scan)];
|
|
3615
|
+
},
|
|
3261
3616
|
];
|
|
3262
3617
|
for (const step of guarded) {
|
|
3263
3618
|
try {
|
|
@@ -3274,8 +3629,8 @@ export function runVaultChecks(cfg) {
|
|
|
3274
3629
|
...checkArtifactIdentity(layout, vaultFiles, artifacts),
|
|
3275
3630
|
...checkArtifactNames(vaultFiles),
|
|
3276
3631
|
...checkExternalRefsForm(artifacts),
|
|
3277
|
-
...checkCodeRefs(artifacts,
|
|
3278
|
-
...checkWorkWithoutStory(cfg,
|
|
3632
|
+
...checkCodeRefs(artifacts, proj),
|
|
3633
|
+
...checkWorkWithoutStory(cfg, proj),
|
|
3279
3634
|
...checkCodeMap(cfg),
|
|
3280
3635
|
...checkGraph(cfg),
|
|
3281
3636
|
);
|
|
@@ -3363,7 +3718,7 @@ async function main() {
|
|
|
3363
3718
|
}
|
|
3364
3719
|
if (vault && cfg && cfg.vault_path && existsSync(cfg.vault_path)) {
|
|
3365
3720
|
groups.push("vault");
|
|
3366
|
-
findings.push(...runVaultChecks(cfg));
|
|
3721
|
+
findings.push(...runVaultChecks(cfg, { proj }));
|
|
3367
3722
|
} else if (vault) {
|
|
3368
3723
|
groups.push("vault");
|
|
3369
3724
|
findings.push(finding("vault", "info", "vault", "Vault checks skipped — no usable vault (see install issues)."));
|
|
@@ -896,6 +896,17 @@ export function envFlag(env, name) {
|
|
|
896
896
|
return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
|
|
897
897
|
}
|
|
898
898
|
|
|
899
|
+
// The one package in `npm pack --json`'s answer: an array of one entry up to
|
|
900
|
+
// npm 11, an object keyed by the package's name from npm 12. Here, not in
|
|
901
|
+
// scripts/version-guard.mjs: packaging/shells.mjs reads it too, and an import
|
|
902
|
+
// of the guard from there is a cycle (the guard loads shells.mjs while its own
|
|
903
|
+
// top-level await is pending, and Node exits 13).
|
|
904
|
+
export function packEntry(answer) {
|
|
905
|
+
const [pkg] = Array.isArray(answer) ? answer : Object.values(answer ?? {});
|
|
906
|
+
if (!pkg || !Array.isArray(pkg.files)) throw new Error("no package entry with a file list");
|
|
907
|
+
return pkg;
|
|
908
|
+
}
|
|
909
|
+
|
|
899
910
|
// The files a package ships, as the release gate defines them — package.json
|
|
900
911
|
// files[] plus npm's always-included package.json, README and LICENSE — copied
|
|
901
912
|
// from `from` to `to`. Never node_modules, never a symlink (a symlink is a
|
|
@@ -1835,6 +1846,24 @@ export function storiesAttributionRe() {
|
|
|
1835
1846
|
return new RegExp(`[—–-]\\s*${keywordRe("stories").source}\\s*[::]\\s*(.+)$`, "i");
|
|
1836
1847
|
}
|
|
1837
1848
|
|
|
1849
|
+
// A test cites a spec contract with `[<key>#<n>]` (the spec policy ADR,
|
|
1850
|
+
// Decisions 4 and 8): <key> is the spec's id or alias, <n> the contract
|
|
1851
|
+
// number in ASCII — `4'` for 4′, `4''` for 4″, `4/1''` for a step nested
|
|
1852
|
+
// under contract 4. The closing bracket ends the token, so #4 never matches
|
|
1853
|
+
// #4' or #40. The lookarounds drop a Markdown link `[x#1](…)` and a
|
|
1854
|
+
// wikilink `[[x#1]]`. These tokens are machine tokens: English in every
|
|
1855
|
+
// locale, never in the heading registry.
|
|
1856
|
+
export const CITATION_EXAMPLE_KEY = "example";
|
|
1857
|
+
export function citationTokenRe() {
|
|
1858
|
+
return /(?<!\[)\[([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+'*(?:\/\d+'*)?)\](?![(\]])/g;
|
|
1859
|
+
}
|
|
1860
|
+
// A loose ERE for git's side of the scan; citationTokenRe decides.
|
|
1861
|
+
export const CITATION_PREFILTER = "\\[[A-Za-z0-9][A-Za-z0-9._-]*#[0-9]";
|
|
1862
|
+
// A contract number as a spec writes it (`4′`, `1″`), in the token's ASCII.
|
|
1863
|
+
export function contractNumberAscii(n) {
|
|
1864
|
+
return String(n).replace(/″/g, "''").replace(/′/g, "'");
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1838
1867
|
// The body footer (`*Last updated: 2026-01-01*`) is content rather than a heading,
|
|
1839
1868
|
// but the lifecycle gates keep it in step with frontmatter `updated:`. Matching
|
|
1840
1869
|
// accepts every registered language; the rewrite preserves the file's OWN prefix
|
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
// projectstore — mcp-server.mjs
|
|
3
3
|
//
|
|
4
4
|
// The MCP server's entry: what the plugin's .mcp.json runs, as `node
|
|
5
|
-
// <plugin root>/scripts/mcp-server.mjs
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// <plugin root>/scripts/mcp-server.mjs` with no other argument. The project
|
|
6
|
+
// comes from the directory the host declares in the server's environment
|
|
7
|
+
// (projectRootDeclared), since 0.30.9: Anthropic's plugin portal blocks any
|
|
8
|
+
// variable but the plugin root in a server's command (PS-DIRECTORY: "The MCP
|
|
9
|
+
// server's entry names no variable but ${CLAUDE_PLUGIN_ROOT}…"). That
|
|
10
|
+
// directory comes before PROJECTSTORE_PROJECT_DIR, as 0.30.8's --project
|
|
11
|
+
// did; --project still wins, for a test or a start by hand. It starts the
|
|
12
|
+
// server through mcp.mjs's serveSupplied, the path the CLI's `mcp` verb
|
|
13
|
+
// takes too, so the two entries serve alike.
|
|
8
14
|
// It lives under scripts/, never bin/: a file in the plugin's top-level bin/
|
|
9
15
|
// is on the host's Bash PATH, and it imports nothing from cli.mjs, so its
|
|
10
16
|
// imports reach no install verb (the PS-DIRECTORY story "The MCP server's
|
|
@@ -17,6 +23,7 @@
|
|
|
17
23
|
|
|
18
24
|
import { parseArgs } from "node:util";
|
|
19
25
|
import { serveSupplied } from "./mcp.mjs";
|
|
26
|
+
import { projectRootDeclared } from "./harness.mjs";
|
|
20
27
|
|
|
21
28
|
let values;
|
|
22
29
|
try {
|
|
@@ -30,4 +37,4 @@ try {
|
|
|
30
37
|
process.stderr.write(`mcp-server: ${e.message} — it takes --project <dir> alone\n`);
|
|
31
38
|
process.exitCode = 2;
|
|
32
39
|
}
|
|
33
|
-
if (values) process.exitCode = await serveSupplied({ project: values.project ??
|
|
40
|
+
if (values) process.exitCode = await serveSupplied({ project: values.project ?? projectRootDeclared(process.env) });
|
|
@@ -33,7 +33,7 @@ import { resolve, dirname } from "node:path";
|
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
34
|
// Contract 2 of the atomic-regeneration work: nothing under scripts/ writes
|
|
35
35
|
// directly, and tests/scripts.test.mjs enforces it by globbing this directory.
|
|
36
|
-
import { writeFileAtomic } from "./lib.mjs";
|
|
36
|
+
import { writeFileAtomic, packEntry } from "./lib.mjs";
|
|
37
37
|
import { LAYOUT } from "./harness.mjs";
|
|
38
38
|
|
|
39
39
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -298,14 +298,6 @@ export function unshippedTopLevel(entries, packlist) {
|
|
|
298
298
|
// The packlist is the second release-time invariant: what npm would actually
|
|
299
299
|
// ship. Stored as sorted paths only — sizes churn on every content edit and
|
|
300
300
|
// would make the fixture unreviewable.
|
|
301
|
-
// The one package in `npm pack --json`'s answer: an array of one entry up to
|
|
302
|
-
// npm 11, an object keyed by the package's name from npm 12.
|
|
303
|
-
export function packEntry(answer) {
|
|
304
|
-
const [pkg] = Array.isArray(answer) ? answer : Object.values(answer ?? {});
|
|
305
|
-
if (!pkg || !Array.isArray(pkg.files)) throw new Error("no package entry with a file list");
|
|
306
|
-
return pkg;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
301
|
export function currentPacklist(root = ROOT) {
|
|
310
302
|
const r = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
|
311
303
|
cwd: root,
|
|
@@ -339,7 +331,10 @@ async function main(argv) {
|
|
|
339
331
|
// The shells' fixtures, from the core's CURRENT pack — after the core's
|
|
340
332
|
// fixture, since each lists the core's files under node_modules/. A lazy
|
|
341
333
|
// import: scripts/ ships and packaging/ does not, so a static specifier
|
|
342
|
-
// here would ship a module that cannot load.
|
|
334
|
+
// here would ship a module that cannot load. Nothing shells.mjs imports
|
|
335
|
+
// may import this file back: this module is still evaluating (the
|
|
336
|
+
// top-level await below), so the import would wait on it forever and Node
|
|
337
|
+
// would exit 13. tests/shells.test.mjs walks shells.mjs's imports for it.
|
|
343
338
|
const shellsModule = resolve(ROOT, "packaging", "shells.mjs");
|
|
344
339
|
const shells = [];
|
|
345
340
|
if (existsSync(shellsModule)) {
|
|
@@ -34,7 +34,15 @@ Stories — eine Story ist erst fertig, wenn beides grün ist (ADR-007).
|
|
|
34
34
|
## Verhaltensverträge
|
|
35
35
|
|
|
36
36
|
<!-- Nummeriert, einzeln testbar. Jeder beschreibt ein beobachtbares Ergebnis, keinen Implementierungsschritt.
|
|
37
|
-
Für einen Research-Spike: zu beantwortende Fragen + Ausstiegskriterien.
|
|
37
|
+
Für einen Research-Spike: zu beantwortende Fragen + Ausstiegskriterien.
|
|
38
|
+
Optional, unter einem Vertrag: Szenarien, denen ein Test folgen kann, und
|
|
39
|
+
eine Markierung für einen Vertrag, den kein Test abdeckt. Die Schlüsselwörter
|
|
40
|
+
bleiben englisch:
|
|
41
|
+
- WHEN … THEN …
|
|
42
|
+
(verified: live) oder (verified: manual)
|
|
43
|
+
Ein Test zitiert einen Vertrag als [example#1]: id oder alias der Spec, dann
|
|
44
|
+
die Vertragsnummer. Das Frontmatter kann `alias: <kurzer-schlüssel>` und
|
|
45
|
+
`coverage: report` ergänzen. Siehe docs/contracts-and-tests.md. -->
|
|
38
46
|
|
|
39
47
|
1.
|
|
40
48
|
2.
|
|
@@ -34,7 +34,14 @@ Spec acceptance is **additive** to the covered stories' own acceptance criteria
|
|
|
34
34
|
## Behavioral contracts
|
|
35
35
|
|
|
36
36
|
<!-- Numbered, individually testable. Each states an observable outcome, not an implementation step.
|
|
37
|
-
For a research spike: questions to answer + exit criteria.
|
|
37
|
+
For a research spike: questions to answer + exit criteria.
|
|
38
|
+
Optional, under a contract: scenario items a test can follow, and a marker
|
|
39
|
+
for a contract no test can cover. The keywords stay in English:
|
|
40
|
+
- WHEN … THEN …
|
|
41
|
+
(verified: live) or (verified: manual)
|
|
42
|
+
A test cites a contract as [example#1]: the spec's id or alias, then the
|
|
43
|
+
contract number. Frontmatter may add `alias: <short-key>` and
|
|
44
|
+
`coverage: report`. See docs/contracts-and-tests.md. -->
|
|
38
45
|
|
|
39
46
|
1.
|
|
40
47
|
2.
|
|
@@ -34,7 +34,15 @@ cubiertas: una historia se cierra cuando ambos están en verde (ADR-007).
|
|
|
34
34
|
## Contratos de comportamiento
|
|
35
35
|
|
|
36
36
|
<!-- Numerados, verificables uno a uno. Cada uno describe un resultado observable, no un paso de implementación.
|
|
37
|
-
Para un spike de investigación: preguntas a responder + criterios de salida.
|
|
37
|
+
Para un spike de investigación: preguntas a responder + criterios de salida.
|
|
38
|
+
Opcional, bajo un contrato: escenarios que una prueba puede seguir, y una
|
|
39
|
+
marca para un contrato que ninguna prueba puede cubrir. Las palabras clave
|
|
40
|
+
quedan en inglés:
|
|
41
|
+
- WHEN … THEN …
|
|
42
|
+
(verified: live) o (verified: manual)
|
|
43
|
+
Una prueba cita un contrato así: [example#1] — el id o alias de la spec y
|
|
44
|
+
el número del contrato. El frontmatter puede añadir `alias: <clave-corta>`
|
|
45
|
+
y `coverage: report`. Ver docs/contracts-and-tests.md. -->
|
|
38
46
|
|
|
39
47
|
1.
|
|
40
48
|
2.
|
|
@@ -34,7 +34,15 @@ couvertes : une story n'est finie que si les deux sont au vert (ADR-007).
|
|
|
34
34
|
## Contrats de comportement
|
|
35
35
|
|
|
36
36
|
<!-- Numérotés, testables un par un. Chacun énonce un résultat observable, pas une étape d'implémentation.
|
|
37
|
-
Pour un spike de recherche : questions à trancher + critères de sortie.
|
|
37
|
+
Pour un spike de recherche : questions à trancher + critères de sortie.
|
|
38
|
+
Facultatif, sous un contrat : des scénarios qu'un test peut suivre, et une
|
|
39
|
+
marque pour un contrat qu'aucun test ne peut couvrir. Les mots-clés restent
|
|
40
|
+
en anglais :
|
|
41
|
+
- WHEN … THEN …
|
|
42
|
+
(verified: live) ou (verified: manual)
|
|
43
|
+
Un test cite un contrat ainsi : [example#1] — l'id ou l'alias de la spec,
|
|
44
|
+
puis le numéro du contrat. Le frontmatter peut ajouter `alias: <clé-courte>`
|
|
45
|
+
et `coverage: report`. Voir docs/contracts-and-tests.md. -->
|
|
38
46
|
|
|
39
47
|
1.
|
|
40
48
|
2.
|
|
@@ -34,7 +34,14 @@ reviewed_at: null
|
|
|
34
34
|
## Контракты поведения
|
|
35
35
|
|
|
36
36
|
<!-- Нумерованные, проверяемые по одному. Каждый — наблюдаемый результат, не шаг реализации.
|
|
37
|
-
Для research-спайка: вопросы, на которые надо ответить + критерии выхода.
|
|
37
|
+
Для research-спайка: вопросы, на которые надо ответить + критерии выхода.
|
|
38
|
+
По желанию, под контрактом: сценарии, по которым пишется тест, и пометка
|
|
39
|
+
для контракта, который тестом не проверить. Ключевые слова — по-английски:
|
|
40
|
+
- WHEN … THEN …
|
|
41
|
+
(verified: live) или (verified: manual)
|
|
42
|
+
Тест ссылается на контракт так: [example#1] — id или alias спеки и номер
|
|
43
|
+
контракта. Во frontmatter можно добавить `alias: <короткий-ключ>` и
|
|
44
|
+
`coverage: report`. Подробнее: docs/contracts-and-tests.md. -->
|
|
38
45
|
|
|
39
46
|
1.
|
|
40
47
|
2.
|
|
@@ -34,7 +34,14 @@ story 才算完成(ADR-007)。
|
|
|
34
34
|
## 行为契约
|
|
35
35
|
|
|
36
36
|
<!-- 编号列出,逐条可测。每条描述可观察的结果,而不是实现步骤。
|
|
37
|
-
若是研究性 spike:需要回答的问题 + 退出条件。
|
|
37
|
+
若是研究性 spike:需要回答的问题 + 退出条件。
|
|
38
|
+
可选,写在契约下方:测试可以照着写的场景,以及无法用测试覆盖的契约的标记。
|
|
39
|
+
关键字保持英文:
|
|
40
|
+
- WHEN … THEN …
|
|
41
|
+
(verified: live) 或 (verified: manual)
|
|
42
|
+
测试这样引用契约:[example#1],即 spec 的 id 或 alias,加上契约编号。
|
|
43
|
+
frontmatter 可以加 `alias: <短键>` 和 `coverage: report`。
|
|
44
|
+
详见 docs/contracts-and-tests.md。 -->
|
|
38
45
|
|
|
39
46
|
1.
|
|
40
47
|
2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.9",
|
|
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.9"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|