@thedecipherist/claude-packs 1.0.0 → 1.1.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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +275 -18
  3. package/bin/claude-packs.mjs +18 -10
  4. package/configurations/base/config.json +1 -1
  5. package/configurations/cpp/config.json +2 -1
  6. package/configurations/full-stack/config.json +2 -1
  7. package/configurations/go/config.json +3 -1
  8. package/configurations/node/config.json +3 -1
  9. package/configurations/python/config.json +3 -1
  10. package/configurations/rust/config.json +106 -0
  11. package/configurations/synth-creator/config.json +1 -1
  12. package/global/hooks/lib/shell-writes.mjs +2 -293
  13. package/lib/build-queue.mjs +22 -0
  14. package/lib/claude-md.mjs +4 -2
  15. package/lib/config-cli.mjs +1 -1
  16. package/lib/coverage.mjs +10 -2
  17. package/lib/doctor.mjs +14 -3
  18. package/lib/fix-plan.mjs +2 -1
  19. package/lib/found-folders.mjs +25 -0
  20. package/lib/global.mjs +3 -3
  21. package/lib/hook-proof.mjs +24 -7
  22. package/lib/kit-command.mjs +14 -0
  23. package/lib/layout.mjs +27 -3
  24. package/lib/logs.mjs +45 -5
  25. package/lib/new.mjs +1 -1
  26. package/lib/packs.mjs +4 -2
  27. package/lib/rule-tests.mjs +42 -0
  28. package/lib/scan-detect.mjs +41 -5
  29. package/lib/scan.mjs +5 -2
  30. package/lib/suggest.mjs +16 -3
  31. package/lib/sync.mjs +5 -2
  32. package/lib/workorder.mjs +26 -0
  33. package/package.json +1 -1
  34. package/packs/audio-core/hooks/param-id-lock.mjs +59 -16
  35. package/packs/audio-core/pack.json +5 -0
  36. package/packs/audio-core/rules/a-control-is-proved-through-its-input.md +26 -0
  37. package/packs/audio-core/rules/cpu-worst-case-per-block.md +3 -1
  38. package/packs/audio-core/rules/house-control-behaviours.md +1 -1
  39. package/packs/audio-core/rules/scoped-no-denormals.md +1 -1
  40. package/packs/audio-core/rules/straight-on-and-aligned.md +1 -1
  41. package/packs/audio-core/rules/ui-matches-reference-look.md +2 -0
  42. package/packs/audio-core/skills/analyse-reference-look/SKILL.md +25 -11
  43. package/packs/audio-core/skills/prove-control/SKILL.md +2 -0
  44. package/packs/base/checks/one-walk-per-template.json +1 -1
  45. package/packs/base/hooks/code-shape.mjs +10 -6
  46. package/packs/base/hooks/dispatch-once.mjs +58 -21
  47. package/packs/base/hooks/folder-law.mjs +3 -0
  48. package/packs/base/hooks/git-guard.mjs +22 -109
  49. package/packs/base/hooks/lesson-checks.mjs +9 -1
  50. package/packs/base/hooks/lib/code-shapes.mjs +11 -6
  51. package/packs/base/hooks/lib/crash-policy.mjs +47 -0
  52. package/packs/base/hooks/lib/dup-index.mjs +6 -1
  53. package/packs/base/hooks/lib/dup.mjs +2 -2
  54. package/packs/base/hooks/lib/event-log.mjs +4 -3
  55. package/packs/base/hooks/lib/git-adds.mjs +2 -1
  56. package/packs/base/hooks/lib/git-auth.mjs +44 -0
  57. package/packs/base/hooks/lib/git-guard-selftest.mjs +93 -0
  58. package/packs/base/hooks/lib/languages.mjs +5 -2
  59. package/packs/base/hooks/lib/layout.mjs +6 -2
  60. package/packs/base/hooks/lib/making-check-selftest.mjs +3 -0
  61. package/packs/base/hooks/lib/near-copies.mjs +1 -1
  62. package/packs/base/hooks/lib/program-read.mjs +6 -3
  63. package/packs/base/hooks/lib/rust-scan.mjs +139 -0
  64. package/packs/base/hooks/lib/shape-lists.mjs +31 -0
  65. package/packs/base/hooks/lib/shape-prose.mjs +78 -0
  66. package/packs/base/hooks/lib/shape.mjs +18 -39
  67. package/packs/base/hooks/lib/turn.mjs +6 -1
  68. package/packs/base/hooks/lib/unread-writes.mjs +24 -0
  69. package/packs/base/hooks/making-check.mjs +12 -7
  70. package/packs/base/hooks/run-hooks.mjs +15 -7
  71. package/packs/base/hooks/shape-law.mjs +11 -4
  72. package/packs/base/hooks/suite-once.mjs +22 -6
  73. package/packs/base/rules/a-control-is-seen-to-fire.md +1 -1
  74. package/packs/base/rules/behaviour-lives-with-its-kind.md +1 -1
  75. package/packs/base/rules/bug-fix-starts-red.md +1 -1
  76. package/packs/base/rules/field-map-over-if-runs.md +1 -1
  77. package/packs/base/rules/instances-by-index-identities-by-id.md +2 -2
  78. package/packs/base/rules/lookup-over-if-chains.md +1 -1
  79. package/packs/base/rules/plan-and-logs.md +1 -1
  80. package/packs/base/rules/refusals-are-judged.md +1 -0
  81. package/packs/base/rules/test-the-template-once.md +2 -1
  82. package/packs/base/rules/unattended-runs.md +15 -0
  83. package/packs/base/skills/new-project/SKILL.md +23 -0
  84. package/packs/base/skills/write-a-workorder/SKILL.md +41 -0
  85. package/packs/cpp/checks/cpp-find-with-the-library.json +2 -2
  86. package/packs/go/pack.json +5 -0
  87. package/packs/hook-proof/rules/a-refusal-is-exit-2.md +2 -2
  88. package/packs/instrument-shell/rules/render-the-extremes.md +2 -2
  89. package/packs/midi/checks/position-not-integrated.json +5 -1
  90. package/packs/postgres/checks/pg-bind-values.json +33 -0
  91. package/packs/postgres/checks/pg-concurrently-in-txn.json +18 -0
  92. package/packs/postgres/checks/pg-fk-not-valid.json +18 -0
  93. package/packs/postgres/checks/pg-index-concurrently.json +18 -0
  94. package/packs/postgres/checks/pg-no-float-money.json +19 -0
  95. package/packs/postgres/checks/pg-no-money.json +18 -0
  96. package/packs/postgres/checks/pg-no-required-column.json +18 -0
  97. package/packs/postgres/checks/pg-no-volatile-default.json +18 -0
  98. package/packs/postgres/checks/pg-not-exists.json +18 -0
  99. package/packs/postgres/checks/pg-timestamptz.json +18 -0
  100. package/packs/postgres/hooks/db-wipe-guard.mjs +95 -0
  101. package/packs/postgres/pack.json +48 -0
  102. package/packs/postgres/rules/postgres-migrations-never-stop-traffic.md +28 -0
  103. package/packs/postgres/rules/postgres-one-pool-per-process.md +21 -0
  104. package/packs/postgres/rules/postgres-queries-are-null-safe-and-bounded.md +26 -0
  105. package/packs/postgres/rules/postgres-types-say-what-they-hold.md +22 -0
  106. package/packs/postgres/rules/postgres-values-are-bound.md +130 -0
  107. package/packs/postgres/skills/postgres-safe-migration/SKILL.md +45 -0
  108. package/packs/reverse-engineering/rules/addresses-name-their-space.md +1 -0
  109. package/packs/reverse-engineering/rules/reproduction-or-original.md +1 -1
  110. package/packs/reverse-engineering/skills/gather-sources/SKILL.md +1 -0
  111. package/packs/rust/checks/rust-allow-needs-reason.json +16 -0
  112. package/packs/rust/checks/rust-blocking-in-async.json +32 -0
  113. package/packs/rust/checks/rust-if-chain-is-a-match.json +32 -0
  114. package/packs/rust/checks/rust-static-mut.json +32 -0
  115. package/packs/rust/checks/rust-string-as-error.json +34 -0
  116. package/packs/rust/checks/rust-stub-left-in.json +32 -0
  117. package/packs/rust/checks/rust-unsafe-needs-safety.json +31 -0
  118. package/packs/rust/checks/rust-unwrap-in-library.json +34 -0
  119. package/packs/rust/checks/rust-wildcard-dependency.json +16 -0
  120. package/packs/rust/clippy.toml +5 -0
  121. package/packs/rust/pack.json +27 -0
  122. package/packs/rust/rules/rust-async-never-blocks.md +20 -0
  123. package/packs/rust/rules/rust-borrow-before-you-clone.md +19 -0
  124. package/packs/rust/rules/rust-errors-are-returned.md +22 -0
  125. package/packs/rust/rules/rust-lints-are-the-gate.md +23 -0
  126. package/packs/rust/rules/rust-repeats-become-templates.md +28 -0
  127. package/packs/rust/rules/rust-unsafe-is-proved.md +21 -0
  128. package/packs/rust/skills/rust-extract-template/SKILL.md +50 -0
  129. package/packs/rust/skills/rust-port/SKILL.md +22 -0
  130. package/packs/rust/skills/rust-review/SKILL.md +24 -0
  131. package/packs/rust/tools/rust-lints.toml +19 -0
  132. package/packs/sound-design/skills/bounce-a-plugin/SKILL.md +40 -0
  133. package/packs/sound-design/tools/vst3host/build.sh +73 -0
  134. package/packs/sound-design/tools/vst3host/host_events.h +101 -0
  135. package/packs/sound-design/tools/vst3host/options.h +172 -0
  136. package/packs/sound-design/tools/vst3host/render.cpp +245 -0
  137. package/packs/sound-design/tools/vst3host/session.h +241 -0
  138. package/packs/synth-creator/hooks/build-guard.mjs +7 -7
  139. package/packs/synth-creator/hooks/protect-vendor.mjs +2 -1
  140. package/packs/synth-creator/hooks/stage-guard.mjs +1 -1
  141. package/packs/synth-creator/rules/timing-from-synthcore.md +1 -1
  142. package/packs/synth-creator/skills/build-through-the-queue/SKILL.md +4 -2
  143. package/packs/synth-creator/tools/buildq.mjs +74 -0
  144. package/prompts/workorder/PROMPT.md +53 -0
  145. package/prompts/workorder/run.sh +36 -0
  146. package/prompts/workorder/workorder.md +59 -0
  147. package/global/hooks/lib/program-read.mjs +0 -196
  148. package/global/hooks/lib/program-writes.mjs +0 -177
