projectstore-claude 0.30.7 → 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 +6 -6
- package/node_modules/projectstore/agents/planner.md +6 -0
- package/node_modules/projectstore/agents/reviewer.md +11 -1
- package/node_modules/projectstore/commands/review.md +2 -2
- package/node_modules/projectstore/commands/statusline.md +1 -1
- package/node_modules/projectstore/docs/contracts-and-tests.md +107 -0
- package/node_modules/projectstore/docs/how-it-works.md +15 -12
- package/node_modules/projectstore/harnesses/claude-code.json +7 -6
- package/node_modules/projectstore/harnesses/codex.json +4 -3
- package/node_modules/projectstore/hooks/band.tsx +17 -15
- package/node_modules/projectstore/hooks/pre-compact.mjs +25 -8
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/convergence.mjs +2 -4
- package/node_modules/projectstore/scripts/diff-refs.mjs +39 -0
- package/node_modules/projectstore/scripts/doctor.mjs +360 -5
- package/node_modules/projectstore/scripts/fetch-shell.mjs +5 -1
- package/node_modules/projectstore/scripts/harness.mjs +32 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1 -1
- package/node_modules/projectstore/scripts/lib.mjs +31 -0
- package/node_modules/projectstore/scripts/mcp-server.mjs +11 -4
- package/node_modules/projectstore/scripts/term.mjs +3 -3
- package/node_modules/projectstore/scripts/touch-session.mjs +1 -1
- 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
|
@@ -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)."));
|
|
@@ -214,8 +214,12 @@ export function npmError(stdout, stderr) {
|
|
|
214
214
|
|
|
215
215
|
const seconds = (ms) => `${Number((ms / 1000).toFixed(ms % 1000 ? 1 : 0))} s`;
|
|
216
216
|
|
|
217
|
-
// npm's own registry: the one README's registry-mirror advice names
|
|
217
|
+
// npm's own registry: the one README's registry-mirror advice names, and the
|
|
218
|
+
// plugin's one spelling of it.
|
|
218
219
|
export const PUBLIC_REGISTRY = "https://registry.npmjs.org";
|
|
220
|
+
// npm's default: what the installer's registry read (readLatest) and its
|
|
221
|
+
// registry comparison use when the user's own registry cannot be resolved.
|
|
222
|
+
export const DEFAULT_REGISTRY = `${PUBLIC_REGISTRY}/`;
|
|
219
223
|
|
|
220
224
|
// A registry that has no such shell may be a mirror that has not synced it:
|
|
221
225
|
// the run once more, from npm's public registry, in README's form — the
|
|
@@ -473,6 +473,38 @@ export function stopCarrierProblem(manifest) {
|
|
|
473
473
|
return null;
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
+
// ─── The PreCompact carrier (generation spec, contract 17; skeleton spec,
|
|
477
|
+
// contract 22, both as amended 2026-10-28) ─────────────────────────────
|
|
478
|
+
//
|
|
479
|
+
// How hooks/pre-compact.mjs prints its one line. Claude Code discards a
|
|
480
|
+
// PreCompact systemMessage and echoes the hook's raw stdout in the /compact
|
|
481
|
+
// output instead (measured 2026-10-28), so there the line goes out as plain
|
|
482
|
+
// text. Null — nothing identified, or no carrier measured — keeps the
|
|
483
|
+
// {"systemMessage"} object the hook has always printed: a plain line on a host
|
|
484
|
+
// whose schema refuses it would be a failed hook, and an unchanged object is
|
|
485
|
+
// not. The same identified-only rule as stopCarrier, for the same reason.
|
|
486
|
+
export const PRECOMPACT_CARRIERS = Object.freeze(["stdout"]);
|
|
487
|
+
export function preCompactCarrier(env = process.env, dir = MANIFEST_DIR) {
|
|
488
|
+
const id = identifiedHarnessId(env, dir);
|
|
489
|
+
const carrier = id ? loadHarness(id, dir)?.output_channels?.PreCompact?.carrier : null;
|
|
490
|
+
return PRECOMPACT_CARRIERS.includes(carrier) ? carrier : null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// What is wrong with a manifest's PreCompact row, or null. The key is present
|
|
494
|
+
// on every row (null when nothing is measured), and a carrier is only ever
|
|
495
|
+
// chosen from a measured row.
|
|
496
|
+
export function preCompactCarrierProblem(manifest) {
|
|
497
|
+
const row = manifest?.output_channels?.PreCompact;
|
|
498
|
+
if (row === null || row === undefined) return null;
|
|
499
|
+
if (typeof row !== "object") return "output_channels.PreCompact is not an object";
|
|
500
|
+
if (!("carrier" in row)) return "output_channels.PreCompact has no carrier (null when none is measured)";
|
|
501
|
+
if (![...PRECOMPACT_CARRIERS, null].includes(row.carrier))
|
|
502
|
+
return `output_channels.PreCompact.carrier ${JSON.stringify(row.carrier)} is not one of ${PRECOMPACT_CARRIERS.join(", ")} or null`;
|
|
503
|
+
if (row.carrier !== null && row.evidence !== "measured")
|
|
504
|
+
return "output_channels.PreCompact.carrier is set on a row that is not measured";
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
476
508
|
// ─── Who a message is for (generation spec, contract 18) ───────────────
|
|
477
509
|
//
|
|
478
510
|
// The core prints for ONE harness at a time, and a command it names must be
|
|
@@ -159,6 +159,7 @@ import {
|
|
|
159
159
|
fetchRefusal,
|
|
160
160
|
fetchRunId,
|
|
161
161
|
sweepFetchRuns,
|
|
162
|
+
DEFAULT_REGISTRY,
|
|
162
163
|
} from "./fetch-shell.mjs";
|
|
163
164
|
import { HOST_BUDGET_MS, startFailure, runHost } from "./run-host.mjs";
|
|
164
165
|
import {
|
|
@@ -191,7 +192,6 @@ import {
|
|
|
191
192
|
terminalRun,
|
|
192
193
|
copyBehind,
|
|
193
194
|
behindAdvice,
|
|
194
|
-
DEFAULT_REGISTRY,
|
|
195
195
|
NOTICE_BUDGET_MS,
|
|
196
196
|
READ_TIMEOUT_MS,
|
|
197
197
|
MIN_READ_MS,
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
whichOnPath,
|
|
55
55
|
hostSessionId,
|
|
56
56
|
stopCarrier,
|
|
57
|
+
preCompactCarrier,
|
|
57
58
|
hostRegistry,
|
|
58
59
|
autoUpdateDefault,
|
|
59
60
|
} from "./harness.mjs";
|
|
@@ -194,6 +195,7 @@ export {
|
|
|
194
195
|
speakingHarness,
|
|
195
196
|
whichOnPath,
|
|
196
197
|
stopCarrier,
|
|
198
|
+
preCompactCarrier,
|
|
197
199
|
};
|
|
198
200
|
|
|
199
201
|
// A hook's payload carries the `cwd` of the session that fired it, and on a
|
|
@@ -894,6 +896,17 @@ export function envFlag(env, name) {
|
|
|
894
896
|
return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
|
|
895
897
|
}
|
|
896
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
|
+
|
|
897
910
|
// The files a package ships, as the release gate defines them — package.json
|
|
898
911
|
// files[] plus npm's always-included package.json, README and LICENSE — copied
|
|
899
912
|
// from `from` to `to`. Never node_modules, never a symlink (a symlink is a
|
|
@@ -1833,6 +1846,24 @@ export function storiesAttributionRe() {
|
|
|
1833
1846
|
return new RegExp(`[—–-]\\s*${keywordRe("stories").source}\\s*[::]\\s*(.+)$`, "i");
|
|
1834
1847
|
}
|
|
1835
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
|
+
|
|
1836
1867
|
// The body footer (`*Last updated: 2026-01-01*`) is content rather than a heading,
|
|
1837
1868
|
// but the lifecycle gates keep it in step with frontmatter `updated:`. Matching
|
|
1838
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) });
|
|
@@ -37,9 +37,9 @@ import { createInterface, emitKeypressEvents } from "node:readline";
|
|
|
37
37
|
|
|
38
38
|
// FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
|
|
39
39
|
// "2", "3" or "true" turn colour on even without a terminal, anything else
|
|
40
|
-
// ("0", "false") turns it off. Otherwise NO_COLOR
|
|
41
|
-
//
|
|
42
|
-
// that understands no escapes.
|
|
40
|
+
// ("0", "false") turns it off. Otherwise NO_COLOR turns colour off when set
|
|
41
|
+
// to anything non-empty, as the NO_COLOR convention has it, and TERM=dumb means
|
|
42
|
+
// a terminal that understands no escapes.
|
|
43
43
|
//
|
|
44
44
|
// Glyphs are a switch of their own (presentation spec contract 2): ASCII
|
|
45
45
|
// under TERM=dumb or PROJECTSTORE_ASCII set non-empty, Unicode otherwise, in
|
|
@@ -80,7 +80,7 @@ import {
|
|
|
80
80
|
const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
|
|
81
81
|
|
|
82
82
|
// Per-session active pointer (ADR-006) — denormalized titles/status captured
|
|
83
|
-
// after the write so the statusline renders with
|
|
83
|
+
// after the write so the statusline renders with no read of the vault — plus the
|
|
84
84
|
// raw-edit nudge (PS-IMPROVE story-003): throttled, never blocking. Both in
|
|
85
85
|
// patchPointer below.
|
|
86
86
|
// The session-name offer (ADR: the settled-anchor offer). Returns a line or
|
|
@@ -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.
|