continuous-improvement 3.23.0 → 3.25.0

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 (78) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +37 -0
  3. package/QUICKSTART.md +19 -20
  4. package/README.md +71 -18
  5. package/SKILL.md +4 -0
  6. package/bin/check-invariant-count.mjs +144 -0
  7. package/bin/check-routing-targets.mjs +74 -4
  8. package/bin/check-test-count.mjs +135 -0
  9. package/bin/companion-preference-status.mjs +2 -5
  10. package/bin/generate-plugin-manifests.mjs +21 -2
  11. package/bin/harvest-friction.mjs +10 -8
  12. package/bin/install.mjs +4 -14
  13. package/bin/mcp-server.mjs +4 -9
  14. package/bin/observe.mjs +3 -3
  15. package/bin/reconcile-instinct-hashes.mjs +226 -0
  16. package/bin/refresh-third-party.mjs +180 -10
  17. package/commands/discipline.md +5 -2
  18. package/commands/reconcile.md +1 -1
  19. package/commands/superpowers.md +2 -2
  20. package/commands/verify-install.md +8 -3
  21. package/hooks/companion-preference.mjs +3 -10
  22. package/hooks/config-guard.mjs +94 -0
  23. package/hooks/gateguard.mjs +39 -39
  24. package/hooks/goal-drift-stop.mjs +2 -2
  25. package/hooks/query-cost-nudge.mjs +2 -2
  26. package/hooks/recall-briefing.mjs +2 -2
  27. package/hooks/route-prompt.mjs +2 -5
  28. package/hooks/session.mjs +2 -2
  29. package/hooks/workflow-distill.mjs +2 -2
  30. package/lib/config-guard-gate.mjs +243 -0
  31. package/lib/destructive-bash.mjs +216 -0
  32. package/lib/gateguard-state.mjs +5 -1
  33. package/lib/plugin-metadata.mjs +12 -1
  34. package/lib/skill-catalog.mjs +169 -0
  35. package/llms.txt +12 -1
  36. package/package.json +5 -3
  37. package/plugins/beginner.json +1 -1
  38. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +2 -2
  39. package/plugins/continuous-improvement/.claude-plugin/plugin.json +2 -2
  40. package/plugins/continuous-improvement/README.md +1 -2
  41. package/plugins/continuous-improvement/bin/mcp-server.mjs +4 -9
  42. package/plugins/continuous-improvement/bin/observe.mjs +3 -3
  43. package/plugins/continuous-improvement/commands/discipline.md +5 -2
  44. package/plugins/continuous-improvement/commands/reconcile.md +1 -1
  45. package/plugins/continuous-improvement/commands/superpowers.md +2 -2
  46. package/plugins/continuous-improvement/commands/verify-install.md +8 -3
  47. package/plugins/continuous-improvement/hooks/companion-preference.mjs +3 -10
  48. package/plugins/continuous-improvement/hooks/config-guard.mjs +94 -0
  49. package/plugins/continuous-improvement/hooks/gateguard.mjs +39 -39
  50. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +2 -2
  51. package/plugins/continuous-improvement/hooks/hooks.json +10 -0
  52. package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +2 -2
  53. package/plugins/continuous-improvement/hooks/recall-briefing.mjs +2 -2
  54. package/plugins/continuous-improvement/hooks/route-prompt.mjs +2 -5
  55. package/plugins/continuous-improvement/hooks/session.mjs +2 -2
  56. package/plugins/continuous-improvement/hooks/workflow-distill.mjs +2 -2
  57. package/plugins/continuous-improvement/lib/config-guard-gate.mjs +243 -0
  58. package/plugins/continuous-improvement/lib/destructive-bash.mjs +216 -0
  59. package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
  60. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +12 -1
  61. package/plugins/continuous-improvement/scripts/route-recommendation.routes.json +2 -2
  62. package/plugins/continuous-improvement/skills/README.md +0 -1
  63. package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +4 -0
  64. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +1 -1
  65. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +17 -2
  66. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +2 -2
  67. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +0 -1
  68. package/plugins/continuous-improvement/skills/superpowers/SKILL.md +5 -6
  69. package/plugins/expert.json +1 -1
  70. package/scripts/route-recommendation.routes.json +2 -2
  71. package/skills/README.md +1 -2
  72. package/skills/deploy-receipt.md +1 -1
  73. package/skills/gateguard.md +17 -2
  74. package/skills/proceed-with-the-recommendation.md +2 -2
  75. package/skills/reconcile.md +0 -1
  76. package/skills/superpowers.md +5 -6
  77. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
  78. package/skills/safety-guard.md +0 -77
