continuous-improvement 3.22.1 → 3.24.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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +23 -0
  3. package/QUICKSTART.md +19 -20
  4. package/README.md +72 -19
  5. package/SKILL.md +4 -0
  6. package/bin/companion-preference-status.mjs +2 -5
  7. package/bin/generate-plugin-manifests.mjs +21 -2
  8. package/bin/harvest-friction.mjs +10 -8
  9. package/bin/install.mjs +208 -33
  10. package/bin/mcp-server.mjs +4 -9
  11. package/bin/observe.mjs +3 -3
  12. package/bin/reconcile-instinct-hashes.mjs +226 -0
  13. package/commands/discipline.md +5 -2
  14. package/commands/reconcile.md +1 -1
  15. package/commands/ship.md +5 -49
  16. package/commands/superpowers.md +1 -1
  17. package/commands/verify-install.md +8 -3
  18. package/hooks/companion-preference.mjs +2 -5
  19. package/hooks/config-guard.mjs +94 -0
  20. package/hooks/gateguard.mjs +39 -39
  21. package/hooks/goal-drift-stop.mjs +2 -2
  22. package/hooks/query-cost-nudge.mjs +2 -2
  23. package/hooks/recall-briefing.mjs +2 -2
  24. package/hooks/route-prompt.mjs +2 -5
  25. package/hooks/session.mjs +2 -2
  26. package/hooks/workflow-distill.mjs +2 -2
  27. package/lib/config-guard-gate.mjs +243 -0
  28. package/lib/destructive-bash.mjs +216 -0
  29. package/lib/gateguard-state.mjs +5 -1
  30. package/lib/plugin-metadata.mjs +12 -1
  31. package/lib/skill-catalog.mjs +169 -0
  32. package/llms.txt +11 -0
  33. package/package.json +1 -1
  34. package/plugins/beginner.json +2 -2
  35. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
  36. package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
  37. package/plugins/continuous-improvement/README.md +1 -2
  38. package/plugins/continuous-improvement/bin/mcp-server.mjs +4 -9
  39. package/plugins/continuous-improvement/bin/observe.mjs +3 -3
  40. package/plugins/continuous-improvement/commands/discipline.md +5 -2
  41. package/plugins/continuous-improvement/commands/reconcile.md +1 -1
  42. package/plugins/continuous-improvement/commands/ship.md +5 -49
  43. package/plugins/continuous-improvement/commands/superpowers.md +1 -1
  44. package/plugins/continuous-improvement/commands/verify-install.md +8 -3
  45. package/plugins/continuous-improvement/hooks/companion-preference.mjs +2 -5
  46. package/plugins/continuous-improvement/hooks/config-guard.mjs +94 -0
  47. package/plugins/continuous-improvement/hooks/gateguard.mjs +39 -39
  48. package/plugins/continuous-improvement/hooks/goal-drift-stop.mjs +2 -2
  49. package/plugins/continuous-improvement/hooks/hooks.json +10 -0
  50. package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +2 -2
  51. package/plugins/continuous-improvement/hooks/recall-briefing.mjs +2 -2
  52. package/plugins/continuous-improvement/hooks/route-prompt.mjs +2 -5
  53. package/plugins/continuous-improvement/hooks/session.mjs +2 -2
  54. package/plugins/continuous-improvement/hooks/workflow-distill.mjs +2 -2
  55. package/plugins/continuous-improvement/lib/config-guard-gate.mjs +243 -0
  56. package/plugins/continuous-improvement/lib/destructive-bash.mjs +216 -0
  57. package/plugins/continuous-improvement/lib/gateguard-state.mjs +5 -1
  58. package/plugins/continuous-improvement/lib/plugin-metadata.mjs +12 -1
  59. package/plugins/continuous-improvement/skills/README.md +1 -1
  60. package/plugins/continuous-improvement/skills/continuous-improvement/SKILL.md +4 -0
  61. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +1 -1
  62. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +17 -2
  63. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +0 -1
  64. package/plugins/continuous-improvement/skills/ship/SKILL.md +139 -0
  65. package/plugins/expert.json +1 -1
  66. package/skills/README.md +2 -2
  67. package/skills/deploy-receipt.md +1 -1
  68. package/skills/gateguard.md +17 -2
  69. package/skills/reconcile.md +0 -1
  70. package/skills/ship.md +139 -0
  71. package/plugins/continuous-improvement/skills/safety-guard/SKILL.md +0 -77
  72. package/skills/safety-guard.md +0 -77