package/lib/fix-plan.mjs CHANGED
@@ -4,9 +4,9 @@ import { createHash } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
7
+ import { kitCommand } from './kit-command.mjs';
7
8
  import { scan } from './scan.mjs';
8
9
 
9
- const CLI = 'npx --no-install @thedecipherist/claude-packs';
10
10
  const pointerOf = (dir) => join(tmpdir(), `claude-packs-last-scan-${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 12)}`);
11
11
  export const rememberScan = (dir, json) => writeFileSync(pointerOf(dir), json);
12
12
 
@@ -26,6 +26,7 @@ function repeatBox(r) {
26
26
  }
27
27
 
28
28
  export function planText(dir, s) {
29
+ const CLI = kitCommand(dir);
29
30
  const packs = [...(s.pick?.packs ?? []).map((p) => p.pack)];
30
31
  const howMany = (s.secrets?.length ?? 0) + (s.results ?? []).reduce((n, r) => n + r.hits.length, 0) + (s.repeats?.length ?? 0) + (s.near?.length ?? 0) + (s.remade?.length ?? 0) + (s.longFiles?.length ?? 0);
31
32
  return [
@@ -0,0 +1,25 @@
1
+ // What a first install learns from the project: the folders its source already lives in. A layout written from a template alone leaves them out, and then
2
+ // folder-law refuses a new file in the project's own folder (KWB-83). Only the top level: `lib/`, never `lib/net/`.
3
+ import { SOURCE } from '../packs/base/hooks/lib/dup.mjs';
4
+ import { isContent } from '../packs/base/hooks/lib/content.mjs';
5
+ import { filesOf } from './scan.mjs';
6
+
7
+ // Not the project's own source: hidden folders, what is built or fetched, what is another project's, and folders the layout already has by name.
8
+ const NOT_THE_PROJECTS = /^(?:\..*|node_modules|vendor|third_party|thirdparty|external|extern|deps|target|build|out|dist|coverage|JUCE|cmake-build.*|docs|tools)$/;
9
+
10
+ export function foundFolders(projectDir, sourceFiles, known = []) {
11
+ const source = sourceFiles ? new RegExp(sourceFiles, 'i') : SOURCE;
12
+ const folders = filesOf(projectDir).filter((f) => f.includes('/') && source.test(f) && !isContent(f)).map((f) => f.split('/')[0]);
13
+ return [...new Set(folders)].filter((f) => !NOT_THE_PROJECTS.test(f) && !known.includes(f)).sort();
14
+ }
15
+
16
+ // A seed layout with those folders in it. A complete layout has been fitted by hand, and stays as it is.
17
+ export function withFoundFolders(projectDir, seed, config, log) {
18
+ if (seed?.complete) return seed;
19
+ const known = Object.values({ ...config.layout?.folders, ...seed?.folders }).map((f) => f.path.split('/')[0]);
20
+ const found = foundFolders(projectDir, config.sourceFiles, known);
21
+ if (!found.length) return seed;
22
+ log(` layout: your source is in ${found.map((f) => `${f}/`).join(', ')}, so the layout has ${found.length > 1 ? 'them' : 'it'}`);
23
+ const folders = Object.fromEntries(found.map((f) => [f, { path: f, purpose: 'source, found at install' }]));
24
+ return { ...seed, folders: { ...folders, ...seed?.folders } };
25
+ }
package/lib/global.mjs CHANGED
@@ -17,9 +17,9 @@ const SRC = fileURLToPath(new URL('../global/', import.meta.url));
17
17
  export const FILES = [
18
18
  ['skills/latest-versions/SKILL.md', 'skills/latest-versions/SKILL.md'],
19
19
  ['hooks/version-pin-guard.mjs', 'hooks/claude-packs/version-pin-guard.mjs'],
20
- ['hooks/lib/shell-writes.mjs', 'hooks/claude-packs/lib/shell-writes.mjs'],
21
- ['hooks/lib/program-writes.mjs', 'hooks/claude-packs/lib/program-writes.mjs'],
22
- ['hooks/lib/program-read.mjs', 'hooks/claude-packs/lib/program-read.mjs'],
20
+ ['../packs/base/hooks/lib/shell-writes.mjs', 'hooks/claude-packs/lib/shell-writes.mjs'],
21
+ ['../packs/base/hooks/lib/program-writes.mjs', 'hooks/claude-packs/lib/program-writes.mjs'],
22
+ ['../packs/base/hooks/lib/program-read.mjs', 'hooks/claude-packs/lib/program-read.mjs'],
23
23
  ['hooks/shell-traps.mjs', 'hooks/shell-traps.mjs'],
24
24
  ['hooks/gnu-shell.mjs', 'hooks/gnu-shell.mjs'],
25
25
  ];
@@ -1,13 +1,26 @@
1
1
  // A hook that should refuse and does not (0.18.0: whenever a hook is supposed to exit 2 and it exits 0, it is caught). Only exit 2
2
- // blocks, so a guard fails open and silently. doctor sees two of the ways: a hook no settings entry runs (dispatch-once was that way for
3
- // a moment in 0.11.0), and a hook that crashed since it last changed, from its exit code in .claude/hooks.log. A hook killed at its
4
- // timeout leaves no line and is not seen.
2
+ // blocks, so a guard fails open and silently. doctor sees three of the ways: a hook no settings entry runs (dispatch-once was that way for
3
+ // a moment in 0.11.0); a hook that crashed since it last changed, from its exit code in .claude/hooks.log, where "changed" counts every
4
+ // file in hooks/lib/, since most crashes are there (KF-40), and a crash no change answers is cleared by `logs clear` with its reason (KF-18);
5
+ // and param-id-lock holding no ids in a unit with parameters (KF-15). A hook killed at its timeout leaves no line and is not seen.
6
+ import { spawnSync } from 'node:child_process';
5
7
  import { existsSync, readdirSync, statSync } from 'node:fs';
6
8
  import { join } from 'node:path';
7
9
  import { pathToFileURL } from 'node:url';
10
+ import { ANSWERS, clearedAt, clearsOf } from './logs.mjs';
8
11
 
9
12
  const RUNNER = 'run-hooks.mjs';
10
- const ANSWERS = new Set(['0', '2', '-', 'undefined', 'null']);
13
+ const hookName = (file) => file.replace(/\.(mjs|py)$/, '');
14
+
15
+ // The time a hook last changed: its own file, or anything it shares in hooks/lib.
16
+ const changedAt = (dir, file, libTimes) => Math.max(statSync(join(dir, file)).mtimeMs, ...libTimes);
17
+
18
+ export function paramLockProblems(projectDir) {
19
+ const hook = join(projectDir, '.claude', 'hooks', 'param-id-lock.mjs');
20
+ if (!existsSync(hook)) return [];
21
+ const r = spawnSync('node', [hook, '--status'], { encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir } });
22
+ return r.status === 0 ? [] : [r.stdout.trim() || r.stderr.trim()];
23
+ }
11
24
 
12
25
  export async function hookProofProblems(projectDir, settingsText) {
13
26
  const dir = join(projectDir, '.claude', 'hooks');
@@ -17,12 +30,16 @@ export async function hookProofProblems(projectDir, settingsText) {
17
30
  const log = join(dir, 'lib', 'hook-log.mjs');
18
31
  if (!existsSync(log)) return problems;
19
32
  const { readHookLog } = await import(pathToFileURL(log).href);
33
+ const libTimes = readdirSync(join(dir, 'lib')).map((f) => statSync(join(dir, 'lib', f)).mtimeMs);
34
+ const clears = clearsOf(projectDir);
20
35
  const crashed = new Map();
21
36
  for (const row of readHookLog(projectDir, { rotated: true })) {
22
- const file = hooks.find((f) => f.replace(/\.(mjs|py)$/, '') === row.hook);
37
+ const file = hooks.find((f) => hookName(f) === row.hook);
23
38
  if (!file || ANSWERS.has(String(row.code))) continue;
24
- if (Date.parse(row.time) > statSync(join(dir, file)).mtimeMs) crashed.set(file, (crashed.get(file) ?? 0) + 1);
39
+ if (Date.parse(row.time) > changedAt(dir, file, libTimes) && row.time > clearedAt(clears, row.hook)) crashed.set(file, (crashed.get(file) ?? 0) + 1);
40
+ }
41
+ for (const [f, n] of crashed) {
42
+ problems.push(`${f} crashed on ${n} call(s) since it last changed: those calls were not judged (\`logs crashes\`). Once fixed or superseded: \`logs clear ${hookName(f)} fixed|superseded "<why>"\``);
25
43
  }
26
- for (const [f, n] of crashed) problems.push(`${f} crashed on ${n} call(s) since it last changed: those calls were not judged, and went through (.claude/hooks.log, .claude/logs/)`);
27
44
  return problems;
28
45
  }
@@ -0,0 +1,14 @@
1
+ // How this project runs the kit. `npx --no-install` needs the kit in its own node_modules, and a project in another language has none: there npx
2
+ // fetches it, and the --no-install form every installed file names fails with "npx canceled due to missing packages" (KWB-76).
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const PACKAGE = '@thedecipherist/claude-packs';
7
+
8
+ export const hasLocalKit = (dir) => existsSync(join(dir, 'node_modules', PACKAGE, 'package.json'));
9
+ export const kitCommand = (dir) => (hasLocalKit(dir) ? `npx --no-install ${PACKAGE}` : `npx ${PACKAGE}`);
10
+
11
+ // The line the CLAUDE.md block adds where the installed files' form cannot work: a project with no package.json at all.
12
+ export const noPackageLine = (dir) => (existsSync(join(dir, 'package.json')) || hasLocalKit(dir)
13
+ ? []
14
+ : [`This project has no package.json: run the kit as \`npx ${PACKAGE} <command>\`, wherever its files say \`npx --no-install ${PACKAGE}\`.`, '']);
package/lib/layout.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  // checks a later package version adds to a pack the project already had (see switches.mjs)
13
13
  // "sourceFiles": "<regex>" what is source to folder-law and doctor (written once from the configuration's; KWB-37)
14
14
  // "gates": { "hover": "<command>", "a11y": "<command>" } the gate that proves the UI's hover (doctor is red for UI code without one; "none: <reason>" declines; 0.10.1)
15
+ // "ruleTests": { "rule-name": "path/to/its/test" | "none: <reason>" } what answers a rule's named test when no test names the rule (KF-31)
16
+ // "followFolders": true base's language concepts cover every code folder of the layout. A first install writes it; an older project chooses it
15
17
  // "guards": { "git", "copy", "time", "browser", "images" } the project's values for the house guards
16
18
  // (repo owner and token, the sanctioned copy tools, ...); each hook has its defaults
17
19
  // A seed layout (a profile, or one written from docs/project.md) is used when no layout exists yet.
@@ -19,14 +21,35 @@
19
21
  // before the packs', so a project gets the folder structure of its kind (0.10.0).
20
22
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
23
  import { join } from 'node:path';
24
+ import { NEW_INSTALL_GIT } from '../packs/base/hooks/lib/git-auth.mjs';
25
+ import { codeFolders } from '../packs/base/hooks/lib/layout.mjs';
22
26
  import { readPack } from './packs.mjs';
23
27
 
24
- const OWNED = ['files', 'protected', 'buildOutput', 'writeOnce', 'keepWorktrees', 'disable', 'enable', 'commands', 'siblings', 'maxFileLines', 'paramIds', 'guards', 'sourceFiles', 'gates'];
28
+ const OWNED = ['files', 'protected', 'buildOutput', 'writeOnce', 'keepWorktrees', 'disable', 'enable', 'commands', 'siblings', 'maxFileLines', 'paramIds', 'guards', 'sourceFiles', 'gates', 'ruleTests', 'followFolders'];
29
+ const OPTIONAL = ['maxFileLines', 'paramIds', 'guards', 'buildOutput', 'writeOnce', 'keepWorktrees', 'sourceFiles', 'gates', 'ruleTests', 'followFolders'];
25
30
  // "complete": true marks a tailored layout; see below.
26
31
 
32
+ // Base's language concepts name folders, where every other language's are `**`: a file in a layout folder could be legal and have no rules (KWB-83).
33
+ // `**` would close that and hide a stray file from doctor --strict, so the concept follows the layout instead: a folder in it gets its rules, and one outside stays reported.
34
+ const FOLLOWED = { cpp: ['c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'mm'], js: ['{js,mjs,cjs,jsx,ts,tsx}'] };
35
+
36
+ function followFolders(layout) {
37
+ for (const [concept, extensions] of Object.entries(FOLLOWED)) {
38
+ if (!layout.concepts[concept]) continue;
39
+ const globs = codeFolders(layout).flatMap((folder) => extensions.map((ext) => `${folder}/**/*.${ext}`));
40
+ layout.concepts[concept] = [...new Set([...layout.concepts[concept], ...globs])];
41
+ }
42
+ }
43
+
27
44
  export function buildLayout(claudeDir, packNames, seed = null, config = null) {
28
45
  const file = join(claudeDir, 'layout.json');
29
- const current = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : (seed ?? {});
46
+ const firstInstall = !existsSync(file);
47
+ const current = firstInstall ? (seed ?? {}) : JSON.parse(readFileSync(file, 'utf8'));
48
+ // A project already installed changes only through "enable": "guards/git-house".
49
+ if (firstInstall) current.guards = { ...current.guards, git: { ...NEW_INSTALL_GIT, ...current.guards?.git } };
50
+ // The key is the choice: a new install is held to its rules' named tests, and one already installed is told, until it adds the key.
51
+ if (firstInstall) current.ruleTests ??= {};
52
+ if (firstInstall && !current.complete) current.followFolders ??= true;
30
53
  const layout = {
31
54
  _comment: 'Where everything lives. folders = the folders; kinds = what goes where (CLAUDE.md shows it, placement-law enforces it); concepts = which rules fire where. Edit here, then run `npx --no-install @thedecipherist/claude-packs update`.',
32
55
  ...(config ? { configuration: config.name } : current.configuration ? { configuration: current.configuration } : {}),
@@ -36,7 +59,7 @@ export function buildLayout(claudeDir, packNames, seed = null, config = null) {
36
59
  };
37
60
  for (const key of OWNED) {
38
61
  if (current[key] !== undefined) layout[key] = current[key];
39
- else if (!['maxFileLines', 'paramIds', 'guards', 'buildOutput', 'writeOnce', 'keepWorktrees', 'sourceFiles', 'gates'].includes(key)) layout[key] = key === 'files' || key === 'commands' ? {} : [];
62
+ else if (!OPTIONAL.includes(key)) layout[key] = key === 'files' || key === 'commands' ? {} : [];
40
63
  }
41
64
  // A kind's content pattern that is exactly a pattern the pack used to ship ("contentWas") is
42
65
  // replaced by the pack's current one; a pattern the project wrote itself is kept.
@@ -70,6 +93,7 @@ export function buildLayout(claudeDir, packNames, seed = null, config = null) {
70
93
  if (!current.concepts?.[k]) layout.concepts[k] = [...new Set([...(layout.concepts[k] ?? []), ...v])];
71
94
  }
72
95
  }
96
+ if (layout.followFolders) followFolders(layout);
73
97
  writeFileSync(file, JSON.stringify(layout, null, 2) + '\n');
74
98
  return layout;
75
99
  }
package/lib/logs.mjs CHANGED
@@ -2,10 +2,15 @@
2
2
  // logs per hook: calls, refusals, right, wrong, not judged, crashes, time
3
3
  // logs refusals [--hook <h>] [--open] every refusal with its reason (--open: not judged yet)
4
4
  // logs mark <id> right|wrong "<why>" the verdict, added to .claude/logs/verdicts.jsonl; nothing is rewritten
5
+ // logs crashes every crash, and whether it is cleared (KF-40: a crash is no refusal, and has no verdict)
6
+ // logs clear <hook>|all fixed|superseded "<why>" clears the crashes so far, for doctor (KF-18); a later crash is red again
5
7
  import { appendFileSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
6
8
  import { join } from 'node:path';
9
+ import { readHookLog } from '../packs/base/hooks/lib/hook-log.mjs';
7
10
 
8
11
  const DIR = join('.claude', 'logs');
12
+ const HOW = ['fixed', 'superseded'];
13
+ export const ANSWERS = new Set(['0', '2', '-', 'undefined', 'null']); // the codes of hooks.log that are no crash
9
14
 
10
15
  function linesOf(dir, name) {
11
16
  const logs = join(dir, DIR);
@@ -14,6 +19,39 @@ function linesOf(dir, name) {
14
19
  return files.flatMap((f) => readFileSync(join(logs, f), 'utf8').split('\n').filter(Boolean).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } }));
15
20
  }
16
21
 
22
+ // A crash, in its own log or, in an old log, in refusals.jsonl with a code other than 2. Old lines are read as they are, never rewritten.
23
+ const crashesOf = (dir) => [...linesOf(dir, 'crashes'), ...linesOf(dir, 'refusals').filter((r) => r.code !== 2)];
24
+ const refusalsOf = (dir) => linesOf(dir, 'refusals').filter((r) => r.code === 2);
25
+
26
+ // hook -> the time of its last clear ("all" stands for every hook). doctor counts only a crash after it.
27
+ export function clearsOf(dir) {
28
+ const clears = new Map();
29
+ for (const v of linesOf(dir, 'verdicts')) if (v.clear) clears.set(v.clear, v.t);
30
+ return clears;
31
+ }
32
+ export const clearedAt = (clears, hook) => [clears.get(hook), clears.get('all')].filter(Boolean).sort().at(-1) ?? '';
33
+
34
+ function clearCrashes(dir, [, hook, how, ...why], log) {
35
+ if (!hook || !HOW.includes(how) || !why.length) throw new Error('use `logs clear <hook>|all fixed|superseded "<why>"`');
36
+ const logged = readHookLog(dir, { rotated: true }).filter((r) => !ANSWERS.has(String(r.code)));
37
+ if (![...crashesOf(dir), ...logged].some((c) => hook === 'all' || c.hook === hook)) throw new Error(`no crash of ${hook}: \`logs crashes\` lists them`);
38
+ mkdirSync(join(dir, DIR), { recursive: true });
39
+ appendFileSync(join(dir, DIR, 'verdicts.jsonl'), `${JSON.stringify({ clear: hook, how, why: why.join(' '), t: new Date().toISOString() })}\n`);
40
+ log(`${hook}: crashes so far cleared as ${how}`);
41
+ return 0;
42
+ }
43
+
44
+ function listCrashes(dir, log) {
45
+ const clears = clearsOf(dir);
46
+ const list = crashesOf(dir);
47
+ for (const c of list) {
48
+ const state = c.t <= clearedAt(clears, c.hook) ? 'cleared' : 'open';
49
+ log(`${c.id} ${c.t} ${c.hook} ${state}\n ${c.file ?? c.command ?? c.tool ?? '-'}\n ${(c.reason ?? '').split('\n')[0]}`);
50
+ }
51
+ log(`${list.length} crash(es)`);
52
+ return 0;
53
+ }
54
+
17
55
  const verdictsOf = (dir) => new Map(linesOf(dir, 'verdicts').map((v) => [v.id, v]));
18
56
  const middle = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)] ?? 0;
19
57
 
@@ -24,14 +62,13 @@ function passedAfter(refusal, events) {
24
62
 
25
63
  export function logsSummary(dir) {
26
64
  const events = linesOf(dir, 'events');
27
- const refusals = linesOf(dir, 'refusals');
28
65
  const verdicts = verdictsOf(dir);
29
66
  const rows = new Map();
30
67
  const row = (hook) => { if (!rows.has(hook)) rows.set(hook, { hook, calls: 0, refused: 0, crashed: 0, right: 0, wrong: 0, open: 0, passedAfter: 0, ms: [] }); return rows.get(hook); };
31
68
  for (const e of events) for (const h of e.hooks ?? []) { const r = row(h.hook); r.calls += 1; r.ms.push(h.ms); }
32
- for (const f of refusals) {
69
+ for (const c of crashesOf(dir)) row(c.hook).crashed += 1;
70
+ for (const f of refusalsOf(dir)) {
33
71
  const r = row(f.hook);
34
- if (f.code !== 2) { r.crashed += 1; continue; }
35
72
  r.refused += 1;
36
73
  const v = verdicts.get(f.id)?.verdict;
37
74
  if (v === 'right') r.right += 1; else if (v === 'wrong') r.wrong += 1; else r.open += 1;
@@ -45,16 +82,19 @@ export function logsCommand(dir, rest, log = console.log) {
45
82
  if (sub === 'mark') {
46
83
  const [, id, verdict, ...why] = rest;
47
84
  if (!id || !['right', 'wrong'].includes(verdict) || !why.length) throw new Error('use `logs mark <id> right|wrong "<why>"`');
48
- if (!linesOf(dir, 'refusals').some((r) => r.id === id)) throw new Error(`no refusal ${id}: \`logs refusals\` lists them`);
85
+ if (crashesOf(dir).some((r) => r.id === id)) throw new Error(`${id} is a crash, which refused nothing: \`logs clear <hook> fixed|superseded "<why>"\` clears it`);
86
+ if (!refusalsOf(dir).some((r) => r.id === id)) throw new Error(`no refusal ${id}: \`logs refusals\` lists them`);
49
87
  mkdirSync(join(dir, DIR), { recursive: true });
50
88
  appendFileSync(join(dir, DIR, 'verdicts.jsonl'), `${JSON.stringify({ id, verdict, why: why.join(' '), t: new Date().toISOString() })}\n`);
51
89
  log(`${id}: ${verdict}`);
52
90
  return 0;
53
91
  }
92
+ if (sub === 'crashes') return listCrashes(dir, log);
93
+ if (sub === 'clear') return clearCrashes(dir, rest, log);
54
94
  if (sub === 'refusals') {
55
95
  const hook = rest.includes('--hook') ? rest[rest.indexOf('--hook') + 1] : null;
56
96
  const verdicts = verdictsOf(dir);
57
- const list = linesOf(dir, 'refusals').filter((r) => (!hook || r.hook === hook) && (!rest.includes('--open') || !verdicts.has(r.id)));
97
+ const list = refusalsOf(dir).filter((r) => (!hook || r.hook === hook) && (!rest.includes('--open') || !verdicts.has(r.id)));
58
98
  for (const r of list) log(`${r.id} ${r.t} ${r.hook} ${verdicts.get(r.id)?.verdict ?? 'not judged'}\n ${r.file ?? r.command ?? r.tool ?? '-'}\n ${(r.reason ?? '').split('\n')[0]}`);
59
99
  log(`${list.length} refusal(s)`);
60
100
  return 0;
package/lib/new.mjs CHANGED
@@ -45,5 +45,5 @@ export async function newProject(parent, name, { packs = ['audio-core'], seedLay
45
45
  log(` git: the scaffold is not committed (${String(e.stderr ?? e.message).trim().split('\n')[0]}); commit it, then work on a work/ branch`);
46
46
  }
47
47
  log(`New project: ${dir}`);
48
- return { dir, code: await doctor(dir, log) };
48
+ return { dir, code: await doctor(dir, log, { installing: true }) };
49
49
  }
package/lib/packs.mjs CHANGED
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
5
5
 
6
6
  export const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'packs');
7
7
  const CLAUDE_DIRS = ['rules', 'checks', 'hooks', 'tools'];
8
+ // A tool's own config, read from the project's root: the project's once written, so a pack's file never replaces it.
9
+ const PROJECT_ROOT_FILES = [['clang-tidy', '.clang-tidy'], ['clippy.toml', 'clippy.toml']];
8
10
 
9
11
  // Packs from a team's own repository (lib/custom-packs.mjs): name -> the folder it was fetched to.
10
12
  const CUSTOM = new Map();
@@ -57,8 +59,7 @@ export function planFiles(name, layout) {
57
59
  const skillFolder = pack.skillsIn && layout.folders[pack.skillsIn];
58
60
  const skillBase = skillFolder ? join(skillFolder.path, '.claude') : '.claude';
59
61
  for (const src of walk(join(root, 'skills'))) if (!off(src)) plan.push({ src, rel: join(skillBase, relative(root, src)) });
60
- const tidy = join(root, 'clang-tidy');
61
- if (existsSync(tidy)) plan.push({ src: tidy, rel: '.clang-tidy', userOwned: true });
62
+ for (const [file, rel] of PROJECT_ROOT_FILES) if (existsSync(join(root, file))) plan.push({ src: join(root, file), rel, userOwned: true });
62
63
  return plan;
63
64
  }
64
65
 
@@ -96,6 +97,7 @@ export const PRESETS = {
96
97
  'python-app': ['python', 'shell', 'docker'],
97
98
  'shell-tools': ['shell'],
98
99
  'go-app': ['go', 'shell', 'docker', 'ci'],
100
+ 'rust-app': ['rust', 'shell', 'docker', 'ci'],
99
101
  };
100
102
 
101
103
  // Base always first, no duplicates, presets expanded, in the order given.
@@ -0,0 +1,42 @@
1
+ // KF-31: a rule's Check can name a test, and an install reads as if the test exists. Three rules of the MPX 500 named tests that were never written, and
2
+ // the gap held four real bugs while every hook, scan and doctor stayed quiet. doctor is red for a live rule (one that matches a file of the project)
3
+ // whose Check names a test nothing answers to. A test answers by naming the rule (`proves: <rule>`, in a test, bench or gate file), or layout.json
4
+ // "ruleTests" points the rule at a file, or declines it with "none: <reason>".
5
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
+ import { basename, join } from 'node:path';
7
+
8
+ const TEST_PLACE = /(^|\/)(tests?|specs?|e2e|bench|benches|benchmarks?|gates?)(\/|$)|(^|[._-])(test|spec)[._-][^/]*$|_test\.[^/]+$/i;
9
+ const MAX_BYTES = 2 * 1024 * 1024;
10
+ const checkOf = (text) => text.split(/^## Check[ \t]*$/m)[1]?.split(/^## /m)[0] ?? '';
11
+
12
+ // "Test:", "Tests:", "- Unit test:", "ThreadSanitizer stress test:", "Benchmark test, offline render:" at the head of a line of the Check.
13
+ export const namesATest = (text) => /^[ \t]*(?:(?:[-*]|\d+\.)[ \t]+)?(?:[\w'-]+[ \t]+){0,3}tests?(?:,[^:\n]*)?:/im.test(checkOf(text));
14
+
15
+ // A file's text, or '' for one too big to be a test or gone since it was listed.
16
+ const smallFileText = (file) => { try { return statSync(file).size > MAX_BYTES ? '' : readFileSync(file, 'utf8'); } catch { return ''; } };
17
+ const inTestFolder = (rel, folders) => TEST_PLACE.test(rel) || folders.some((f) => rel === f || rel.startsWith(`${f}/`));
18
+
19
+ export function ruleTestProblems(projectDir, layout, { liveRules, files }) {
20
+ const dir = join(projectDir, '.claude', 'rules');
21
+ if (!existsSync(dir)) return [];
22
+ const named = readdirSync(dir).filter((f) => liveRules.includes(f) && namesATest(smallFileText(join(dir, f)))).map((f) => basename(f, '.md'));
23
+ if (!named.length) return [];
24
+ const folders = (layout.concepts?.tests ?? []).map((f) => layout.folders?.[f]?.path ?? f).filter((f) => !f.includes('*'));
25
+ const tests = files.filter((f) => inTestFolder(f, folders)).map((f) => smallFileText(join(projectDir, f)));
26
+ const declared = layout.ruleTests ?? {};
27
+ const problems = [];
28
+ const open = named.filter((rule) => {
29
+ const said = declared[rule];
30
+ if (/^none:\s*\S/.test(said ?? '')) return false;
31
+ if (said && said !== 'none') {
32
+ if (!existsSync(join(projectDir, said))) problems.push(`${rule}: ruleTests names ${said}, which does not exist`);
33
+ return false;
34
+ }
35
+ return !tests.some((t) => t.includes(rule));
36
+ });
37
+ if (open.length) {
38
+ const how = 'Write the test its Check names, and put `proves: <rule>` in it. Or set layout.json "ruleTests": { "<rule>": "<path>" }, or "none: <reason>".';
39
+ problems.push(`${open.length} rule(s) name a test nothing in this project answers to: ${open.join(', ')}\n ${how}`);
40
+ }
41
+ return problems;
42
+ }
@@ -14,9 +14,42 @@ function holds(ctx, name, text) {
14
14
  return ctx.files.filter((f) => name.test(f)).slice(0, MOST_READ).find((f) => { try { return text.test(readFileSync(join(ctx.dir, f), 'utf8')); } catch { return false; } });
15
15
  }
16
16
 
17
+ // A project at its start has no source, and only its notes say what it will be (KF-3: a JUCE plugin's first `suggest` said base, with no warning). Once code
18
+ // exists the code decides, and a word in a README adds nothing.
19
+ const NOTES = /(^|\/)(?:README|PROMPT|BRIEF|SPEC|PLAN)[^/]*\.md$|^docs\/(?:[^/]+\/)*[^/]+\.md$/i;
20
+ const NOTED = {
21
+ 'audio-core': /\bJUCE\b|\bVST3?\b|\baudio plug-?in\b/i,
22
+ cpp: /\bC\+\+/,
23
+ midi: /\bMIDI\b/,
24
+ 'sound-design': /\b(?:reverb|synth|synthesi[sz]er|oscillator)\b/i,
25
+ 'instrument-shell': /\b(?:front panel|plug-?in editor)\b/i,
26
+ 'reverse-engineering': /\bfirmware\b|\breverse[- ]engineer/i,
27
+ python: /\bPython\b/,
28
+ rust: /\bRust\b/,
29
+ };
30
+ function noted(ctx, pack) {
31
+ if (!ctx.early || !NOTED[pack]) return null;
32
+ const notes = [...ctx.files, ...ctx.published].filter((f) => NOTES.test(f)).slice(0, MOST_READ);
33
+ const hit = notes.find((f) => { try { return NOTED[pack].test(readFileSync(join(ctx.dir, f), 'utf8')); } catch { return false; } });
34
+ return said(hit, 'named in the notes');
35
+ }
36
+
37
+ // A driver alone says Postgres; a tool that speaks several databases says it only with this second signal in its own file.
38
+ const POSTGRES_FILES = [
39
+ [/(^|\/)(?:requirements[^/]*\.txt|pyproject\.toml|Pipfile)$/, /\b(?:psycopg2?(?:-binary)?|asyncpg)\b/],
40
+ [/(^|\/)go\.mod$/, /jackc\/pgx|lib\/pq\b|gorm\.io\/driver\/postgres/],
41
+ [/(^|\/)Cargo\.toml$/, /\b(?:tokio-postgres|deadpool-postgres)\b|\b(?:sqlx|diesel|sea-orm)\b[^\n]*postgres/],
42
+ [/(^|\/)Gemfile$/, /^\s*gem\s+['"]pg['"]/m],
43
+ [/\.prisma$/, /provider\s*=\s*"postgresql"/],
44
+ [/(^|\/)drizzle\.config\.[cm]?[jt]s$/, /dialect:\s*['"]postgresql['"]/],
45
+ [/(^|\/)[\w.-]*compose[\w.-]*\.ya?ml$/, /image:\s*['"]?[\w./-]*(?:postgres|postgis|pgvector|timescale)/],
46
+ ];
47
+
17
48
  const ROWS = {
18
49
  'js-ts': (c) => said(file(c, /\.(ts|tsx|js|jsx|mjs|cjs)$/), 'JS or TS source'),
19
50
  mongo: (c) => said(dep(c, 'mongodb', 'mongoose'), 'dependency'),
51
+ postgres: (c) => said(dep(c, 'pg', 'postgres', 'pg-promise', '@neondatabase/serverless', '@vercel/postgres'), 'dependency')
52
+ ?? said(POSTGRES_FILES.map(([name, text]) => holds(c, name, text)).find(Boolean), 'Postgres named in'),
20
53
  fastify: (c) => said(dep(c, 'fastify'), 'dependency'),
21
54
  ai: (c) => said(dep(c, '@anthropic-ai/sdk', 'openai', 'ai', '@ai-sdk'), 'dependency'),
22
55
  docker: (c) => said(file(c, /(^|\/)(Dockerfile[^/]*|[\w.-]*compose[\w.-]*\.ya?ml)$/), 'file'),
@@ -30,6 +63,7 @@ const ROWS = {
30
63
  cpp: (c) => said(file(c, /\.(?:cc|cpp|cxx|hh|hpp|mm)$/), 'C++ source'),
31
64
  python: (c) => said(file(c, /\.py$/), 'Python source'),
32
65
  go: (c) => said(file(c, /\.go$/), 'Go source'),
66
+ rust: (c) => said(file(c, /(^|\/)Cargo\.toml$/) ?? file(c, /\.rs$/), 'Rust crate'),
33
67
  midi: (c) => said(file(c, /\.(syx|mid)$|(^|\/)\w*(Midi|MIDI|Sysex|SysEx)\w*\.(h|hpp|cpp|cc)$/), 'MIDI file')
34
68
  ?? said(holds(c, /\.(h|hpp|cpp|cc)$/, /\b(MidiMessage|isController|isNoteOn|getSysExData)\b/), 'MIDI handling'),
35
69
  'sound-design': (c) => said(file(c, /(^|\/)\w*(Voice|Oscillator|Filter|Reverb|Envelope|Lfo|LFO)\w*\.(h|hpp|cpp|cc)$/), 'DSP source'),
@@ -50,15 +84,16 @@ const ROWS = {
50
84
  };
51
85
 
52
86
  // The configuration of the project: a synth; else the language most of its files are in; else base. full-stack is offered, never assumed: it fixes a folder layout.
53
- const LEADS = [['cpp', /\.(?:c|cc|cpp|cxx|h|hh|hpp|mm)$/, 'C++ sources'], ['node', /\.(?:ts|tsx|js|jsx|mjs|cjs)$/, 'JS or TS sources'], ['python', /\.py$/, 'Python sources'], ['go', /\.go$/, 'Go sources']];
87
+ const LEADS = [['cpp', /\.(?:c|cc|cpp|cxx|h|hh|hpp|mm)$/, 'C++ sources'], ['node', /\.(?:ts|tsx|js|jsx|mjs|cjs)$/, 'JS or TS sources'], ['python', /\.py$/, 'Python sources'], ['go', /\.go$/, 'Go sources'], ['rust', /\.rs$|(^|\/)Cargo\.toml$/, 'Rust sources']];
54
88
 
55
89
  function configFor(ctx) {
56
90
  if (ROWS['audio-core'](ctx)) return { name: 'synth-creator', why: 'JUCE sources' };
91
+ if (noted(ctx, 'audio-core')) return { name: 'synth-creator', why: 'named in the notes' };
57
92
  const [lead] = LEADS.map(([name, re, why]) => ({ name, why, n: ctx.files.filter((f) => re.test(f)).length })).sort((a, b) => b.n - a.n);
58
93
  return lead.n ? lead : { name: 'base', why: 'no stack the kit has a configuration for' };
59
94
  }
60
95
 
61
- const INSTALLED = /(^|\/)\.claude\//;
96
+ const INSTALLED = /(^|\/)(\.claude|node_modules)\//;
62
97
 
63
98
  // A project that names its configuration has decided; reading the files again can only disagree with it.
64
99
  function namedConfig(projectDir) {
@@ -78,17 +113,18 @@ export function recommend(projectDir, files) {
78
113
  for (const k of ['dependencies', 'devDependencies', 'peerDependencies']) for (const n of Object.keys(j[k] ?? {})) deps.add(n);
79
114
  } catch { /* a package.json that does not parse says nothing */ }
80
115
  }
81
- const own = files.filter((f) => !INSTALLED.test(f)); // the kit's own files in a project say nothing about the project
116
+ const own = files.filter((f) => !INSTALLED.test(f)); // the kit's own files and installed dependencies say nothing about the project
82
117
  const ctx = { deps, dir: projectDir, files: own.filter((f) => !isContent(f)), published: own.filter(isContent) };
118
+ ctx.early = !LEADS.some(([, re]) => ctx.files.some((f) => re.test(f)));
83
119
  const { name, why } = namedConfig(projectDir) ?? configFor(ctx);
84
120
  const config = readConfig(name);
85
121
  const packs = [];
86
122
  const leftOut = [];
87
123
  for (const pack of config.packs.choose) {
88
- const found = ROWS[pack]?.(ctx);
124
+ const found = ROWS[pack]?.(ctx) ?? noted(ctx, pack);
89
125
  if (found) packs.push({ pack, why: found });
90
126
  else leftOut.push(pack);
91
127
  }
92
128
  const fullStack = dep(ctx, 'next') && dep(ctx, 'fastify');
93
- return { config: name, why, always: config.packs.always, packs, leftOut, fullStack: Boolean(fullStack) };
129
+ return { config: name, why, always: config.packs.always, packs, leftOut, early: ctx.early, fullStack: Boolean(fullStack) };
94
130
  }
package/lib/scan.mjs CHANGED
@@ -20,6 +20,7 @@ import { onceOnlyDoctor } from './once-only-report.mjs';
20
20
  import { PACKS_DIR, availablePacks } from './packs.mjs';
21
21
  import { nearCopies, remadeNames, repeatLine, repeatedShapes } from './repeats.mjs';
22
22
  import { rememberScan } from './fix-plan.mjs';
23
+ import { kitCommand } from './kit-command.mjs';
23
24
  import { repoWeight, weightLines } from './repo-weight.mjs';
24
25
  import { recommend } from './scan-detect.mjs';
25
26
  import { sync } from './sync.mjs';
@@ -102,7 +103,7 @@ function report({ dir, copy, pick, results, secrets, skipped, longFiles, cov, du
102
103
  for (const p of pick.packs) out.push(`- \`${p.pack}\`: ${p.why}`);
103
104
  if (pick.leftOut.length) out.push('', `Left out, nothing found for them: ${pick.leftOut.join(', ')}.`);
104
105
  if (pick.fullStack) out.push('', 'Next.js and Fastify are both here: the `full-stack` configuration may fit, but it fixes a folder layout and has fewer checks. Compare before choosing.');
105
- out.push('', '```', `npx --no-install @thedecipherist/claude-packs init ${pick.packs.map((p) => p.pack).join(' ')} --config ${pick.config}`, '```', '');
106
+ out.push('', '```', `${kitCommand(dir)} init ${pick.packs.map((p) => p.pack).join(' ')} --config ${pick.config}`, '```', '');
106
107
  if (secrets.length) {
107
108
  out.push('## Secrets', '', 'Values are never shown. Secret-shaped text in test files and content folders is usually an example, and is listed apart.', '');
108
109
  for (const s of live) out.push(`- ${s.file}:${s.line}: ${s.name}`);
@@ -126,7 +127,9 @@ function report({ dir, copy, pick, results, secrets, skipped, longFiles, cov, du
126
127
  out.push(...weightLines(weight));
127
128
  out.push(`- ${cov.uncovered.length} source file(s) no rule covers yet; the layout is fitted to the project at install`, '');
128
129
  if (skipped.length) out.push(`Not read: ${skipped.length} file(s), secret files, symlinks or over 1 MB. ${skipped.filter(isSecretFile).length} of them are secret files, left out on purpose.`, '');
129
- out.push('## Next step', '', 'The checks find patterns. For runaway loops, security holes between files and work that disappears silently, run the review on the copy:', '', '```', `cd ${copy}`, 'npx --no-install @thedecipherist/claude-packs scan --prompt # give what it prints to Claude Code here', '```', '', `The copy holds your code: delete ${copy} when you are done.`, '');
130
+ const why = 'The checks find patterns. For runaway loops, security holes between files and work that disappears silently, run the review on the copy:';
131
+ const review = `${kitCommand(dir)} scan --prompt # give what it prints to Claude Code here`;
132
+ out.push('## Next step', '', why, '', '```', `cd ${copy}`, review, '```', '', `The copy holds your code: delete ${copy} when you are done.`, '');
130
133
  return out.join('\n');
131
134
  }
132
135
 
package/lib/suggest.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  // claude-packs suggest: which packs this project needs, read from its own files, so nobody picks them by hand. `init --auto` installs exactly this.
2
- // Fast and read-only: it reads file names and package.json, nothing else. `scan` goes further and runs the checks on a copy.
2
+ // Fast and read-only: it reads file names and dependency files, nothing else. `scan` goes further and runs the checks on a copy.
3
+ import { allowedPacks, readConfig } from './configs.mjs';
4
+ import { kitCommand } from './kit-command.mjs';
5
+ import { normalisePacks } from './packs.mjs';
3
6
  import { filesOf } from './scan.mjs';
4
7
  import { recommend } from './scan-detect.mjs';
5
8
 
@@ -8,11 +11,21 @@ export const suggestionOf = (dir) => recommend(dir, filesOf(dir));
8
11
  export function suggest(dir, { log = console.log } = {}) {
9
12
  const pick = suggestionOf(dir);
10
13
  log(`configuration: ${pick.config} (${pick.why})`);
14
+ if (pick.early) log('no source yet: this comes from the notes alone. Run `suggest` again once code exists, and `add` what it finds.');
11
15
  log(`always: ${pick.always.join(', ')}`);
12
16
  if (pick.packs.length) log('suggested:');
13
17
  for (const p of pick.packs) log(` ${p.pack.padEnd(14)} ${p.why}`);
14
- if (pick.leftOut.length) log(`left out, nothing here needs them: ${pick.leftOut.join(', ')}`);
18
+ if (pick.leftOut.length) log(`${pick.early ? 'not named yet' : 'left out, nothing here needs them'}: ${pick.leftOut.join(', ')}`);
15
19
  if (pick.fullStack) log('this looks like the full-stack configuration: `init --config full-stack` fixes its folder layout, so it is offered and never assumed');
16
- log('\nInstall exactly this: npx --no-install @thedecipherist/claude-packs init --auto');
20
+ log(`\nInstall exactly this: ${kitCommand(dir)} init --auto`);
17
21
  return pick;
18
22
  }
23
+
24
+ // The configuration of an `init <pack>` that names none: the project's own, when it takes those packs. A Node project that asks for one pack must not
25
+ // land in whichever configuration is smallest. With no code yet nothing says what the project is, so the packs decide.
26
+ export function detectedConfig(dir, requested) {
27
+ const pick = suggestionOf(dir);
28
+ if (pick.early) return null;
29
+ const allowed = allowedPacks(readConfig(pick.config));
30
+ return normalisePacks(requested).every((p) => allowed.includes(p)) ? pick.config : null;
31
+ }
package/lib/sync.mjs CHANGED
@@ -10,6 +10,7 @@ import { backup, ensureGitignore } from './backup.mjs';
10
10
  import { writeClaudeMd } from './claude-md.mjs';
11
11
  import { listedPacks, packsFor, resolveConfig } from './configs.mjs';
12
12
  import { buildLayout } from './layout.mjs';
13
+ import { withFoundFolders } from './found-folders.mjs';
13
14
  import { hashFile, isModified, readLock, writeLock } from './manifest.mjs';
14
15
  import { normalisePacks, planFiles } from './packs.mjs';
15
16
  import { applyPaths, renderRule } from './rules.mjs';
@@ -103,7 +104,8 @@ export async function sync(projectDir, requested, { force = false, mkdirs = fals
103
104
  if (hadClaude) log(` backup: ${backup(projectDir)}`);
104
105
  ensureGitignore(projectDir);
105
106
 
106
- const layout = buildLayout(claudeDir, packNames, seedLayout, config);
107
+ const seed = existsSync(join(claudeDir, 'layout.json')) ? seedLayout : withFoundFolders(projectDir, seedLayout, config, log);
108
+ const layout = buildLayout(claudeDir, packNames, seed, config);
107
109
  if (mkdirs) createFolders(projectDir, layout);
108
110
  // New hooks and checks switch on only where the project chose them; the lists are printed first. A configuration's own
109
111
  // packs count as had while the project keeps its configuration (before 0.10.0 their files were base's).
@@ -124,7 +126,8 @@ export async function sync(projectDir, requested, { force = false, mkdirs = fals
124
126
  files[rel] = hashFile(join(projectDir, rel));
125
127
  }
126
128
  const fetched = Object.fromEntries(Object.entries({ ...lock.sources, ...sources }).filter(([name]) => listed.includes(name)));
127
- writeLock(claudeDir, { kit: KIT_VERSION, packs: listed, ...(Object.keys(fetched).length ? { sources: fetched } : {}), updated: new Date().toISOString(), files });
129
+ // packs: what was asked for, which an update reads back; installed: every pack whose files are here, the configuration's own too (KF-4)
130
+ writeLock(claudeDir, { kit: KIT_VERSION, packs: listed, installed: [...new Set([...listed, ...config.packs.always])], ...(Object.keys(fetched).length ? { sources: fetched } : {}), updated: new Date().toISOString(), files });
128
131
  log(`Packs: ${listed.join(', ')}. ${Object.keys(files).length} files managed, ${stale} rule paths refreshed.`);
129
132
  return { packNames: listed, layout, config };
130
133
  }
@@ -0,0 +1,26 @@
1
+ // `claude-packs workorder <name>`: the three files a project needs to build on its own, from what two whole builds taught: the order, the prompt that runs
2
+ // it with nobody there, and the runner that starts session after session. The steps are the author's; the `write-a-workorder` skill says how to write them.
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { kitCommand } from './kit-command.mjs';
7
+
8
+ const TEMPLATES = fileURLToPath(new URL('../prompts/workorder/', import.meta.url));
9
+ const FILES = ['workorder.md', 'PROMPT.md', 'run.sh'];
10
+
11
+ export function newWorkorder(dir, name, { log = console.log } = {}) {
12
+ if (!name || !/^[\w.-]+$/.test(name)) throw new Error(`name the work order: \`${kitCommand(dir)} workorder <name>\``);
13
+ const folder = join(dir, 'docs', 'workorders', name);
14
+ if (existsSync(folder)) throw new Error(`docs/workorders/${name} already exists: nothing was written`);
15
+ mkdirSync(folder, { recursive: true });
16
+ const date = new Date().toISOString().slice(0, 10);
17
+ for (const file of FILES) {
18
+ const text = readFileSync(join(TEMPLATES, file), 'utf8').replaceAll('{{name}}', name).replaceAll('{{date}}', date);
19
+ writeFileSync(join(folder, file), text, { mode: file.endsWith('.sh') ? 0o755 : 0o644 });
20
+ }
21
+ log(`Work order: docs/workorders/${name}/`);
22
+ log(' 1. Write the steps in workorder.md, with Claude if you like: the write-a-workorder skill says how.');
23
+ log(` 2. To watch it, type in Claude Code: Run docs/workorders/${name}/PROMPT.md`);
24
+ log(` 3. To leave it running, read run.sh, then: bash docs/workorders/${name}/run.sh`);
25
+ return 0;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thedecipherist/claude-packs",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Per-project Claude Code rules, lesson checks, hooks and skills that hold code to what a senior engineer would accept, in every language. Stops repetition before it is written, scans a project on a copy, and turns what it finds into a work order.",
5
5
  "type": "module",
6
6
  "bin": {