@@ -8,6 +8,13 @@
8
8
  * (a) ships bundled at plugins/continuous-improvement/skills/<name>/SKILL.md, or
9
9
  * (b) is declared in the root-level optional-companions.json file.
10
10
  *
11
+ * And, for the prefixes we vendor (oh-my-claudecode, superpowers, agent-skills,
12
+ * ruflo-swarm), that every declared target actually EXISTS in its snapshot under
13
+ * third-party/<snapshot>/skills/<name>/. Declaring a target only asserted intent;
14
+ * nothing checked the destination. oh-my-claudecode:ultrawork was declared after
15
+ * upstream deleted it, and oh-my-claudecode:retrospective was declared for months
16
+ * having never existed upstream at all — both with this check green.
17
+ *
11
18
  * Catches: a routing-table row that names a skill the bundle does not ship and
12
19
  * the maintainer has not declared as an optional companion. Without this gate,
13
20
  * such drift only surfaces at runtime when the orchestrator routes to a target
@@ -20,15 +27,60 @@
20
27
  *
21
28
  * Exit codes:
22
29
  * 0 — every routing target is accounted for (bundled or optional-declared)
23
- * 1 — at least one routing target is unaccounted for
30
+ * 1 — at least one routing target is unaccounted for, or a declared vendored
31
+ * target has no directory in its snapshot
24
32
  */
25
- import { readdirSync, readFileSync, statSync } from "node:fs";
33
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
26
34
  import { join } from "node:path";
27
35
  import { argv, cwd, exit } from "node:process";
28
36
  const ORCHESTRATOR_SKILL_PATH = "skills/proceed-with-the-recommendation.md";
29
37
  const OPTIONAL_COMPANIONS_PATH = "optional-companions.json";
30
38
  const PLUGIN_SKILLS_DIR = "plugins/continuous-improvement/skills";
31
39
  const SECTION_HEADER = "### Routing Table (with Inline Fallbacks)";
40
+ /**
41
+ * Routing-target prefix -> vendored snapshot directory under third-party/.
42
+ *
43
+ * Declaring a target in optional-companions.json only asserted that we MEANT to
44
+ * route somewhere; nothing checked the destination existed. So when
45
+ * oh-my-claudecode 5.x deleted `ultrawork`, this check stayed green while the
46
+ * routing table named a skill upstream no longer ships (#308, fixed in #309) —
47
+ * and `oh-my-claudecode:retrospective` had been declared for months without ever
48
+ * existing upstream at all. For prefixes we vendor, the snapshot is ground truth.
49
+ *
50
+ * Prefixes NOT listed here (host built-ins, `frontend-design:`, `commit-commands:`)
51
+ * are deliberately unchecked: we have no local copy to check them against, and
52
+ * guessing would make this fail closed on things it cannot see.
53
+ */
54
+ export const VENDORED_PREFIXES = {
55
+ "oh-my-claudecode": "oh-my-claudecode",
56
+ superpowers: "superpowers",
57
+ "agent-skills": "addy-agent-skills",
58
+ "ruflo-swarm": "ruflo-swarm",
59
+ };
60
+ /** Repo-relative skill path a vendored target must resolve to, or null if unvendored. */
61
+ export function resolveVendoredSkillPath(target) {
62
+ const idx = target.indexOf(":");
63
+ if (idx < 0)
64
+ return null;
65
+ const dir = VENDORED_PREFIXES[target.slice(0, idx)];
66
+ if (!dir)
67
+ return null;
68
+ // Everything after the FIRST colon is the skill name, colons included.
69
+ return `third-party/${dir}/skills/${target.slice(idx + 1)}`;
70
+ }
71
+ /** Vendored targets with no directory in their snapshot. */
72
+ export function findMissingVendoredTargets(repoRoot, targets) {
73
+ const missing = [];
74
+ for (const target of targets) {
75
+ const rel = resolveVendoredSkillPath(target);
76
+ if (rel === null)
77
+ continue;
78
+ const abs = join(repoRoot, rel);
79
+ if (!existsSync(abs))
80
+ missing.push({ target, expectedPath: abs });
81
+ }
82
+ return missing;
83
+ }
32
84
  export function discoverBundledSkills(repoRoot) {
33
85
  const dir = join(repoRoot, PLUGIN_SKILLS_DIR);
34
86
  let entries;
@@ -119,19 +171,37 @@ export function checkRoutingTargets(repoRoot) {
119
171
  continue;
120
172
  drifts.push(t);
121
173
  }
174
+ // Declared is not the same as existing: for prefixes we vendor, the snapshot
175
+ // is ground truth. Checks the declared companion set, so a target that is
176
+ // declared but never referenced is still caught.
177
+ const missingVendored = findMissingVendoredTargets(repoRoot, optional);
122
178
  return {
123
179
  targets,
124
180
  drifts,
125
181
  bundledCount: bundled.size,
126
182
  optionalCount: optional.size,
183
+ missingVendored,
127
184
  };
128
185
  }