package/bin/install.mjs CHANGED
@@ -9,18 +9,18 @@
9
9
  * npx continuous-improvement install --target gemini,codex # skill into other agents' rules files
10
10
  * npx continuous-improvement install --uninstall # remove everything
11
11
  */
12
- import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
13
- import { execSync } from "node:child_process";
12
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
14
13
  import { homedir } from "node:os";
15
- import { dirname, join } from "node:path";
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);
22
21
  const __dirname = dirname(__filename);
23
22
  const SKILL_SOURCE = join(__dirname, "..", "SKILL.md");
23
+ const SHIP_SKILL_SOURCE = join(__dirname, "..", "skills", "ship.md");
24
24
  const SKILL_NAME = "continuous-improvement";
25
25
  const REPO_ROOT = join(__dirname, "..");
26
26
  const COMMAND_FILES = [
@@ -124,17 +124,169 @@ const modeIndex = rawArgs.indexOf("--mode");
124
124
  const requestedMode = modeIndex !== -1 ? rawArgs[modeIndex + 1] : undefined;
125
125
  const INSTALL_MODE = isInstallMode(requestedMode) ? requestedMode : "beginner";
126
126
  const SKILL_DIR = join(getHomeDir(), ".claude", "skills", SKILL_NAME);
127
+ const SHIP_SKILL_DIR = join(getHomeDir(), ".claude", "skills", "ship");
128
+ const SHIP_SKILLS_DIR = dirname(SHIP_SKILL_DIR);
129
+ const SHIP_STAGING_ROOT = join(getHomeDir(), ".claude", ".continuous-improvement-staging");
130
+ const SHIP_SKILL_OWNER_FILE = join(SHIP_SKILL_DIR, ".continuous-improvement-owner");
131
+ const SHIP_SKILL_OWNER = `${PACKAGE_NAME}\n`;
132
+ function pathEntryExists(path) {
133
+ try {
134
+ lstatSync(path);
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ if (error.code === "ENOENT")
139
+ return false;
140
+ throw error;
141
+ }
142
+ }
143
+ function isOwnedShipSkill() {
144
+ try {
145
+ const dir = lstatSync(SHIP_SKILL_DIR);
146
+ const owner = lstatSync(SHIP_SKILL_OWNER_FILE);
147
+ if (!dir.isDirectory() || dir.isSymbolicLink())
148
+ return false;
149
+ if (!owner.isFile() || owner.isSymbolicLink() || owner.nlink !== 1)
150
+ return false;
151
+ return readFileSync(SHIP_SKILL_OWNER_FILE, "utf8") === SHIP_SKILL_OWNER;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ function isSameOrChildPath(parent, candidate) {
158
+ const relativePath = relative(parent, candidate);
159
+ return (relativePath === "" ||
160
+ (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)));
161
+ }
162
+ function getSafeShipStagingRoot() {
163
+ mkdirSync(dirname(SHIP_STAGING_ROOT), { recursive: true });
164
+ try {
165
+ mkdirSync(SHIP_STAGING_ROOT);
166
+ }
167
+ catch (error) {
168
+ if (error.code !== "EEXIST")
169
+ throw error;
170
+ }
171
+ const stagingEntry = lstatSync(SHIP_STAGING_ROOT);
172
+ if (!stagingEntry.isDirectory() || stagingEntry.isSymbolicLink()) {
173
+ throw new Error(`Ship staging root must be a plain directory, not a link or junction: ${SHIP_STAGING_ROOT}`);
174
+ }
175
+ const stagingRoot = realpathSync(SHIP_STAGING_ROOT);
176
+ const skillsRoot = realpathSync(SHIP_SKILLS_DIR);
177
+ if (isSameOrChildPath(skillsRoot, stagingRoot)) {
178
+ throw new Error(`Ship staging root resolves inside skill discovery: ${stagingRoot}`);
179
+ }
180
+ return { stagingRoot, skillsRoot };
181
+ }
182
+ function stageShipSkill() {
183
+ mkdirSync(SHIP_SKILLS_DIR, { recursive: true });
184
+ const { stagingRoot, skillsRoot } = getSafeShipStagingRoot();
185
+ const createdStagingDir = mkdtempSync(join(stagingRoot, "ship-"));
186
+ try {
187
+ const stagingEntry = lstatSync(createdStagingDir);
188
+ const stagingDir = realpathSync(createdStagingDir);
189
+ if (!stagingEntry.isDirectory() ||
190
+ stagingEntry.isSymbolicLink() ||
191
+ !isSameOrChildPath(stagingRoot, stagingDir) ||
192
+ isSameOrChildPath(skillsRoot, stagingDir)) {
193
+ throw new Error(`Ship staging directory escaped its safe root: ${stagingDir}`);
194
+ }
195
+ copyFileSync(SHIP_SKILL_SOURCE, join(stagingDir, "SKILL.md"));
196
+ writeFileSync(join(stagingDir, ".continuous-improvement-owner"), SHIP_SKILL_OWNER);
197
+ return stagingDir;
198
+ }
199
+ catch (error) {
200
+ rmSync(createdStagingDir, { recursive: true, force: true });
201
+ throw error;
202
+ }
203
+ }
204
+ function installShipSkill() {
205
+ const shipSkillExists = pathEntryExists(SHIP_SKILL_DIR);
206
+ if (shipSkillExists && !isOwnedShipSkill()) {
207
+ console.warn(` ! Preserved existing unowned ship skill at ${SHIP_SKILL_DIR}`);
208
+ return "preserved";
209
+ }
210
+ let stagingDir = "";
211
+ let preserveStagingDir = false;
212
+ try {
213
+ stagingDir = stageShipSkill();
214
+ if (shipSkillExists) {
215
+ const skillPath = join(SHIP_SKILL_DIR, "SKILL.md");
216
+ const previousSkillPath = join(stagingDir, "previous-SKILL.md");
217
+ let previousSkillMoved = false;
218
+ if (pathEntryExists(skillPath)) {
219
+ const skillEntry = lstatSync(skillPath);
220
+ if (skillEntry.isDirectory()) {
221
+ throw new Error(`Refusing to replace directory at ${skillPath}`);
222
+ }
223
+ renameSync(skillPath, previousSkillPath);
224
+ previousSkillMoved = true;
225
+ }
226
+ try {
227
+ renameSync(join(stagingDir, "SKILL.md"), skillPath);
228
+ }
229
+ catch (error) {
230
+ if (previousSkillMoved) {
231
+ try {
232
+ renameSync(previousSkillPath, skillPath);
233
+ }
234
+ catch (restoreError) {
235
+ preserveStagingDir = true;
236
+ let recoveryRenameError = "";
237
+ const stagedSkillPath = join(stagingDir, "SKILL.md");
238
+ if (pathEntryExists(stagedSkillPath)) {
239
+ try {
240
+ renameSync(stagedSkillPath, join(stagingDir, "new-SKILL.md"));
241
+ }
242
+ catch (recoveryError) {
243
+ recoveryRenameError = ` Recovery artifact rename also failed (${getErrorMessage(recoveryError)}).`;
244
+ }
245
+ }
246
+ throw new Error(`Ship skill replacement failed (${getErrorMessage(error)}) and rollback failed (${getErrorMessage(restoreError)}).${recoveryRenameError} Recovery files retained outside skill discovery at ${stagingDir}`);
247
+ }
248
+ }
249
+ throw error;
250
+ }
251
+ rmSync(stagingDir, { recursive: true });
252
+ stagingDir = "";
253
+ }
254
+ else {
255
+ renameSync(stagingDir, SHIP_SKILL_DIR);
256
+ stagingDir = "";
257
+ }
258
+ console.log(` ✓ Global ship skill → ${SHIP_SKILL_DIR}/SKILL.md`);
259
+ return "installed";
260
+ }
261
+ catch (error) {
262
+ console.error(` ✗ Global ship skill install failed: ${getErrorMessage(error)}`);
263
+ return "failed";
264
+ }
265
+ finally {
266
+ if (stagingDir && !preserveStagingDir) {
267
+ try {
268
+ if (pathEntryExists(stagingDir)) {
269
+ rmSync(stagingDir, { recursive: true, force: true });
270
+ }
271
+ }
272
+ catch (cleanupError) {
273
+ console.error(` ! Staged ship skill cleanup failed at ${stagingDir}: ${getErrorMessage(cleanupError)}`);
274
+ }
275
+ }
276
+ }
277
+ }
127
278
  function installSkill() {
128
279
  try {
129
280
  mkdirSync(SKILL_DIR, { recursive: true });
130
281
  copyFileSync(SKILL_SOURCE, join(SKILL_DIR, "SKILL.md"));
131
282
  console.log(` ✓ Claude Code skill → ${SKILL_DIR}/SKILL.md`);
283
+ const shipOutcome = installShipSkill();
132
284
  setupMulahazah();
133
- return true;
285
+ return shipOutcome;
134
286
  }
135
287
  catch (error) {
136
288
  console.error(` ✗ Install failed: ${getErrorMessage(error)}`);
137
- return false;
289
+ return "failed";
138
290
  }
139
291
  }
140
292
  function setupMulahazah() {
@@ -326,9 +478,10 @@ function patchClaudeSettings(observePath) {
326
478
  }
327
479
  }
328
480
  function uninstallAll() {
329
- console.log("\nUninstalling continuous-improvement skill...\n");
481
+ console.log("\nUninstalling continuous-improvement skills...\n");
330
482
  const home = getHomeDir();
331
483
  let removed = 0;
484
+ let failed = false;
332
485
  if (existsSync(SKILL_DIR)) {
333
486
  try {
334
487
  rmSync(SKILL_DIR, { recursive: true });
@@ -337,6 +490,23 @@ function uninstallAll() {
337
490
  }
338
491
  catch (error) {
339
492
  console.error(` ✗ Skill removal failed: ${getErrorMessage(error)}`);
493
+ failed = true;
494
+ }
495
+ }
496
+ if (existsSync(SHIP_SKILL_DIR)) {
497
+ if (!isOwnedShipSkill()) {
498
+ console.warn(` ! Preserved existing unowned ship skill at ${SHIP_SKILL_DIR}`);
499
+ }
500
+ else {
501
+ try {
502
+ rmSync(SHIP_SKILL_DIR, { recursive: true });
503
+ console.log(" ✓ Removed global ship skill");
504
+ removed++;
505
+ }
506
+ catch (error) {
507
+ console.error(` ✗ Global ship skill removal failed: ${getErrorMessage(error)}`);
508
+ failed = true;
509
+ }
340
510
  }
341
511
  }
342
512
  for (const commandName of COMMAND_FILES) {
@@ -350,6 +520,7 @@ function uninstallAll() {
350
520
  }
351
521
  catch (error) {
352
522
  console.error(` ✗ ${commandName}: ${getErrorMessage(error)}`);
523
+ failed = true;
353
524
  }
354
525
  }
355
526
  for (const hookFile of ["observe.sh", "session.sh", "session.mjs"]) {
@@ -363,6 +534,7 @@ function uninstallAll() {
363
534
  }
364
535
  catch (error) {
365
536
  console.error(` ✗ ${hookFile}: ${getErrorMessage(error)}`);
537
+ failed = true;
366
538
  }
367
539
  }
368
540
  // Remove the Node observer artifacts deployed alongside observe.sh.
@@ -379,6 +551,7 @@ function uninstallAll() {
379
551
  }
380
552
  catch (error) {
381
553
  console.error(` ✗ ${observerFile}: ${getErrorMessage(error)}`);
554
+ failed = true;
382
555
  }
383
556
  }
384
557
  const settingsPath = join(home, ".claude", "settings.json");
@@ -435,12 +608,17 @@ function uninstallAll() {
435
608
  }
436
609
  else {
437
610
  console.warn(" ! Could not clean settings.json — remove hooks manually");
611
+ failed = true;
438
612
  }
439
613
  }
440
614
  const desktopConfigPath = join(home, ".claude", "claude_desktop_config.json");
441
615
  if (existsSync(desktopConfigPath)) {
442
616
  const desktopConfig = readJsonFile(desktopConfigPath);
443
- if (desktopConfig?.mcpServers?.["continuous-improvement"]) {
617
+ if (!desktopConfig) {
618
+ console.warn(" ! Could not clean claude_desktop_config.json — remove the MCP server manually");
619
+ failed = true;
620
+ }
621
+ else if (desktopConfig.mcpServers?.["continuous-improvement"]) {
444
622
  delete desktopConfig.mcpServers["continuous-improvement"];
445
623
  writeFileSync(desktopConfigPath, JSON.stringify(desktopConfig, null, 2) + "\n");
446
624
  console.log(" ✓ Removed MCP server from Claude Desktop config");
@@ -451,6 +629,7 @@ function uninstallAll() {
451
629
  }
452
630
  console.log("\n Note: Instinct data in ~/.claude/instincts/ was preserved.\n" +
453
631
  " To remove learned data too: rm -rf ~/.claude/instincts/\n");
632
+ return !failed;
454
633
  }
455
634
  function printUsage() {
456
635
  console.log(`
@@ -463,11 +642,11 @@ Subcommands:
463
642
 
464
643
  Options for 'install':
465
644
  --mode <mode> Installation mode:
466
- beginner — hooks + skill + commands (default)
645
+ beginner — hooks + skills + commands (default)
467
646
  expert — beginner + MCP server + session hooks
468
647
  --pack <name> Load a starter instinct pack (react, python, go, meta)
469
648
  --target <names> Comma-separated platform list (default: claude):
470
- claude — full install: hooks + skill + commands
649
+ claude — full install: hooks + skills + commands
471
650
  gemini — GEMINI.md (Gemini CLI)
472
651
  codex — AGENTS.md (Codex CLI / agents.md standard)
473
652
  cursor — .cursor/rules/continuous-improvement.mdc
@@ -494,19 +673,10 @@ Examples:
494
673
  `);
495
674
  }
496
675
  function getProjectHashSync() {
497
- try {
498
- // No shell redirect — `2>/dev/null` breaks under cmd.exe (tries to open a
499
- // file literally named /dev/null); ignore stderr via stdio instead.
500
- const root = execSync("git rev-parse --show-toplevel", {
501
- encoding: "utf8",
502
- stdio: ["ignore", "pipe", "ignore"],
503
- }).trim();
504
- const hash = createHash("sha256").update(root).digest("hex").slice(0, 12);
505
- return { root, hash };
506
- }
507
- catch {
676
+ const root = resolveProjectRoot();
677
+ if (root === "global")
508
678
  return { root: "global", hash: "global" };
509
- }
679
+ return { root, hash: hashProjectRoot(root) };
510
680
  }
511
681
  const args = process.argv.slice(2);
512
682
  const command = args[0];
@@ -534,8 +704,7 @@ if (command === "backfill") {
534
704
  process.exit(0);
535
705
  }
536
706
  if (args.includes("--uninstall")) {
537
- uninstallAll();
538
- process.exit(0);
707
+ process.exit(uninstallAll() ? 0 : 1);
539
708
  }
540
709
  // --target <names>: comma-separated platform list, default claude-only.
541
710
  // Non-claude targets receive the skill text in their platform's rules file
@@ -597,7 +766,9 @@ Research → Plan → Execute → Verify → Reflect → Learn → Iterate
597
766
  `);
598
767
  warnOnMarketplaceCollision();
599
768
  console.log("Installing to Claude Code...\n");
600
- const installed = installSkill() ? 1 : 0;
769
+ const installOutcome = installSkill();
770
+ if (installOutcome === "failed")
771
+ process.exitCode = 1;
601
772
  const modeInfo = {
602
773
  beginner: "Hooks are capturing silently. System auto-levels as you use it.",
603
774
  expert: `Full plugin active: hooks + MCP server + session hooks. ${getToolNames("expert").length} tools available.`,
@@ -651,19 +822,23 @@ if (packIndex !== -1 && rawArgs[packIndex + 1]) {
651
822
  console.error(` ✗ Unknown pack: ${packName}. Available: ${available.join(", ")}`);
652
823
  }
653
824
  }
654
- console.log(`
655
- ${installed === 1 ? "Done." : "Failed."}
656
- ${modeInfo[INSTALL_MODE]}
825
+ const installedNextSteps = `${modeInfo[INSTALL_MODE]}
657
826
 
658
827
  Next steps:
659
828
  1. Start a new Claude Code session
660
829
  2. Say: "Use the continuous-improvement framework to [your task]"
661
- 3. If a task needs persistent planning, run: /planning-with-files
662
- 4. After your first task, run: /continuous-improvement
663
- 5. Try: /discipline for quick reference, /dashboard for instinct health
830
+ 3. For one defect, run: /ship [one-line defect description]
831
+ 4. If a task needs persistent planning, run: /planning-with-files
832
+ 5. After your first task, run: /continuous-improvement
833
+ 6. Try: /discipline for quick reference, /dashboard for instinct health
664
834
  ${INSTALL_MODE === "expert" ? `\nMCP tools available (${getToolNames("expert").length}): ${getToolNames("expert").join(", ")}` : ""}
665
- Available instinct packs: npx continuous-improvement install --pack react|python|go|meta
666
- `);
835
+ Available instinct packs: npx continuous-improvement install --pack react|python|go|meta`;
836
+ const installSummary = installOutcome === "installed"
837
+ ? `Done.\n${installedNextSteps}`
838
+ : installOutcome === "preserved"
839
+ ? `Done with warning.\n${modeInfo[INSTALL_MODE]}\n\nPackage /ship was not installed because an existing unowned ship skill was preserved. Resolve that collision, then reinstall before using the package workflow.`
840
+ : "Failed.\nInstallation incomplete. Fix the errors above and rerun the installer. Do not assume hooks, skills, or commands are ready.";
841
+ console.log(`\n${installSummary}\n`);
667
842
  // Update-available nudge for the npm/CLI install path (the marketplace path is
668
843
  // covered by Claude Code's native plugin auto-update). Reads the public npm
669
844
  // registry only — no telemetry. Throttled, default-on, off via
@@ -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);
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Merge instinct buckets that are the same project under different path
4
+ * spellings (C:/ vs c:/ vs C:\). observe/gateguard now hash the canonical
5
+ * root; this CLI copies history into that hash and leaves an alias marker
6
+ * on the old dir. Idempotent. Never deletes a directory.
7
+ *
8
+ * Usage:
9
+ * node bin/reconcile-instinct-hashes.mjs --dry-run
10
+ * node bin/reconcile-instinct-hashes.mjs --apply
11
+ * node bin/reconcile-instinct-hashes.mjs --apply --only 3ef4426c6e15 --only 137f2f54ec70
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { argv, exit } from "node:process";
16
+ import { canonicalizeProjectRoot, hashProjectRoot, resolveInstinctsRoot } from "../lib/gateguard-state.mjs";
17
+ function countJsonl(path) {
18
+ if (!existsSync(path))
19
+ return 0;
20
+ return readFileSync(path, "utf8").split(/\n/).filter((line) => line.trim() !== "").length;
21
+ }
22
+ function isoNow() {
23
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
24
+ }
25
+ export function discoverGroups(instinctsRoot) {
26
+ const groups = new Map();
27
+ let entries;
28
+ try {
29
+ entries = readdirSync(instinctsRoot);
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ for (const name of entries) {
35
+ if (name === "global")
36
+ continue;
37
+ const dir = join(instinctsRoot, name);
38
+ try {
39
+ if (!statSync(dir).isDirectory())
40
+ continue;
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ if (existsSync(join(dir, "alias.json")) && !existsSync(join(dir, "observations.jsonl"))) {
46
+ continue;
47
+ }
48
+ const projectPath = join(dir, "project.json");
49
+ if (!existsSync(projectPath))
50
+ continue;
51
+ let project;
52
+ try {
53
+ project = JSON.parse(readFileSync(projectPath, "utf8"));
54
+ }
55
+ catch {
56
+ continue;
57
+ }
58
+ if (!project.root)
59
+ continue;
60
+ const canonicalRoot = canonicalizeProjectRoot(project.root);
61
+ const canonicalHash = hashProjectRoot(canonicalRoot);
62
+ const bucket = {
63
+ hash: name,
64
+ root: project.root,
65
+ name: project.name ?? name,
66
+ rows: countJsonl(join(dir, "observations.jsonl")),
67
+ };
68
+ const existing = groups.get(canonicalHash);
69
+ if (existing) {
70
+ existing.members.push(bucket);
71
+ }
72
+ else {
73
+ groups.set(canonicalHash, {
74
+ canonicalHash,
75
+ canonicalRoot,
76
+ name: project.name ?? name,
77
+ members: [bucket],
78
+ });
79
+ }
80
+ }
81
+ return [...groups.values()].filter((group) => group.members.some((member) => member.hash !== group.canonicalHash && member.rows > 0));
82
+ }
83
+ function parseTs(line) {
84
+ try {
85
+ const parsed = JSON.parse(line);
86
+ const value = Date.parse(parsed.ts ?? "");
87
+ return Number.isFinite(value) ? value : 0;
88
+ }
89
+ catch {
90
+ return 0;
91
+ }
92
+ }
93
+ export function mergeJsonl(sources) {
94
+ const rows = [];
95
+ const seen = new Set();
96
+ for (const file of sources) {
97
+ if (!existsSync(file))
98
+ continue;
99
+ for (const line of readFileSync(file, "utf8").split(/\n/)) {
100
+ const trimmed = line.trim();
101
+ if (!trimmed || seen.has(trimmed))
102
+ continue;
103
+ seen.add(trimmed);
104
+ rows.push(trimmed);
105
+ }
106
+ }
107
+ rows.sort((a, b) => parseTs(a) - parseTs(b) || a.localeCompare(b));
108
+ return rows.length === 0 ? "" : `${rows.join("\n")}\n`;
109
+ }
110
+ export function selectGroups(groups, only) {
111
+ if (only.length === 0)
112
+ return groups;
113
+ const wanted = new Set(only);
114
+ return groups.filter((group) => wanted.has(group.canonicalHash) || group.members.some((member) => wanted.has(member.hash)));
115
+ }
116
+ export function applyGroup(instinctsRoot, group) {
117
+ const destDir = join(instinctsRoot, group.canonicalHash);
118
+ mkdirSync(destDir, { recursive: true });
119
+ const destObs = join(destDir, "observations.jsonl");
120
+ const sources = group.members
121
+ .map((member) => join(instinctsRoot, member.hash, "observations.jsonl"))
122
+ .filter((path) => existsSync(path));
123
+ const merged = mergeJsonl(sources);
124
+ writeFileSync(destObs, merged, "utf8");
125
+ let createdAt = isoNow();
126
+ const destProject = join(destDir, "project.json");
127
+ if (existsSync(destProject)) {
128
+ try {
129
+ const existing = JSON.parse(readFileSync(destProject, "utf8"));
130
+ if (existing.created_at)
131
+ createdAt = existing.created_at;
132
+ }
133
+ catch {
134
+ // rewrite below
135
+ }
136
+ }
137
+ writeFileSync(destProject, `${JSON.stringify({
138
+ id: group.canonicalHash,
139
+ name: group.name,
140
+ root: group.canonicalRoot,
141
+ created_at: createdAt,
142
+ })}\n`, "utf8");
143
+ const aliases = [];
144
+ for (const member of group.members) {
145
+ if (member.hash === group.canonicalHash)
146
+ continue;
147
+ const srcDir = join(instinctsRoot, member.hash);
148
+ try {
149
+ for (const file of readdirSync(srcDir)) {
150
+ if (!file.endsWith(".yaml"))
151
+ continue;
152
+ const dest = join(destDir, file);
153
+ if (!existsSync(dest))
154
+ copyFileSync(join(srcDir, file), dest);
155
+ }
156
+ }
157
+ catch {
158
+ // missing dir is non-fatal
159
+ }
160
+ const srcObs = join(srcDir, "observations.jsonl");
161
+ if (existsSync(srcObs)) {
162
+ renameSync(srcObs, join(srcDir, `observations.migrated-to-${group.canonicalHash}.jsonl`));
163
+ }
164
+ writeFileSync(join(srcDir, "alias.json"), `${JSON.stringify({
165
+ canonical: group.canonicalHash,
166
+ canonical_root: group.canonicalRoot,
167
+ migrated_at: isoNow(),
168
+ rows: member.rows,
169
+ })}\n`, "utf8");
170
+ aliases.push(member.hash);
171
+ }
172
+ const copiedRows = merged === "" ? 0 : merged.trim().split("\n").length;
173
+ return { copiedRows, aliases };
174
+ }
175
+ function printPlan(groups) {
176
+ if (groups.length === 0) {
177
+ console.log("No alias observation buckets to merge.");
178
+ return;
179
+ }
180
+ console.log(`Alias groups: ${groups.length}`);
181
+ for (const group of groups) {
182
+ console.log(` ${group.name} → ${group.canonicalHash} (${group.canonicalRoot})`);
183
+ for (const member of group.members) {
184
+ const mark = member.hash === group.canonicalHash ? "canonical" : "alias";
185
+ console.log(` ${member.hash} ${member.rows} rows ${mark} root=${member.root}`);
186
+ }
187
+ }
188
+ }
189
+ function parseOnly(args) {
190
+ const only = [];
191
+ for (let i = 0; i < args.length; i += 1) {
192
+ if (args[i] === "--only" && args[i + 1]) {
193
+ only.push(args[i + 1]);
194
+ i += 1;
195
+ }
196
+ }
197
+ return only;
198
+ }
199
+ function main() {
200
+ const args = argv.slice(2);
201
+ const apply = args.includes("--apply");
202
+ const dryRun = args.includes("--dry-run") || !apply;
203
+ const instinctsRoot = resolveInstinctsRoot();
204
+ const groups = selectGroups(discoverGroups(instinctsRoot), parseOnly(args));
205
+ printPlan(groups);
206
+ if (dryRun) {
207
+ if (groups.length > 0)
208
+ console.log("\nRe-run with --apply to merge.");
209
+ return;
210
+ }
211
+ for (const group of groups) {
212
+ const result = applyGroup(instinctsRoot, group);
213
+ console.log(`merged ${result.copiedRows} rows into ${group.canonicalHash}; aliases ${result.aliases.join(",") || "(none)"}`);
214
+ }
215
+ }
216
+ const invokedDirectly = argv[1]?.endsWith("reconcile-instinct-hashes.mjs");
217
+ if (invokedDirectly) {
218
+ try {
219
+ main();
220
+ }
221
+ catch (error) {
222
+ const message = error instanceof Error ? error.message : String(error);
223
+ console.error(message);
224
+ exit(1);
225
+ }
226
+ }
@@ -19,6 +19,8 @@ Print this card and check yourself against each law.
19
19
  | 6 | **Iterate One Change** | Am I changing one thing at a time? | "And also..." |
20
20
  | 7 | **Learn From Every Session** | Did I capture this as an instinct? | "Next time I'll..." |
21
21
 
22
+ Read the seven as three moments around every act. **Before** (Laws 1, 2): set the terms. **During** (Laws 3, 6): watch yourself. **After** (Laws 4, 5, 7): settle the account and carry it forward. Every check is an audit you run on yourself; every red flag is you hoping instead. The sentence this comes from, and its sources: `docs/philosophy.md`.
23
+
22
24
  ## Operator Stakes
23
25
 
24
26
  The Laws above are the *how*. These five principles are the *why*: code ships from your account, the incident lands on your pager, the bill hits your budget. Each one pairs with the Law that prevents it from going wrong.
@@ -31,7 +33,7 @@ The Laws above are the *how*. These five principles are the *why*: code ships fr
31
33
  | 4 | **Problem framing** | Builds the websocket chat the ticket asked for | Finds out users wanted faster support replies, not chat | 1 |
32
34
  | 5 | **Constraints management** | Calls the $0.02/image model on every upload | Does the math, adds client-side validation + caching + cheaper triage model | 2 |
33
35
 
34
- Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you.
36
+ Code is a liability, not an asset. Speed without these five turns into someone else's incident at 3am — except the someone is you. The other half of the why is not fear: the session ends and the context is gone, so the only work that survives is what you verified and wrote down for the one who comes after, whether that is tomorrow's session or the engineer who inherits the repo.
35
37
 
36
38
  ## Goal-Driven Execution maps onto the Laws
37
39
 
@@ -61,5 +63,6 @@ Before saying "Done", verify ALL:
61
63
  - [ ] I checked the **actual** result (not assumed)
62
64
  - [ ] Build passes
63
65
  - [ ] I can explain the change in one sentence
66
+ - [ ] For each item above I checked, not hoped
64
67
 
65
- If you're skipping a step, that's the step you need most.
68
+ If you're skipping a step, that's the step you need most. The step you skip is the one you are hoping through.