@phuc1403/musketeer 0.6.0 → 0.7.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 (52) hide show
  1. package/INSTALLATION.md +12 -7
  2. package/manifest.json +8 -10
  3. package/package.json +4 -2
  4. package/src/dotnet-scaffold-copier.js +7 -0
  5. package/src/provisioner/detect.js +3 -23
  6. package/template/.claude/hooks/block-unsafe-adr-title.cjs +40 -27
  7. package/template/.claude/hooks/init-adr-dir.cjs +62 -42
  8. package/template/.claude/hooks/inject-adr-flags.cjs +94 -0
  9. package/template/.claude/hooks/lib/adr/command-scan.cjs +9 -1
  10. package/template/.claude/hooks/sync-adr-toc.cjs +42 -7
  11. package/template/.claude/skills/adr-writer/SKILL.md +48 -46
  12. package/template/.claude/hooks/inject-adr-env.cjs +0 -83
  13. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  14. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfo.cs +0 -22
  15. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfoInputs.cache +0 -1
  16. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GeneratedMSBuildEditorConfig.editorconfig +0 -23
  17. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GlobalUsings.g.cs +0 -17
  18. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  19. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfo.cs +0 -22
  20. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfoInputs.cache +0 -1
  21. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  22. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GlobalUsings.g.cs +0 -8
  23. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  24. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfo.cs +0 -22
  25. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfoInputs.cache +0 -1
  26. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  27. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GlobalUsings.g.cs +0 -8
  28. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  29. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfo.cs +0 -22
  30. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfoInputs.cache +0 -1
  31. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  32. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GlobalUsings.g.cs +0 -8
  33. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  34. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfo.cs +0 -22
  35. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfoInputs.cache +0 -1
  36. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  37. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GlobalUsings.g.cs +0 -9
  38. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  39. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfo.cs +0 -22
  40. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfoInputs.cache +0 -1
  41. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  42. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GlobalUsings.g.cs +0 -9
  43. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  44. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfo.cs +0 -22
  45. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfoInputs.cache +0 -1
  46. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  47. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GlobalUsings.g.cs +0 -9
  48. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +0 -4
  49. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfo.cs +0 -22
  50. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfoInputs.cache +0 -1
  51. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GeneratedMSBuildEditorConfig.editorconfig +0 -17
  52. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GlobalUsings.g.cs +0 -9
package/INSTALLATION.md CHANGED
@@ -14,16 +14,21 @@ an elevation prompt is refused — it falls back to printing the exact manual co
14
14
  | git | core, code-review | `winget install Git.Git` | `brew install git` | `apt/dnf/pacman install git` |
15
15
  | Python 3.8+ | core (skill-creator) | `winget install Python.Python.3.12` | `brew install python@3.12` | `apt install python3 python3-pip` |
16
16
  | Java 8+ | architecture (context-map) | `winget install Microsoft.OpenJDK` | `brew install --cask temurin` | `apt install openjdk-17-jdk` |
