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.
Files changed (33) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  3. package/node_modules/projectstore/.mcp.json +1 -5
  4. package/node_modules/projectstore/README.md +6 -6
  5. package/node_modules/projectstore/agents/planner.md +6 -0
  6. package/node_modules/projectstore/agents/reviewer.md +11 -1
  7. package/node_modules/projectstore/commands/review.md +2 -2
  8. package/node_modules/projectstore/commands/statusline.md +1 -1
  9. package/node_modules/projectstore/docs/contracts-and-tests.md +107 -0
  10. package/node_modules/projectstore/docs/how-it-works.md +15 -12
  11. package/node_modules/projectstore/harnesses/claude-code.json +7 -6
  12. package/node_modules/projectstore/harnesses/codex.json +4 -3
  13. package/node_modules/projectstore/hooks/band.tsx +17 -15
  14. package/node_modules/projectstore/hooks/pre-compact.mjs +25 -8
  15. package/node_modules/projectstore/package.json +1 -1
  16. package/node_modules/projectstore/scripts/convergence.mjs +2 -4
  17. package/node_modules/projectstore/scripts/diff-refs.mjs +39 -0
  18. package/node_modules/projectstore/scripts/doctor.mjs +360 -5
  19. package/node_modules/projectstore/scripts/fetch-shell.mjs +5 -1
  20. package/node_modules/projectstore/scripts/harness.mjs +32 -0
  21. package/node_modules/projectstore/scripts/install-harness.mjs +1 -1
  22. package/node_modules/projectstore/scripts/lib.mjs +31 -0
  23. package/node_modules/projectstore/scripts/mcp-server.mjs +11 -4
  24. package/node_modules/projectstore/scripts/term.mjs +3 -3
  25. package/node_modules/projectstore/scripts/touch-session.mjs +1 -1
  26. package/node_modules/projectstore/scripts/version-guard.mjs +5 -10
  27. package/node_modules/projectstore/templates/de/spec.md.tmpl +9 -1
  28. package/node_modules/projectstore/templates/en/spec.md.tmpl +8 -1
  29. package/node_modules/projectstore/templates/es/spec.md.tmpl +9 -1
  30. package/node_modules/projectstore/templates/fr/spec.md.tmpl +9 -1
  31. package/node_modules/projectstore/templates/ru/spec.md.tmpl +8 -1
  32. package/node_modules/projectstore/templates/zh/spec.md.tmpl +8 -1
  33. 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, projectRoot()),
3278
- ...checkWorkWithoutStory(cfg, projectRoot()),
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 --project <the host's project>`. It
6
- // parses --project and starts the server through mcp.mjs's serveSupplied,
7
- // the path the CLI's `mcp` verb takes too, so the two entries serve alike.
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 ?? null });
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 (https://no-color.org) turns
41
- // colour off when set to anything non-empty, and TERM=dumb means a terminal
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 zero vault reads — plus the
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.