129
186
  function main() {
130
187
  const repoRoot = argv[2] ?? cwd();
131
- const { targets, drifts, bundledCount, optionalCount } = checkRoutingTargets(repoRoot);
188
+ const { targets, drifts, bundledCount, optionalCount, missingVendored } = checkRoutingTargets(repoRoot);
189
+ if (missingVendored.length > 0) {
190
+ console.error(`FAIL routing-targets: ${missingVendored.length} declared target(s) do not exist in the vendored snapshot.\n`);
191
+ for (const m of missingVendored) {
192
+ console.error(` - "${m.target}"`);
193
+ console.error(` Declared in ${OPTIONAL_COMPANIONS_PATH}, but no directory at:`);
194
+ console.error(` ${m.expectedPath}`);
195
+ }
196
+ console.error(`\nEither upstream removed or renamed the skill (retarget the routing rows and this ` +
197
+ `declaration), or the snapshot is stale (refresh it with ` +
198
+ `'node bin/refresh-third-party.mjs <name>'). Declaring a target does not make it exist.`);
199
+ exit(1);
200
+ }
132
201
  if (drifts.length === 0) {
133
202
  console.log(`OK routing-targets: all ${targets.length} routing target(s) accounted for ` +
134
- `(${bundledCount} bundled skill(s), ${optionalCount} optional companion(s) declared).`);
203
+ `(${bundledCount} bundled skill(s), ${optionalCount} optional companion(s) declared, ` +
204
+ `every vendored target present in its snapshot).`);
135
205
  exit(0);
136
206
  }