17
- | adr-tools | architecture (adr-writer) | download the [release zip](https://github.com/npryce/adr-tools/releases), copy `src/*` into `C:\Program Files\Git\usr\bin` (Git Bash) | `brew install adr-tools` | `apt install adr-tools` · Arch: AUR `adr-tools` · Fedora/RHEL: clone + add `src/` to `PATH` |
17
+ | adr-tools | architecture (adr-writer) | `npm i -g @meza/adr-tools@2` | `npm i -g @meza/adr-tools@2` | `npm i -g @meza/adr-tools@2` |
18
18
  | gh | code-review (PR mode) | `winget install GitHub.cli` | `brew install gh` | `apt install gh` |
19
19
 
20
- > `adr-tools` is a set of bash scripts. On Windows it runs inside Git Bash (already required by
21
- > core), which is where muster now probes for it, so a working install is reported as present.
20
+ > `adr-tools` is [`@meza/adr-tools`](https://www.npmjs.com/package/@meza/adr-tools) the maintained
21
+ > Node port of `npryce/adr-tools`. Same `adr` binary, same `.adr-dir`, same `NNNN-slug.md` files, so
22
+ > existing ADRs carry over untouched. One npm command on every platform: no Git Bash, no admin
23
+ > rights, no release zip.
22
24
  >
23
- > On Windows, keep the project on a path with no spaces. `adr-tools` resolves its ADR directory
24
- > with an unquoted path test, so a space makes it fall back to a `doc/adr` default and write the
25
- > record where nothing indexes it. Musketeer refuses the call rather than let that happen, but the
26
- > underlying tool is still wrong.
25
+ > It is the one prereq that needs **Node >= 22.13.0** (musketeer itself still only needs 18). It is
26
+ > installed as a prereq rather than bundled: it is GPL-3.0 and musketeer is MIT, so musketeer
27
+ > invokes it as a subprocess instead of depending on it.
28
+ >
29
+ > Upgrading from `npryce/adr-tools`? Remove the old scripts first. They install as loose files in
30
+ > `~/bin` or Git's `usr/bin`, and if either is earlier on your `PATH` the old `adr` keeps winning —
31
+ > most visibly inside Git Bash.
27
32
 
28
33
  ### Running muster without a terminal
29
34
 
package/manifest.json CHANGED
@@ -64,7 +64,7 @@
64
64
  "hooks/warn-missing-characteristics.cjs",
65
65
  "hooks/block-unsafe-adr-title.cjs",
66
66
  "hooks/init-adr-dir.cjs",
67
- "hooks/inject-adr-env.cjs"
67
+ "hooks/inject-adr-flags.cjs"
68
68
  ],
69
69
  "settings": [
70
70
  {
@@ -90,9 +90,9 @@
90
90
  {
91
91
  "event": "PreToolUse",
92
92
  "matcher": "Bash",
93
- "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-adr-env.cjs\"",
93
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/inject-adr-flags.cjs\"",
94
94
  "order": 3,
95
- "statusMessage": "Filling in adr new env vars"
95
+ "statusMessage": "Adding -q to adr new"
96
96
  },
97
97
  {
98
98
  "event": "PreToolUse",
@@ -267,15 +267,13 @@
267
267
  "needs": ["java"]
268
268
  },
269
269
  "adr-tools": {
270
- "detect": "adr config",
271
- "detectShell": "bash",
270
+ "detect": "adr --version",
271
+ "minVersion": "2",
272
272
  "kind": "package",
273
- "needs": ["git"],
274
273
  "install": {
275
- "mac": "brew install adr-tools",
276
- "linux": {
277
- "apt": "sudo apt-get install -y adr-tools"
278
- }
274
+ "win": "npm install -g @meza/adr-tools@2",
275
+ "mac": "npm install -g @meza/adr-tools@2",
276
+ "linux": "npm install -g @meza/adr-tools@2"
279
277
  }
280
278
  },
281
279
  "gh": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuc1403/musketeer",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Distributable custom Claude Code harness — one declarative command scaffolds a curated company of musketeers (skills/agents/hooks) into any project's .claude/.",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -19,7 +19,9 @@
19
19
  "template",
20
20
  "README.md",
21
21
  "INSTALLATION.md",
22
- "LICENSE"
22
+ "LICENSE",
23
+ "!template/**/bin",
24
+ "!template/**/obj"
23
25
  ],
24
26
  "keywords": [
25
27
  "claude",
@@ -10,6 +10,12 @@ function containedPath(root, candidate) {
10
10
  return resolved;
11
11
  }
12
12
 
13
+ // Build output directories. These are regenerated by `dotnet build` and must
14
+ // never reach a generated project: they embed absolute paths from whichever
15
+ // machine built the template, and their binaries would be mangled by the
16
+ // UTF-8 read + token substitution below.
17
+ const EXCLUDED_DIRS = new Set(["bin", "obj"]);
18
+
13
19
  function substitute(text, token, replacement) {
14
20
  return text.split(token).join(replacement);
15
21
  }
@@ -31,6 +37,7 @@ function copyTemplateWithTokenSubstitution(srcDir, stagingDir, token, replacemen
31
37
  function walk(currentSrc, currentDest, stagingRoot, token, replacement) {
32
38
  const entries = fs.readdirSync(currentSrc, { withFileTypes: true });
33
39
  for (const entry of entries) {
40
+ if (entry.isDirectory() && EXCLUDED_DIRS.has(entry.name)) continue;
34
41
  const substitutedName = substitute(entry.name, token, replacement);
35
42
  const srcPath = path.join(currentSrc, entry.name);
36
43
  const destPath = containedPath(stagingRoot, path.join(currentDest, substitutedName));
@@ -14,12 +14,7 @@ const SECRET_ENV = { 'anthropic-api-key': 'ANTHROPIC_API_KEY' };
14
14
  * @returns {{code:number, stdout:string, stderr:string}}
15
15
  */
16
16
  function defaultRun(cmd) {
17
- // An array is an argv, spawned without a shell. Windows quoting cannot be
18
- // trusted to survive a nested `bash -lc "..."` inside cmd.exe, so probes that
19
- // need a shell of their own pass their arguments already separated.
20
- const r = Array.isArray(cmd)
21
- ? spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8', windowsHide: true })
22
- : spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true });
17
+ const r = spawnSync(cmd, { shell: true, encoding: 'utf8', windowsHide: true });
23
18
  return {
24
19
  code: typeof r.status === 'number' ? r.status : 1,
25
20
  stdout: r.stdout || '',
@@ -27,19 +22,6 @@ function defaultRun(cmd) {
27
22
  };
28
23
  }
29
24
 
30
- /**
31
- * Probe for a prereq whose manifest entry sets `detectShell: "bash"`.
32
- * adr-tools ships as bash scripts living in Git's own `usr/bin`, which Windows
33
- * cannot see or execute: `adr config` fails there while working fine in Git
34
- * Bash. Probing through the same shell the ADR hooks use stops muster reporting
35
- * a tool that is installed as missing.
36
- * @param {string} detect
37
- * @returns {string[]} argv
38
- */
39
- function bashProbe(detect) {
40
- return [process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash', '-lc', detect];
41
- }
42
-
43
25
  /** Extract a numeric version tuple from arbitrary command output. */
44
26
  function parseVersion(text) {
45
27
  const m = String(text).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
@@ -90,9 +72,7 @@ function detectPrereq(name, prereq, ctx) {
90
72
  default: {
91
73
  // package
92
74
  if (!prereq.detect) return { name, present: false, version: null, reason: 'no detect command' };
93
- let r = run(prereq.detectShell === 'bash' ? bashProbe(prereq.detect) : prereq.detect);
94
- // A shell-script tool invisible to Windows still answers through Git Bash.
95
- if (r.code !== 0 && prereq.detectShell === 'bash') r = run(prereq.detect);
75
+ let r = run(prereq.detect);
96
76
  if (r.code !== 0 && name === 'python') r = run('python3 --version'); // unix fallback
97
77
  const out = r.stdout + r.stderr;
98
78
  const ver = parseVersion(out);
@@ -110,4 +90,4 @@ function detectPrereq(name, prereq, ctx) {
110
90
  }
111
91
  }
112
92
 
113
- module.exports = { detectPrereq, parseVersion, versionGte, defaultRun, bashProbe, SECRET_ENV };
93
+ module.exports = { detectPrereq, parseVersion, versionGte, defaultRun, SECRET_ENV };
@@ -1,22 +1,39 @@
1
1
  #!/usr/bin/env node
2
- // PreToolUse guard: block an `adr new` title that adr-tools' own script cannot
3
- // handle safely.
2
+ // PreToolUse guard: block an `adr new` title the tool cannot render safely.
4
3
  //
5
- // adr-new builds the ADR file with `sed -e "s|TITLE|$title|"`, substituting the
6
- // title in unescaped and using `|` as the delimiter. Two failure modes follow,
7
- // verified directly against /usr/bin/adr-new:
8
- // - `|` in the title breaks sed's own syntax: it exits 1, but only after
9
- // creating a zero-byte ADR file that already burned that number.
10
- // - `&` in a sed replacement means "the whole match" — it does NOT error.
11
- // "Use Kafka & Queues" silently becomes the title "Use Kafka TITLE Queues",
12
- // written straight into the file and the generated index.
13
- // A title starting with `-` is a third case: adr-new parses its own flags with
14
- // getopts, so without a `--` separator the title is read as an unknown option
15
- // and the ADR is silently never created (clean failure, still worth catching
16
- // early with a clearer reason than adr-tools' own "no title given").
4
+ // The tool builds the ADR by running plain JS string replacements over the
5
+ // template, in a fixed order: DATE, TITLE, NUMBER, STATUS. `String.replace`
6
+ // with a string pattern rewrites the FIRST occurrence, and by the time STATUS
7
+ // is substituted the title is already sitting in the document on line 1,
8
+ // ahead of the real `STATUS` placeholder in the `## Status` section.
17
9
  //
18
- // Blocking, not warning: `|` wastes an ADR number and `&` corrupts data with no
19
- // error at all, so there is nothing later in the pipeline that will catch it.
10
+ // So an uppercase STATUS inside the title captures the substitution meant for
11
+ // the status line. Verified against @meza/adr-tools 2.0.4:
12
+ // adr new -q -- "Use STATUS codes for errors"
13
+ // writes `# 1: Use Accepted codes for errors` and leaves the real status as the
14
+ // literal word STATUS — exit 0, no warning, wrong data in the index.
15
+ //
16
+ // Only STATUS is blocked, and only in uppercase:
17
+ // - the replacement is case-sensitive, so "Use status codes" is fine;
18
+ // - DATE is substituted before the title is inserted, so it cannot be caught;
19
+ // - NUMBER and TITLE are substituted at points that precede the title text in
20
+ // the template shipped with this skill, so the real placeholder always wins.
21
+ // That last one holds because of where the tokens sit in
22
+ // `references/adr-template.md` — revisit this guard if that file is ever
23
+ // reordered so `## Status` precedes the heading.
24
+ //
25
+ // A title starting with `-` is the second case: the tool parses its own flags
26
+ // with commander, so without a `--` separator the title is read as an unknown
27
+ // option. That one fails cleanly (exit 1, no file created), but it is still
28
+ // worth catching early with a clearer reason than "unknown option".
29
+ //
30
+ // Blocking, not warning: the STATUS case corrupts data with no error at all, so
31
+ // there is nothing later in the pipeline that will catch it.
32
+ //
33
+ // The `|` and `&` characters used to be blocked here too. Those were hazards of
34
+ // the old bash implementation, which substituted the title into
35
+ // `sed -e "s|TITLE|$title|"`. Substitution is JS now, and both characters were
36
+ // re-verified as harmless, so blocking them would only refuse valid titles.
20
37
 
21
38
  const { invokesAdr } = require("./lib/adr/command-scan.cjs");
22
39
 
@@ -43,18 +60,14 @@ process.stdin.on("end", () => {
43
60
  if (!afterDashDash) {
44
61
  reason =
45
62
  "no `--` before the title (or no quoted title found after it). Without `--`, " +
46
- "adr-new's getopts can read a title starting with `-` as a flag and silently " +
47
- "create nothing. Always: adr new [-s STEM]... -- \"Title\".";
48
- } else if (title.includes("|")) {
49
- reason =
50
- "the title contains `|`. adr-new substitutes it into `sed -e \"s|TITLE|$title|\"`, " +
51
- "using `|` as sed's own delimiter — this breaks sed's syntax and exits non-zero, " +
52
- "but only after creating a zero-byte ADR file that already consumed that number.";
53
- } else if (title.includes("&")) {
63
+ "a title starting with `-` is parsed as an unknown option and no ADR is " +
64
+ 'created. Always: adr new -q [-s STEM]... -- "Title".';
65
+ } else if (title.includes("STATUS")) {
54
66
  reason =
55
- "the title contains `&`. In a sed replacement `&` means \"the whole match\", so " +
56
- "adr-new writes it as literal text into the title (e.g. \"Use Kafka & Queues\" " +
57
- "becomes \"Use Kafka TITLE Queues\") and exits 0 nothing else will catch this.";
67
+ "the title contains `STATUS` in uppercase. The tool substitutes STATUS into the " +
68
+ 'template after the title is already in the document, so "Use STATUS codes" ' +
69
+ 'becomes "Use Accepted codes" and the real Status section is left as the literal ' +
70
+ "word STATUS — it exits 0, so nothing else will catch this. Lowercase `status` is fine.";
58
71
  }
59
72
 
60
73
  if (reason) {
@@ -7,9 +7,13 @@
7
7
  // it creates docs/adr/, writes `.adr-dir`, and adds a baseline ADR.
8
8
  // - migration (numbered ADRs already exist, but no `.adr-dir`): `adr init`
9
9
  // would ALSO add that baseline ADR, burning the next real number. The fix
10
- // is a plain `.adr-dir` file with no tool call: `printf 'docs/adr\n' > .adr-dir`.
10
+ // is a plain `.adr-dir` file with no tool call.
11
11
  // Already initialized: nothing to do, both paths are a no-op.
12
12
  //
13
+ // Also makes sure this skill's ADR template is the one `adr new` picks up, by
14
+ // placing it at `<adr-dir>/templates/template.md` — the third step of the tool's
15
+ // own template resolution, and the reason no env var has to be injected.
16
+ //
13
17
  // Runs the setup itself (as a side effect) before allowing `adr new` through,
14
18
  // and denies a *direct* `adr init` call whenever running it would be wrong —
15
19
  // already initialized, or migration state — since either would burn a number.
@@ -24,6 +28,14 @@ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
24
28
  const ADR_DIR = 'docs/adr';
25
29
  const ADR_DIR_FILE = path.join(root, '.adr-dir');
26
30
  const ADR_FILE = /^\d+-.*\.md$/;
31
+ const SKILL_TEMPLATE = path.join(
32
+ root,
33
+ '.claude',
34
+ 'skills',
35
+ 'adr-writer',
36
+ 'references',
37
+ 'adr-template.md'
38
+ );
27
39
 
28
40
  function allow(message) {
29
41
  if (message) process.stdout.write(message + '\n');
@@ -52,11 +64,7 @@ function readAdrDir() {
52
64
  }
53
65
 
54
66
  function isInitialized() {
55
- try {
56
- return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() !== '';
57
- } catch {
58
- return false;
59
- }
67
+ return readAdrDir() !== null;
60
68
  }
61
69
 
62
70
  function hasNumberedAdrs() {
@@ -68,39 +76,42 @@ function hasNumberedAdrs() {
68
76
  }
69
77
 
70
78
  function runAdrInit() {
71
- const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
72
- return spawnSync(shell, ['-c', `adr init ${ADR_DIR}`], { cwd: root, encoding: 'utf8' });
79
+ // `adr` is an npm bin — a real executable on every platform, so it is called
80
+ // directly rather than through a shell script interpreter. `shell: true` is
81
+ // here only because npm installs it as `adr.cmd` on Windows, which
82
+ // CreateProcess cannot launch on its own; every argument is a constant, so
83
+ // there is nothing for a shell to interpolate.
84
+ return spawnSync('adr', ['init', ADR_DIR], { cwd: root, shell: true, encoding: 'utf8' });
73
85
  }
74
86
 
75
- // Where adr-tools itself thinks the ADRs go. `_adr_dir` is the resolver every
76
- // adr subcommand uses, so asking it directly is the only honest answer.
77
- function resolvedAdrDir() {
78
- const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
79
- const res = spawnSync(shell, ['-c', '_adr_dir'], { cwd: root, encoding: 'utf8' });
80
- if (res.error || res.status !== 0) return null;
81
- const out = (res.stdout || '').trim();
82
- return out || null;
83
- }
84
-
85
- // adr-tools resolves the directory with an unquoted `[` test, so a path holding
86
- // a space splits into too many arguments, the search loop is skipped, and the
87
- // script falls through to its `doc/adr` default. It prints the wrong answer on
88
- // stdout and exits 0, so the ADR lands in a directory nothing else looks at and
89
- // nothing reports a problem. Refuse the call instead of losing the record.
90
- function denyOnDirMismatch() {
87
+ // `adr init` writes `.adr-dir` with `path.relative`, so on Windows it lands as
88
+ // `docs\adr` with no trailing newline. That file is committed and read on every
89
+ // other machine, where a backslash is an ordinary filename character and not a
90
+ // separator. Rewrite it in the portable form the rest of this toolchain emits.
91
+ function normalizeAdrDirFile() {
91
92
  const declared = readAdrDir();
92
- const resolved = resolvedAdrDir();
93
- if (!declared || !resolved || declared === resolved) return;
93
+ if (!declared) return;
94
+ fs.writeFileSync(ADR_DIR_FILE, `${declared.split(path.sep).join('/')}\n`);
95
+ }
94
96
 
95
- deny(
96
- `adr-tools resolves the ADR directory to "${resolved}", but .adr-dir says "${declared}". ` +
97
- `The new ADR would be written to "${resolved}", where the index hook never looks. ` +
98
- (/\s/.test(root)
99
- ? `This project's path contains a space (${root}), which is the known cause: ` +
100
- "adr-tools' `_adr_dir` leaves its path test unquoted. "
101
- : '') +
102
- 'Fix the quoting in adr-tools\' `_adr_dir`, or move the project to a path with no spaces.'
103
- );
97
+ // The tool resolves its template as: explicit argument, then $ADR_TEMPLATE, then
98
+ // `<adr-dir>/templates/template.md`, then its own bundled default. Copying this
99
+ // skill's template into the third slot means a bare `adr new` produces a
100
+ // musketeer ADR with no environment set up for it.
101
+ //
102
+ // Only ever writes when the file is absent, so a project that has customised its
103
+ // own template keeps it.
104
+ function ensureTemplate() {
105
+ try {
106
+ const dir = readAdrDir() || ADR_DIR;
107
+ const target = path.join(root, dir, 'templates', 'template.md');
108
+ if (fs.existsSync(target) || !fs.existsSync(SKILL_TEMPLATE)) return null;
109
+ fs.mkdirSync(path.dirname(target), { recursive: true });
110
+ fs.copyFileSync(SKILL_TEMPLATE, target);
111
+ return `Installed the adr-writer template at ${dir}/templates/template.md.`;
112
+ } catch {
113
+ return null; // never block an ADR over the template copy
114
+ }
104
115
  }
105
116
 
106
117
  function main() {
@@ -117,8 +128,7 @@ function main() {
117
128
  'baseline ADR and burn the next number. Run `adr new` directly.'
118
129
  );
119
130
  }
120
- denyOnDirMismatch(); // exits when adr-tools would write somewhere else
121
- return allow(); // adr new, already set up: nothing to do
131
+ return allow(ensureTemplate()); // adr new, already set up: nothing else to do
122
132
  }
123
133
 
124
134
  if (hasNumberedAdrs()) {
@@ -132,18 +142,28 @@ function main() {
132
142
  );
133
143
  }
134
144
  fs.writeFileSync(ADR_DIR_FILE, `${ADR_DIR}\n`);
135
- denyOnDirMismatch();
136
- return allow(`Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.`);
145
+ const note = ensureTemplate();
146
+ return allow(
147
+ `Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.` +
148
+ (note ? `\n${note}` : '')
149
+ );
137
150
  }
138
151
 
139
152
  // Fresh project.
140
153
  if (isInit) return allow(); // the agent's own `adr init` call is correct here — let it run
141
154
  const res = runAdrInit();
142
155
  if (res.error || res.status !== 0) {
143
- return allow(`Could not run \`adr init ${ADR_DIR}\` (${res.error?.message || res.stderr}); letting the original command run and fail with its own error.`);
156
+ return allow(
157
+ `Could not run \`adr init ${ADR_DIR}\` (${res.error?.message || res.stderr}); ` +
158
+ 'letting the original command run and fail with its own error.'
159
+ );
144
160
  }
145
- denyOnDirMismatch();
146
- return allow(`Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).`);
161
+ normalizeAdrDirFile();
162
+ const note = ensureTemplate();
163
+ return allow(
164
+ `Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).` +
165
+ (note ? `\n${note}` : '')
166
+ );
147
167
  }
148
168
 
149
169
  try {
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: add the one flag every `adr new` call needs, so the agent
3
+ // can write a bare `adr new -- "Title"` and still get a safe command.
4
+ //
5
+ // -q / --quiet — without it, an `-s` pattern matching more than one file
6
+ // opens an interactive prompt on stdin and hangs the session. With it,
7
+ // the ambiguous case throws instead, so the wrong ADR is never superseded
8
+ // by a silent guess.
9
+ //
10
+ // Nothing else is injected. The template no longer needs an env var: `adr`
11
+ // resolves `<adr-dir>/templates/template.md` on its own, and init-adr-dir.cjs
12
+ // puts musketeer's template there. There is no editor to suppress either —
13
+ // `adr` only opens one on an explicit `--open`.
14
+ //
15
+ // Uses PreToolUse's `updatedInput` (not just allow/deny) to rewrite the
16
+ // command before it runs. A `-q` the agent already wrote is left alone, and
17
+ // the call is a silent no-op.
18
+ //
19
+ // Safety: a title that happens to contain the literal text "adr new" could
20
+ // make the matching regex fire inside a quoted string. Guarded by counting
21
+ // quote characters before each match — an odd count means "inside an open
22
+ // string", and that occurrence is left untouched rather than risk corrupting
23
+ // the title.
24
+
25
+ const fs = require('fs');
26
+ const {
27
+ invocationRe,
28
+ isInsideQuotes,
29
+ isInsideHeredoc,
30
+ invokesAdr,
31
+ } = require('./lib/adr/command-scan.cjs');
32
+
33
+ const QUIET = /(?:^|\s)(?:-q|--quiet)(?=\s|$)/;
34
+
35
+ // Everything between `adr new` and either the `--` option terminator or the end
36
+ // of this command segment, whichever comes first.
37
+ //
38
+ // The title lives after `--` and may legitimately contain the text "-q"
39
+ // ("Use -q for quiet builds"). Scanning it would make the hook believe the flag
40
+ // was already supplied and skip an invocation that genuinely needs it, so the
41
+ // title is never part of the region searched.
42
+ function flagsRegion(command, from) {
43
+ const rest = command.slice(from);
44
+ let end = rest.length;
45
+
46
+ const terminator = rest.search(/\s--(\s|$)/);
47
+ if (terminator !== -1) end = terminator;
48
+
49
+ for (let i = 0; i < end; i++) {
50
+ if (';&|\n'.includes(rest[i]) && !isInsideQuotes(command, from + i)) {
51
+ end = i;
52
+ break;
53
+ }
54
+ }
55
+ return rest.slice(0, end);
56
+ }
57
+
58
+ function main() {
59
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
60
+ const command = payload?.tool_input?.command || '';
61
+ if (!invokesAdr(command, 'new')) return;
62
+
63
+ let changed = false;
64
+ const updated = command.replace(invocationRe('new'), (whole, boundary, ws, envPrefix, adrNew, offset) => {
65
+ // Both guards, for the same reason the deny hooks use them: a heredoc body
66
+ // is documentation being written, and injecting a flag into it would
67
+ // rewrite the file's contents rather than the command.
68
+ // `offset` is the boundary character; step past it to the word `adr`.
69
+ const at = offset + boundary.length + ws.length + envPrefix.length;
70
+ if (isInsideQuotes(command, at) || isInsideHeredoc(command, at)) return whole;
71
+ if (QUIET.test(flagsRegion(command, at + adrNew.length))) return whole;
72
+ changed = true;
73
+ return `${boundary}${ws}${envPrefix}${adrNew} -q`;
74
+ });
75
+ if (!changed) return;
76
+
77
+ process.stdout.write(
78
+ JSON.stringify({
79
+ systemMessage: 'Added -q to `adr new` — without it an ambiguous -s prompts on stdin and hangs.',
80
+ hookSpecificOutput: {
81
+ hookEventName: 'PreToolUse',
82
+ permissionDecision: 'allow',
83
+ updatedInput: { command: updated },
84
+ },
85
+ })
86
+ );
87
+ }
88
+
89
+ try {
90
+ main();
91
+ } catch {
92
+ /* fail open: leave the command untouched */
93
+ }
94
+ process.exit(0);
@@ -22,9 +22,17 @@
22
22
  // false one blocks an unrelated command for a reason nobody can guess.
23
23
 
24
24
  // boundary/start, whitespace, any existing KEY=value prefix, then `adr <sub>`.
25
+ //
26
+ // The subcommand must not run straight into a word character or a hyphen. `\b`
27
+ // alone is not enough: it sits happily between the `w` of `new` and the `-` of
28
+ // `adr new-thing`, so that string would count as an `adr new` invocation. A hook
29
+ // that only reads the command could shrug that off, but inject-adr-flags writes
30
+ // the flag directly after the subcommand and would turn it into `adr new -q-thing`.
31
+ // A trailing `;` or `|` still has to count, so this is a negated class rather
32
+ // than a required space.
25
33
  function invocationRe(sub) {
26
34
  return new RegExp(
27
- `(^|[;&|\\n(){}])([ \\t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\\S*)[ \\t]+)*)(adr[ \\t]+${sub}\\b)`,
35
+ `(^|[;&|\\n(){}])([ \\t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\\S*)[ \\t]+)*)(adr[ \\t]+${sub})(?![\\w-])`,
28
36
  'g'
29
37
  );
30
38
  }
@@ -6,6 +6,11 @@
6
6
  // source. That is why there is no need to be clever about which adr subcommand
7
7
  // ran, or to protect the file from being rewritten.
8
8
  //
9
+ // Also deletes the tool's own `decisions.md`. Every `adr new` regenerates that
10
+ // file unconditionally (not just on `adr generate toc`), which would leave two
11
+ // competing indexes side by side in the same directory. README.md is the richer
12
+ // one — it carries a status column — so it is the one kept.
13
+ //
9
14
  // Also runnable directly: node sync-adr-toc.cjs --write
10
15
  //
11
16
  // Fails open at exit 0 on any problem — a hook must never block a session.
@@ -16,7 +21,7 @@ const path = require('path');
16
21
  const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
22
  const ADR_FILE = /^\d+-.*\.md$/;
18
23
 
19
- // adr-tools reads its directory from a committed `.adr-dir` (default `doc/adr`).
24
+ // The tool reads its directory from a committed `.adr-dir` (default `doc/adr`).
20
25
  function adrDir() {
21
26
  try {
22
27
  return fs.readFileSync(path.join(root, '.adr-dir'), 'utf8').trim() || 'doc/adr';
@@ -25,8 +30,8 @@ function adrDir() {
25
30
  }
26
31
  }
27
32
 
28
- // Was this Bash call an adr command? (`VISUAL=true EDITOR=true adr new …` counts;
29
- // the internal `adr-new` script name does not, hence the required whitespace.)
33
+ // Was this Bash call an adr command? (`adr new -q …` counts; a hyphenated name
34
+ // such as `adr-new` does not, hence the required whitespace.)
30
35
  function isAdrCommand() {
31
36
  try {
32
37
  const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
@@ -36,8 +41,9 @@ function isAdrCommand() {
36
41
  }
37
42
  }
38
43
 
39
- // First line, minus the leading "# " and adr-tools' own number prefix — it writes
40
- // "# 12: Use X over Y" for 0012-….md. Dropping the unpadded 12 keeps the padded
44
+ // First line, minus the leading "# " and the tool's own number prefix — the
45
+ // template renders "# 12: Use X over Y" for 0012-….md. Dropping the unpadded 12
46
+ // keeps the padded
41
47
  // stem in the ADR column the only number in the file, so nothing here can be
42
48
  // mistaken for the stem `adr new -s` expects.
43
49
  function title(body, fallback) {
@@ -45,8 +51,8 @@ function title(body, fallback) {
45
51
  return first.replace(/^#\s*(\d+\s*[:.]\s*)?/, '').trim() || fallback;
46
52
  }
47
53
 
48
- // First non-empty line of the `## Status` section. adr-tools maintains this:
49
- // "Accepted", or "Superceded by [4: …](0004-….md)" after `adr new -s`.
54
+ // First non-empty line of the `## Status` section. The tool maintains this:
55
+ // "Accepted", or "Superseded by [4: …](0004-….md)" after `adr new -s`.
50
56
  function status(body) {
51
57
  const lines = body.split(/\r?\n/);
52
58
  const start = lines.findIndex((l) => /^##\s+Status\s*$/i.test(l));
@@ -78,10 +84,39 @@ function render(dir, files) {
78
84
  ].join('\n');
79
85
  }
80
86
 
87
+ // The tool writes its own table of contents to `<adr-dir>/decisions.md` on every
88
+ // `adr new`. README.md supersedes it, so drop it rather than commit two indexes
89
+ // that have to agree with each other.
90
+ //
91
+ // Only ever deletes a file that is recognisably that generated index: the exact
92
+ // heading it writes, followed by nothing but link lines. `decisions.md` is an
93
+ // ordinary name for a hand-written decision log, and deleting one of those on
94
+ // the next `adr new` would destroy work no backup covers.
95
+ const GENERATED_TOC = /^# Table of Contents\r?\n\r?\n(?:- \[[^\]]*\]\([^)]*\)\r?\n?)*$/;
96
+
97
+ function removeRedundantToc(dir) {
98
+ const stale = path.join(root, dir, 'decisions.md');
99
+ try {
100
+ if (!fs.existsSync(stale)) return;
101
+ if (!GENERATED_TOC.test(fs.readFileSync(stale, 'utf8'))) {
102
+ process.stdout.write(
103
+ `Left ${dir}/decisions.md alone — it is not the generated index. ` +
104
+ 'README.md is the one this hook maintains.\n'
105
+ );
106
+ return;
107
+ }
108
+ fs.unlinkSync(stale);
109
+ process.stdout.write(`Removed ${dir}/decisions.md (README.md is the index).\n`);
110
+ } catch {
111
+ /* not worth failing the hook over */
112
+ }
113
+ }
114
+
81
115
  function main() {
82
116
  if (!process.argv.includes('--write') && !isAdrCommand()) return;
83
117
 
84
118
  const dir = adrDir();
119
+ removeRedundantToc(dir);
85
120
  const files = fs
86
121
  .readdirSync(path.join(root, dir))
87
122
  .filter((f) => ADR_FILE.test(f))