137
207
  console.error(`FAIL routing-targets: ${drifts.length} unaccounted target(s) in ${ORCHESTRATOR_SKILL_PATH}.\n`);
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test Count Check
4
+ *
5
+ * The landing page states the size of the test suite in two places: the spec
6
+ * strip ("1,271 / Tests in the suite") and the verification-loop panel
7
+ * ("tests PASS 1271/1271"). Both are hand-maintained and both drifted
8
+ * twice in a single day on 2026-09-06 — 1,137 to 1,249 to 1,271 — because every
9
+ * PR that adds a test moves the real number and nothing guarded the claim. The
10
+ * skill and command counts are guarded (check-skill-count, check-command-count);
11
+ * this closes the same hole for tests.
12
+ *
13
+ * Unlike skills and commands, the real number cannot be derived from the
14
+ * filesystem: 45 test files generate cases in loops, so counting `it(` / `test(`
15
+ * declarations reports 1,066 against an actual 1,271. Node's runner has no
16
+ * collect-without-running mode either — filtering by name pattern reports the
17
+ * file count, not the test count. The only honest source is an actual run.
18
+ *
19
+ * So the check runs in two layers, and the docs are the source of truth:
20
+ *
21
+ * 1. Locally, inside `verify:all`, it asserts that every surface states the
22
+ * SAME number. Fast, no suite run, and it catches the two-surfaces-disagree
23
+ * case that a single-number edit produces.
24
+ * 2. In CI, after `npm test` has already run, `--actual-from <file>` parses
25
+ * the runner's "tests N" summary out of the captured output and asserts the
26
+ * claimed number equals reality. No second suite run.
27
+ *
28
+ * A claim that drifts from reality fails CI; two claims that drift apart fail
29
+ * locally. Fail-closed: a missing surface is a violation, not a silent pass.
30
+ *
31
+ * Usage:
32
+ * node bin/check-test-count.mjs # Claims agree with each other
33
+ * node bin/check-test-count.mjs --actual-from out.txt # ...and with an actual run
34
+ * node bin/check-test-count.mjs <repo-root> [--actual-from out.txt]
35
+ *
36
+ * Exit codes:
37
+ * 0 — every stated test count agrees (and matches the run, when supplied)
38
+ * 1 — a surface is missing, surfaces disagree, or a claim is stale
39
+ */
40
+ import { readFileSync } from "node:fs";
41
+ import { join } from "node:path";
42
+ import { argv, cwd, exit } from "node:process";
43
+ const LANDING = join("docs", "landing", "index.html");
44
+ /** The spec strip cell: <span class="v">1,271</span><span class="k">Tests in the suite</span> */
45
+ const STRIP_RE = /<span\b[^>]*class=["'][^"']*\bv\b[^"']*["'][^>]*>\s*([\d,]+)\s*<\/span>\s*<span\b[^>]*class=["'][^"']*\bk\b[^"']*["'][^>]*>\s*Tests in the suite\s*<\/span>/gi;
46
+ /** The verification-loop panel line: tests PASS 1271/1271 */
47
+ const LADDER_RE = /^tests\s+PASS\s+([\d,]+)\/([\d,]+)\s*$/gim;
48
+ /** The node test runner summary: "ℹ tests 1271" */
49
+ const ACTUAL_RE = /^\W*tests\s+(\d+)\s*$/im;
50
+ const toNumber = (raw) => Number(raw.replace(/,/g, ""));
51
+ export function extractClaims(html) {
52
+ const strip = [...html.matchAll(STRIP_RE)].map((m) => toNumber(m[1]));
53
+ const ladder = [...html.matchAll(LADDER_RE)].flatMap((m) => [toNumber(m[1]), toNumber(m[2])]);
54
+ return { strip, ladder };
55
+ }
56
+ export function parseActualCount(runOutput) {
57
+ const m = ACTUAL_RE.exec(runOutput);
58
+ return m ? Number(m[1]) : null;
59
+ }
60
+ export function findViolations(claims, actual) {
61
+ const violations = [];
62
+ if (claims.strip.length === 0) {
63
+ violations.push(`the spec strip states no test count (expected a <span class="v">N</span> cell labelled "Tests in the suite").`);
64
+ }
65
+ if (claims.ladder.length === 0) {
66
+ violations.push(`the verification-loop ladder states no test count (expected a "tests PASS N/N" line).`);
67
+ }
68
+ if (violations.length > 0)
69
+ return violations;
70
+ const stated = [...claims.strip, ...claims.ladder];
71
+ const distinct = [...new Set(stated)].sort((a, b) => a - b);
72
+ if (distinct.length > 1) {
73
+ // Surfaces disagree with each other. Comparing a contested number to the run
74
+ // would emit a second, derivative violation; fix the disagreement first.
75
+ return [`the stated test counts disagree with each other: ${distinct.join(" vs ")}.`];
76
+ }
77
+ const claimed = distinct[0];
78
+ if (actual !== null && actual !== claimed) {
79
+ violations.push(`every surface states ${claimed} tests but the suite actually ran ${actual}.`);
80
+ }
81
+ return violations;
82
+ }
83
+ function main() {
84
+ const args = argv.slice(2);
85
+ const actualIdx = args.indexOf("--actual-from");
86
+ const actualPath = actualIdx === -1 ? null : args[actualIdx + 1];
87
+ if (actualIdx !== -1 && !actualPath) {
88
+ console.error("FAIL test-count: --actual-from needs a path to a captured test run.");
89
+ exit(1);
90
+ }
91
+ const repoRoot = args.find((a) => !a.startsWith("--") && a !== actualPath) ?? cwd();
92
+ let html;
93
+ try {
94
+ html = readFileSync(join(repoRoot, LANDING), "utf8");
95
+ }
96
+ catch {
97
+ console.error(`FAIL test-count: cannot read ${LANDING} at ${repoRoot}.`);
98
+ exit(1);
99
+ }
100
+ let actual = null;
101
+ if (actualPath) {
102
+ let runOutput;
103
+ try {
104
+ runOutput = readFileSync(actualPath, "utf8");
105
+ }
106
+ catch {
107
+ console.error(`FAIL test-count: cannot read the captured test run at ${actualPath}.`);
108
+ exit(1);
109
+ }
110
+ actual = parseActualCount(runOutput);
111
+ if (actual === null) {
112
+ console.error(`FAIL test-count: ${actualPath} has no "tests N" summary line, so the claim cannot be checked against a real run.`);
113
+ exit(1);
114
+ }
115
+ }
116
+ const claims = extractClaims(html);
117
+ const violations = findViolations(claims, actual);
118
+ if (violations.length === 0) {
119
+ const claimed = [...claims.strip, ...claims.ladder][0];
120
+ const against = actual === null ? "each other" : `each other and the actual run`;
121
+ console.log(`OK test-count: all ${claims.strip.length + claims.ladder.length} claim(s) state ${claimed} tests, matching ${against}.`);
122
+ exit(0);
123
+ }
124
+ console.error(`FAIL test-count: ${violations.length} issue(s) with the stated test count.`);
125
+ console.error("");
126
+ for (const v of violations)
127
+ console.error(` ${v}`);
128
+ console.error("");
129
+ console.error(`Fix: run the suite, take the "tests N" total, and update both surfaces in ${LANDING} (the spec strip and the verification-loop panel).`);
130
+ exit(1);
131
+ }
132
+ const invokedDirectly = argv[1] !== undefined && import.meta.url.endsWith(argv[1].replace(/\\/g, "/"));
133
+ if (invokedDirectly || argv[1]?.endsWith("check-test-count.mjs")) {
134
+ main();
135
+ }
@@ -19,9 +19,9 @@
19
19
  * 1 — argument parse error or unexpected runtime failure
20
20
  */
21
21
  import { execFileSync } from "node:child_process";
22
- import { createHash } from "node:crypto";
23
22
  import { existsSync, readFileSync } from "node:fs";
24
23
  import { homedir } from "node:os";
24
+ import { hashProjectRoot } from "../lib/gateguard-state.mjs";
25
25
  import { join } from "node:path";
26
26
  import { argv, exit, stdout } from "node:process";
27
27
  const ACTIONS = [
@@ -99,10 +99,7 @@ function resolveProjectRoot() {
99
99
  }
100
100
  function defaultJsonlPath() {
101
101
  const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
102
- const hash = createHash("sha256")
103
- .update(resolveProjectRoot())
104
- .digest("hex")
105
- .slice(0, 12);
102
+ const hash = hashProjectRoot(resolveProjectRoot());
106
103
  return join(home, ".claude", "instincts", hash, "companion-preference.jsonl");
107
104
  }
108
105
  function parseJsonl(raw) {
@@ -4,6 +4,7 @@ import { basename, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { PACKAGE_NAME, PLUGIN_MODES, getPluginHooksConfig, getClaudePluginManifest, getClaudePluginMarketplaceManifest, getClaudeRepoMarketplaceManifest, getPluginManifest, } from "../lib/plugin-metadata.mjs";
6
6
  import { normalizeTier, parseSkillFrontmatter, renderBundledSkillsReadme, } from "../lib/skill-tiers.mjs";
7
+ import { enforcementOf, lawOf, renderSkillCatalogHtml, } from "../lib/skill-catalog.mjs";
7
8
  const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const PLUGINS_DIR = join(REPO_ROOT, "plugins");
9
10
  const PLUGIN_BUNDLE_DIR = join(PLUGINS_DIR, PACKAGE_NAME);
@@ -67,6 +68,23 @@ async function writeBundledSkills() {
67
68
  });
68
69
  }
69
70
  await writeFile(join(targetSkillsDir, "README.md"), renderBundledSkillsReadme(skillsForReadme));
71
+ await writeSkillCatalogHtml(skillsForReadme);
72
+ }
73
+ /**
74
+ * Generate `docs/skill-catalog.html` from the same frontmatter that feeds the
75
+ * bundled README, so the catalog's Enforcement badges can never drift from the
76
+ * shipped file set. Guarded by `verify:generated`.
77
+ */
78
+ async function writeSkillCatalogHtml(skills) {
79
+ const commandNames = new Set((await readdir(join(REPO_ROOT, "commands")))
80
+ .filter((f) => f.endsWith(".md") && f !== "README.md")
81
+ .map((f) => basename(f, ".md")));
82
+ const catalog = skills.map((skill) => ({
83
+ ...skill,
84
+ ...enforcementOf(skill.name, commandNames),
85
+ law: lawOf(skill.description),
86
+ }));
87
+ await writeFile(join(REPO_ROOT, "docs", "skill-catalog.html"), renderSkillCatalogHtml(catalog));
70
88
  }
71
89
  async function writePluginBundleReadme() {
72
90
  const readme = [
@@ -108,8 +126,7 @@ async function writePluginBundleReadme() {
108
126
  "- `tdd-workflow` — RED/GREEN/REFACTOR + 80% coverage gate",
109
127
  "- `workspace-surface-audit` — environment + capability audit",
110
128
  "- Tier-1/Tier-2 enforcement skills (`gateguard`, `verification-loop`,",
111
- " `safety-guard`, `token-budget-advisor`,",
112
- " `strategic-compact`, `wild-risa-balance`)",
129
+ " `token-budget-advisor`, `strategic-compact`, `wild-risa-balance`)",
113
130
  "",
114
131
  "**Optional companions the orchestrator routes to (install separately if",
115
132
  "you want the dedicated skill instead of the inline fallback):**",
@@ -151,12 +168,14 @@ async function writePluginBundle() {
151
168
  copyFileTo(join(REPO_ROOT, "lib", "git-state.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "git-state.mjs")),
152
169
  copyFileTo(join(REPO_ROOT, "bin", "gateguard-clear.mjs"), join(PLUGIN_BUNDLE_DIR, "bin", "gateguard-clear.mjs")),
153
170
  copyFileTo(join(REPO_ROOT, "lib", "gateguard-state.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "gateguard-state.mjs")),
171
+ copyFileTo(join(REPO_ROOT, "lib", "destructive-bash.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "destructive-bash.mjs")),
154
172
  copyFileTo(join(REPO_ROOT, "lib", "plugin-metadata.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "plugin-metadata.mjs")),
155
173
  copyFileTo(join(REPO_ROOT, "lib", "resolve-home-dir.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "resolve-home-dir.mjs")),
156
174
  copyFileTo(join(REPO_ROOT, "lib", "observe-event.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "observe-event.mjs")),
157
175
  copyFileTo(join(REPO_ROOT, "lib", "goal-state.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "goal-state.mjs")),
158
176
  copyFileTo(join(REPO_ROOT, "lib", "goal-drift-gate.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "goal-drift-gate.mjs")),
159
177
  copyFileTo(join(REPO_ROOT, "lib", "hook-pack-gate.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "hook-pack-gate.mjs")),
178
+ copyFileTo(join(REPO_ROOT, "lib", "config-guard-gate.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "config-guard-gate.mjs")),
160
179
  copyFileTo(join(REPO_ROOT, "lib", "recall-index.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "recall-index.mjs")),
161
180
  copyFileTo(join(REPO_ROOT, "lib", "recall-briefing.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "recall-briefing.mjs")),
162
181
  copyFileTo(join(REPO_ROOT, "lib", "skill-distill.mjs"), join(PLUGIN_BUNDLE_DIR, "lib", "skill-distill.mjs")),
@@ -33,7 +33,8 @@ import { createHash } from "node:crypto";
33
33
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
34
34
  import { homedir } from "node:os";
35
35
  import { join } from "node:path";
36
- import { argv, cwd, exit } from "node:process";
36
+ import { argv, exit } from "node:process";
37
+ import { hashProjectRoot, resolveProjectRoot } from "../lib/gateguard-state.mjs";
37
38
  const SUMMARY_TRUNCATE = 120;
38
39
  const DEDUP_KEY_INPUT_TRUNCATE = 120;
39
40
  /**
@@ -74,10 +75,13 @@ export function classifyObservation(row) {
74
75
  return build("env_issue", tool, summary, row.ts);
75
76
  }
76
77
  // permission_block — harness, sandbox, or OS denied the action.
78
+ // Do not match the substring "sandbox" alone: a plan file named
79
+ // slow-regime-gate-sandbox.md is not a denial (2026-08-22 false positive).
77
80
  if (/permission denied/i.test(out) ||
78
81
  /harness[- ]blocked/i.test(out) ||
79
- /sandbox/i.test(out) ||
80
- /operation not permitted/i.test(out)) {
82
+ /operation not permitted/i.test(out) ||
83
+ /\bblocked by (?:the )?sandbox\b/i.test(out) ||
84
+ /\bsandbox(?:ed)?[- ](?:denied|blocked|restrict(?:ed|ion)|violation)\b/i.test(out)) {
81
85
  return build("permission_block", tool, summary, row.ts);
82
86
  }
83
87
  // wrong_approach — agent acted on stale state. The "file changed since last
@@ -219,11 +223,9 @@ export function harvest(observationsPath, instinctsPath) {
219
223
  };
220
224
  }
221
225
  function defaultProjectHash() {
222
- // Mirror the bash hook's hashing rule: sha256(project_root) first 12 hex chars.
223
- // Keeps the CLI compatible with whatever directory the bash hook was already
224
- // populating, without re-implementing project-root detection here.
225
- const root = cwd();
226
- return createHash("sha256").update(root).digest("hex").slice(0, 12);
226
+ // Same root + canonical hash as observe/gateguard so `harvest-friction` from
227
+ // this repo reads the bucket the hook writes (C:/ vs c:/ vs cwd backslashes).
228
+ return hashProjectRoot(resolveProjectRoot());
227
229
  }
228
230
  function main() {
229
231
  const args = argv.slice(2);
package/bin/install.mjs CHANGED
@@ -10,12 +10,11 @@
10
10
  * npx continuous-improvement install --uninstall # remove everything
11
11
  */
12
12
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
13
- import { execSync } from "node:child_process";
14
13
  import { homedir } from "node:os";
15
14
  import { dirname, isAbsolute, join, relative, sep } from "node:path";
16
15
  import { fileURLToPath } from "node:url";
17
- import { createHash } from "node:crypto";
18
16
  import { PACKAGE_NAME, VERSION, getToolNames } from "../lib/plugin-metadata.mjs";
17
+ import { hashProjectRoot, resolveProjectRoot } from "../lib/gateguard-state.mjs";
19
18
  import { TARGET_IDS, planTargetWrites, resolveTargets } from "../lib/install-targets.mjs";
20
19
  import { evaluateUpdateCheck, fetchLatestNpmVersion, isThrottled, pendingNotice, } from "../lib/version-check.mjs";
21
20
  const __filename = fileURLToPath(import.meta.url);
@@ -674,19 +673,10 @@ Examples:
674
673
  `);
675
674
  }
676
675
  function getProjectHashSync() {
677
- try {
678
- // No shell redirect — `2>/dev/null` breaks under cmd.exe (tries to open a
679
- // file literally named /dev/null); ignore stderr via stdio instead.
680
- const root = execSync("git rev-parse --show-toplevel", {
681
- encoding: "utf8",
682
- stdio: ["ignore", "pipe", "ignore"],
683
- }).trim();
684
- const hash = createHash("sha256").update(root).digest("hex").slice(0, 12);
685
- return { root, hash };
686
- }
687
- catch {
676
+ const root = resolveProjectRoot();
677
+ if (root === "global")
688
678
  return { root: "global", hash: "global" };
689
- }
679
+ return { root, hash: hashProjectRoot(root) };
690
680
  }
691
681
  const args = process.argv.slice(2);
692
682
  const command = args[0];
@@ -16,12 +16,11 @@ import { homedir } from "node:os";
16
16
  import { basename, dirname, join, resolve } from "node:path";
17
17
  import { createInterface } from "node:readline";
18
18
  import { fileURLToPath } from "node:url";
19
- import { createHash } from "node:crypto";
20
19
  import { PACKAGE_NAME, VERSION, getToolDefinitions, isPluginMode, } from "../lib/plugin-metadata.mjs";
21
20
  import { formatDriftReport, parseGoalFromPlan, scoreObservations, } from "../lib/goal-state.mjs";
22
21
  import { buildIndex, formatRecallHits, parseSince, query as queryRecall, } from "../lib/recall-index.mjs";
23
22
  import { draftFromCandidate, draftFromWorkflowRun, extractTrajectories, findCandidates, formatCandidates, serializeDraft, workflowRunFromObservations, } from "../lib/skill-distill.mjs";
24
- import { MAX_CLEARED_FILES, canonicalizeFileKey, clearFiles, resolveInstinctsRoot, resolveSessionDir, } from "../lib/gateguard-state.mjs";
23
+ import { MAX_CLEARED_FILES, canonicalizeFileKey, clearFiles, hashProjectRoot, resolveInstinctsRoot, resolveProjectRoot, resolveSessionDir, } from "../lib/gateguard-state.mjs";
25
24
  function getHomeDir() {
26
25
  return process.env.HOME || process.env.USERPROFILE || homedir();
27
26
  }
@@ -93,14 +92,10 @@ const modeIndex = args.indexOf("--mode");
93
92
  const requestedMode = args[modeIndex + 1];
94
93
  const MODE = isPluginMode(requestedMode) ? requestedMode : "beginner";
95
94
  function getProjectHash() {
96
- try {
97
- const root = execSync("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf8" }).trim();
98
- const hash = createHash("sha256").update(root).digest("hex").slice(0, 12);
99
- return { root, hash, name: basename(root) };
100
- }
101
- catch {
95
+ const root = resolveProjectRoot();
96
+ if (root === "global")
102
97
  return { root: "global", hash: "global", name: "global" };
103
- }
98
+ return { root, hash: hashProjectRoot(root), name: basename(root) };
104
99
  }
105
100
  function readInstincts(projectHash) {
106
101
  const instincts = [];
package/bin/observe.mjs CHANGED
@@ -16,10 +16,10 @@
16
16
  //
17
17
  // See docs/plans/2026-05-05-node-observer-rich-schema.md for the full design.
18
18
  import { execFileSync } from "node:child_process";
19
- import { createHash } from "node:crypto";
20
19
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
20
  import { homedir } from "node:os";
22
21
  import { basename, join } from "node:path";
22
+ import { canonicalizeProjectRoot, hashProjectRoot } from "../lib/gateguard-state.mjs";
23
23
  import { parseHookPayload, summariseInput, summariseOutput } from "../lib/observe-event.mjs";
24
24
  const ROTATION_LINE_THRESHOLD = 10_000;
25
25
  const ARCHIVE_RETENTION = 10;
@@ -40,8 +40,8 @@ function runObserver() {
40
40
  const payload = parseHookPayload(raw);
41
41
  if (!payload)
42
42
  return;
43
- const projectRoot = resolveProjectRoot();
44
- const projectHash = createHash("sha256").update(projectRoot).digest("hex").slice(0, 12);
43
+ const projectRoot = canonicalizeProjectRoot(resolveProjectRoot());
44
+ const projectHash = hashProjectRoot(projectRoot);
45
45
  const projectName = basename(projectRoot.replace(/\.git$/, ""));
46
46
  const instinctsDir = join(getHomeDir(), ".claude", "instincts");
47
47
  const projectDir = join(instinctsDir, projectHash);