@phuc1403/musketeer 0.3.0 → 0.5.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 (114) hide show
  1. package/README.md +49 -49
  2. package/manifest.json +301 -253
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/block-unsafe-adr-title.cjs +70 -0
  5. package/template/.claude/hooks/init-adr-dir.cjs +110 -0
  6. package/template/.claude/hooks/inject-adr-env.cjs +82 -0
  7. package/template/.claude/hooks/lib/characteristics/checker.cjs +357 -0
  8. package/template/.claude/hooks/sync-adr-toc.cjs +111 -0
  9. package/template/.claude/hooks/validate-characteristics-hook.cjs +66 -0
  10. package/template/.claude/hooks/warn-missing-characteristics.cjs +32 -0
  11. package/template/.claude/skills/adr-writer/SKILL.md +24 -101
  12. package/template/.claude/skills/adr-writer/references/adr-example.md +0 -5
  13. package/template/.claude/skills/adr-writer/references/adr-template.md +0 -12
  14. package/template/.claude/skills/architecture-characteristic-writer/SKILL.md +197 -99
  15. package/template/.claude/skills/architecture-characteristic-writer/assets/worksheet-template.md +18 -29
  16. package/template/.claude/skills/architecture-characteristic-writer/references/characteristics-catalog.md +22 -88
  17. package/template/.claude/skills/architecture-characteristic-writer/scripts/ranking-table.cjs +171 -0
  18. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  19. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfo.cs +22 -0
  20. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfoInputs.cache +1 -0
  21. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GeneratedMSBuildEditorConfig.editorconfig +23 -0
  22. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.GlobalUsings.g.cs +17 -0
  23. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.assets.cache +0 -0
  24. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.dgspec.json +1536 -0
  25. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.g.props +16 -0
  26. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/__SolutionName__.Api.csproj.nuget.g.targets +2 -0
  27. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/project.assets.json +559 -0
  28. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/project.nuget.cache +8 -0
  29. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  30. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfo.cs +22 -0
  31. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfoInputs.cache +1 -0
  32. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  33. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.GlobalUsings.g.cs +8 -0
  34. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.assets.cache +0 -0
  35. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.dgspec.json +690 -0
  36. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.g.props +16 -0
  37. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/__SolutionName__.Application.csproj.nuget.g.targets +2 -0
  38. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/project.assets.json +376 -0
  39. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/project.nuget.cache +8 -0
  40. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  41. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfo.cs +22 -0
  42. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfoInputs.cache +1 -0
  43. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  44. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.GlobalUsings.g.cs +8 -0
  45. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.assets.cache +0 -0
  46. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.dgspec.json +347 -0
  47. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.g.props +16 -0
  48. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/__SolutionName__.Domain.csproj.nuget.g.targets +2 -0
  49. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/project.assets.json +353 -0
  50. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/project.nuget.cache +8 -0
  51. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  52. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfo.cs +22 -0
  53. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfoInputs.cache +1 -0
  54. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  55. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.GlobalUsings.g.cs +8 -0
  56. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.assets.cache +0 -0
  57. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.csproj.AssemblyReference.cache +0 -0
  58. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.dgspec.json +1047 -0
  59. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.g.props +16 -0
  60. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/__SolutionName__.Infrastructure.csproj.nuget.g.targets +7 -0
  61. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/project.assets.json +858 -0
  62. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/project.nuget.cache +17 -0
  63. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Api.Tests +0 -0
  64. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  65. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfo.cs +22 -0
  66. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfoInputs.cache +1 -0
  67. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  68. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.GlobalUsings.g.cs +9 -0
  69. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.assets.cache +0 -0
  70. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.csproj.AssemblyReference.cache +0 -0
  71. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.dgspec.json +1908 -0
  72. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.g.props +27 -0
  73. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/__SolutionName__.Api.Tests.csproj.nuget.g.targets +16 -0
  74. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/project.assets.json +3283 -0
  75. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/project.nuget.cache +60 -0
  76. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Application.Tests +0 -0
  77. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  78. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfo.cs +22 -0
  79. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfoInputs.cache +1 -0
  80. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  81. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.GlobalUsings.g.cs +9 -0
  82. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.assets.cache +0 -0
  83. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.csproj.AssemblyReference.cache +0 -0
  84. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.dgspec.json +1055 -0
  85. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.g.props +26 -0
  86. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/__SolutionName__.Application.Tests.csproj.nuget.g.targets +11 -0
  87. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/project.assets.json +1662 -0
  88. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/project.nuget.cache +30 -0
  89. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Domain.Tests +0 -0
  90. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  91. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfo.cs +22 -0
  92. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfoInputs.cache +1 -0
  93. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  94. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.GlobalUsings.g.cs +9 -0
  95. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.assets.cache +0 -0
  96. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.csproj.AssemblyReference.cache +0 -0
  97. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.dgspec.json +712 -0
  98. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.g.props +26 -0
  99. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/__SolutionName__.Domain.Tests.csproj.nuget.g.targets +11 -0
  100. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/project.assets.json +1644 -0
  101. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/project.nuget.cache +30 -0
  102. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/bin/Debug/net10.0/.msCoverageSourceRootsMapping___SolutionName__.Infrastructure.Tests +0 -0
  103. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs +4 -0
  104. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfo.cs +22 -0
  105. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfoInputs.cache +1 -0
  106. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GeneratedMSBuildEditorConfig.editorconfig +17 -0
  107. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.GlobalUsings.g.cs +9 -0
  108. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.assets.cache +0 -0
  109. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.csproj.AssemblyReference.cache +0 -0
  110. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.dgspec.json +1412 -0
  111. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.g.props +26 -0
  112. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/__SolutionName__.Infrastructure.Tests.csproj.nuget.g.targets +13 -0
  113. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/project.assets.json +2130 -0
  114. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/project.nuget.cache +38 -0
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: set up `.adr-dir` before the first `adr new` in a project,
3
+ // so the agent never has to pick between `adr init` and a hand-written file.
4
+ //
5
+ // Two setup paths, and picking the wrong one is destructive:
6
+ // - fresh (no `.adr-dir`, no numbered ADRs yet): `adr init docs/adr` is safe —
7
+ // it creates docs/adr/, writes `.adr-dir`, and adds a baseline ADR.
8
+ // - migration (numbered ADRs already exist, but no `.adr-dir`): `adr init`
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`.
11
+ // Already initialized: nothing to do, both paths are a no-op.
12
+ //
13
+ // Runs the setup itself (as a side effect) before allowing `adr new` through,
14
+ // and denies a *direct* `adr init` call whenever running it would be wrong —
15
+ // already initialized, or migration state — since either would burn a number.
16
+ // Anything it does is echoed to stdout so it stays visible in the transcript.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { spawnSync } = require('child_process');
21
+
22
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
23
+ const ADR_DIR = 'docs/adr';
24
+ const ADR_DIR_FILE = path.join(root, '.adr-dir');
25
+ const ADR_FILE = /^\d+-.*\.md$/;
26
+
27
+ function allow(message) {
28
+ if (message) process.stdout.write(message + '\n');
29
+ process.exit(0);
30
+ }
31
+
32
+ function deny(reason) {
33
+ process.stdout.write(
34
+ JSON.stringify({
35
+ hookSpecificOutput: {
36
+ hookEventName: 'PreToolUse',
37
+ permissionDecision: 'deny',
38
+ permissionDecisionReason: reason,
39
+ },
40
+ })
41
+ );
42
+ process.exit(0);
43
+ }
44
+
45
+ function isInitialized() {
46
+ try {
47
+ return fs.readFileSync(ADR_DIR_FILE, 'utf8').trim() !== '';
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function hasNumberedAdrs() {
54
+ try {
55
+ return fs.readdirSync(path.join(root, ADR_DIR)).some((f) => ADR_FILE.test(f));
56
+ } catch {
57
+ return false; // directory doesn't exist yet -> nothing to migrate
58
+ }
59
+ }
60
+
61
+ function runAdrInit() {
62
+ const shell = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'bash';
63
+ return spawnSync(shell, ['-c', `adr init ${ADR_DIR}`], { cwd: root, encoding: 'utf8' });
64
+ }
65
+
66
+ function main() {
67
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
68
+ const command = payload?.tool_input?.command || '';
69
+ const isInit = /\badr\s+init\b/.test(command);
70
+ const isNew = /\badr\s+new\b/.test(command);
71
+ if (!isInit && !isNew) return allow();
72
+
73
+ if (isInitialized()) {
74
+ if (isInit) {
75
+ return deny(
76
+ '.adr-dir already exists — do not run `adr init` again, it would add a duplicate ' +
77
+ 'baseline ADR and burn the next number. Run `adr new` directly.'
78
+ );
79
+ }
80
+ return allow(); // adr new, already set up: nothing to do
81
+ }
82
+
83
+ if (hasNumberedAdrs()) {
84
+ // Migration: numbered ADRs exist but `.adr-dir` does not. `adr init` would
85
+ // still add a baseline ADR here, so it is never the right command.
86
+ if (isInit) {
87
+ return deny(
88
+ 'Numbered ADRs already exist without `.adr-dir` — this is a migration, not a fresh ' +
89
+ 'project. `adr init` would add a duplicate baseline ADR and burn the next number. ' +
90
+ 'Run `adr new` directly; `.adr-dir` is set up automatically.'
91
+ );
92
+ }
93
+ fs.writeFileSync(ADR_DIR_FILE, `${ADR_DIR}\n`);
94
+ return allow(`Migration detected: wrote .adr-dir (${ADR_DIR}) by hand, no baseline ADR added.`);
95
+ }
96
+
97
+ // Fresh project.
98
+ if (isInit) return allow(); // the agent's own `adr init` call is correct here — let it run
99
+ const res = runAdrInit();
100
+ if (res.error || res.status !== 0) {
101
+ 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.`);
102
+ }
103
+ return allow(`Fresh project: ran \`adr init ${ADR_DIR}\` (creates the baseline ADR).`);
104
+ }
105
+
106
+ try {
107
+ main();
108
+ } catch {
109
+ allow(); // fail open
110
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse guard: inject the env vars every `adr new` call needs, so the
3
+ // agent can write a bare `adr new -- "Title"` and get a correct command.
4
+ //
5
+ // VISUAL=true EDITOR=true — without them an ambient EDITOR opens an
6
+ // interactive editor and hangs the session (adr-new always shells out to
7
+ // $VISUAL/$EDITOR to "edit" the new file).
8
+ // ADR_TEMPLATE=...adr-template.md — points adr-new at this skill's template
9
+ // instead of adr-tools' own bundled default.
10
+ //
11
+ // Uses PreToolUse's `updatedInput` (not just allow/deny) to rewrite the
12
+ // command before it runs. Only fills in vars that are missing — an assignment
13
+ // the agent already wrote (any value, even a deliberately different template)
14
+ // is left alone. Silent no-op if all three are already present.
15
+ //
16
+ // Safety: a title that happens to contain the literal text "adr new" could
17
+ // make the matching regex fire inside a quoted string. Guarded by counting
18
+ // quote characters before each match — an odd count means "inside an open
19
+ // string", and that occurrence is left untouched rather than risk corrupting
20
+ // the title.
21
+
22
+ const fs = require('fs');
23
+
24
+ const DEFAULTS = [
25
+ ['ADR_TEMPLATE', '.claude/skills/adr-writer/references/adr-template.md'],
26
+ ['VISUAL', 'true'],
27
+ ['EDITOR', 'true'],
28
+ ];
29
+
30
+ // boundary/start, leading whitespace, any existing KEY=value prefix, `adr new`.
31
+ const ADR_NEW_RE = /(^|[;&|\n])([ \t]*)((?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)[ \t]+)*)(adr[ \t]+new\b)/g;
32
+
33
+ function existingVars(prefix) {
34
+ const names = new Set();
35
+ const re = /([A-Za-z_][A-Za-z0-9_]*)=/g;
36
+ let m;
37
+ while ((m = re.exec(prefix))) names.add(m[1]);
38
+ return names;
39
+ }
40
+
41
+ function isInsideQuotes(command, index) {
42
+ const before = command.slice(0, index);
43
+ const dq = (before.match(/"/g) || []).length;
44
+ const sq = (before.match(/'/g) || []).length;
45
+ return dq % 2 === 1 || sq % 2 === 1;
46
+ }
47
+
48
+ function main() {
49
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
50
+ const command = payload?.tool_input?.command || '';
51
+ if (!/\badr\s+new\b/.test(command)) return;
52
+
53
+ let changed = false;
54
+ const updated = command.replace(ADR_NEW_RE, (whole, boundary, ws, existingPrefix, adrNew, offset) => {
55
+ if (isInsideQuotes(command, offset)) return whole;
56
+ const present = existingVars(existingPrefix);
57
+ const missing = DEFAULTS.filter(([name]) => !present.has(name));
58
+ if (missing.length === 0) return whole;
59
+ changed = true;
60
+ const injected = missing.map(([name, value]) => `${name}=${value}`).join(' ');
61
+ return `${boundary}${ws}${injected} ${existingPrefix}${adrNew}`;
62
+ });
63
+ if (!changed) return;
64
+
65
+ process.stdout.write(
66
+ JSON.stringify({
67
+ systemMessage: 'Added missing VISUAL/EDITOR/ADR_TEMPLATE env vars before `adr new`.',
68
+ hookSpecificOutput: {
69
+ hookEventName: 'PreToolUse',
70
+ permissionDecision: 'allow',
71
+ updatedInput: { command: updated },
72
+ },
73
+ })
74
+ );
75
+ }
76
+
77
+ try {
78
+ main();
79
+ } catch {
80
+ /* fail open: leave the command untouched */
81
+ }
82
+ process.exit(0);
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+
3
+ // Structural checker for the two architecture-characteristic documents.
4
+ //
5
+ // Checks only what is mechanically decidable. The ranking ORDER is a judgement
6
+ // call and is never questioned here; what is checked is that the table is
7
+ // complete, that every comparative reason names the row it actually sits above,
8
+ // and that the worksheet is a faithful derivation of the ranking's top 7.
9
+ //
10
+ // The catalog markdown is the single source of truth for which characteristics
11
+ // exist, which of them are implicit, and how the composites decompose. Nothing
12
+ // here restates it — edit the markdown and this checker follows.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const DEFAULT_CATALOG_PATH = path.join(
18
+ __dirname,
19
+ '..',
20
+ '..',
21
+ '..',
22
+ 'skills',
23
+ 'architecture-characteristic-writer',
24
+ 'references',
25
+ 'characteristics-catalog.md'
26
+ );
27
+
28
+ const DRIVING_LIMIT = 7;
29
+
30
+ // The ranking table is found by its header row rather than by the heading above
31
+ // it. A heading is prose the author may reword or duplicate; this signature is
32
+ // the table itself. Both the checker and scripts/ranking-table.cjs locate the
33
+ // table this way, so neither can end up working on a different table.
34
+ const RANKING_HEADER = /^\|\s*order\s*\|\s*characteristic\s*\|\s*reason\s*\|/i;
35
+
36
+ // "feasibility (cost/time)" and "**feasibility**" both normalise to "feasibility"
37
+ // so the checker never argues with formatting.
38
+ function normalise(cell) {
39
+ return String(cell)
40
+ .replace(/`([^`]*)`/g, '$1')
41
+ .replace(/\*\*/g, '')
42
+ .replace(/\([^)]*\)/g, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .toLowerCase();
46
+ }
47
+
48
+ const isPlaceholder = (row) => row.some((c) => /\{[a-z0-9_]+\}/i.test(c)) || row.some((c) => c === '...');
49
+
50
+ const splitCells = (line) =>
51
+ line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
52
+
53
+ const isSeparator = (cells) => cells.every((c) => /^:?-+:?$/.test(c) || c === '');
54
+
55
+ // Returns the data rows of the first pipe table under a heading matching `re`,
56
+ // or of the whole document when `re` is null. Header and separator are dropped.
57
+ function parseTable(markdown, re) {
58
+ const lines = markdown.split(/\r?\n/);
59
+ let start = 0;
60
+
61
+ if (re) {
62
+ const i = lines.findIndex((l) => /^#{1,6}\s/.test(l) && re.test(l));
63
+ if (i === -1) return null;
64
+ start = i + 1;
65
+ const next = lines.slice(start).findIndex((l) => /^#{1,6}\s/.test(l));
66
+ if (next !== -1) lines.length = start + next;
67
+ }
68
+
69
+ const rows = [];
70
+ let seenHeader = false;
71
+
72
+ for (const line of lines.slice(start)) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed.startsWith('|')) {
75
+ if (seenHeader && rows.length) break;
76
+ continue;
77
+ }
78
+ const cells = splitCells(trimmed);
79
+ if (!seenHeader) {
80
+ seenHeader = true;
81
+ continue;
82
+ }
83
+ if (isSeparator(cells)) continue;
84
+ rows.push(cells);
85
+ }
86
+
87
+ return seenHeader ? rows : null;
88
+ }
89
+
90
+ // Line bounds of the table whose header matches `headerRe`, covering the header
91
+ // through the last consecutive pipe line. Returns null when there is no match.
92
+ function locateTable(lines, headerRe) {
93
+ for (let i = 0; i < lines.length; i += 1) {
94
+ if (!lines[i].trim().startsWith('|') || !headerRe.test(lines[i].trim())) continue;
95
+ let end = i + 1;
96
+ while (end < lines.length && lines[end].trim().startsWith('|')) end += 1;
97
+ return { start: i, end };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // Data rows of the table located by `headerRe`, header and separator dropped.
103
+ function tableRows(markdown, headerRe) {
104
+ const lines = markdown.split(/\r?\n/);
105
+ const at = locateTable(lines, headerRe);
106
+ if (!at) return null;
107
+ return lines
108
+ .slice(at.start + 1, at.end)
109
+ .map(splitCells)
110
+ .filter((c) => !isSeparator(c));
111
+ }
112
+
113
+ // Reads the catalog markdown and derives every set the checker needs from it.
114
+ // Throws when the file is absent or a section is unreadable; the caller decides
115
+ // whether that is fatal.
116
+ function loadCatalog(catalogPath = DEFAULT_CATALOG_PATH) {
117
+ const markdown = fs.readFileSync(catalogPath, 'utf8');
118
+
119
+ const names = (re) => {
120
+ const rows = parseTable(markdown, re);
121
+ if (!rows) throw new Error(`no table found under a heading matching ${re}`);
122
+ return rows.filter((r) => r.length && !isPlaceholder(r)).map((r) => normalise(r[0]));
123
+ };
124
+
125
+ const common = names(/common/i);
126
+ const implicit = names(/implicit/i);
127
+
128
+ const compositeRows = parseTable(markdown, /composite/i);
129
+ if (!compositeRows) throw new Error('no Composite Architecture Characteristics table found');
130
+
131
+ const composites = {};
132
+ for (const row of compositeRows.filter((r) => r.length >= 2 && !isPlaceholder(r))) {
133
+ composites[normalise(row[0])] = row[1].split('+').map(normalise).filter(Boolean);
134
+ }
135
+
136
+ if (!common.length || !implicit.length) throw new Error('the Common or Implicit section is empty');
137
+
138
+ return { catalog: [...common, ...implicit], implicit, composites };
139
+ }
140
+
141
+ function checkRanking(markdown, { catalog }) {
142
+ const errors = [];
143
+ const found = tableRows(markdown, RANKING_HEADER);
144
+ if (!found) {
145
+ return {
146
+ errors: ['ranking: no table with a "| Order | Characteristic | Reason |" header row'],
147
+ order: [],
148
+ };
149
+ }
150
+ const raw = found.filter((r) => r.length >= 3);
151
+ const unfilled = raw.filter(isPlaceholder);
152
+ const rows = raw.filter((r) => !isPlaceholder(r));
153
+
154
+ // A scaffold is a blank form, not a broken document: every reason is still a
155
+ // placeholder, bar the last row whose reason is only the em dash.
156
+ const hasContent = (r) => !isPlaceholder(r) && !/^[—-]$/.test(String(r[2]).trim());
157
+ if (raw.length && !raw.some(hasContent)) return { errors: [], order: [] };
158
+ if (!rows.length) return { errors: ['ranking: no table rows found'], order: [] };
159
+
160
+ // Half-filled, so adjacency cannot be judged: a filled row's neighbour may be
161
+ // one of the rows still holding a placeholder. Ask for the rest first.
162
+ if (unfilled.length) {
163
+ return {
164
+ errors: [`ranking: ${unfilled.length} row(s) still hold the {why} placeholder, fill them in`],
165
+ order: [],
166
+ };
167
+ }
168
+
169
+ const order = rows.map((r) => normalise(r[1]));
170
+
171
+ // Rule 1 — the Order column counts 1..N with no gaps and no repeats.
172
+ rows.forEach((r, i) => {
173
+ const n = Number(String(r[0]).trim());
174
+ if (n !== i + 1) errors.push(`ranking row ${i + 1}: Order column reads "${r[0]}", expected ${i + 1}`);
175
+ });
176
+
177
+ // Rule 2 — the ranked set is exactly the catalog. Nothing missing, nothing
178
+ // invented, nothing ranked twice.
179
+ const seen = new Set();
180
+ order.forEach((name, i) => {
181
+ if (!catalog.includes(name)) errors.push(`ranking row ${i + 1}: "${name}" is not in the catalog`);
182
+ if (seen.has(name)) errors.push(`ranking row ${i + 1}: "${name}" is ranked more than once`);
183
+ seen.add(name);
184
+ });
185
+ for (const name of catalog) {
186
+ if (!seen.has(name)) errors.push(`ranking: "${name}" is missing`);
187
+ }
188
+
189
+ // Rules 3 and 4 — every reason names the row it actually sits above. This is
190
+ // what goes stale after a reorder, so it is checked by string, not by eye.
191
+ rows.forEach((r, i) => {
192
+ const reason = String(r[2]).trim();
193
+ const last = i === rows.length - 1;
194
+
195
+ if (last) {
196
+ if (!/^[—-]$/.test(reason)) {
197
+ errors.push(`ranking row ${i + 1}: last row has nothing below it, its Reason must be "—"`);
198
+ }
199
+ return;
200
+ }
201
+
202
+ const below = order[i + 1];
203
+ const m = reason.match(/^above\s+([^:]+):\s*(.*)$/i);
204
+
205
+ if (!m) {
206
+ errors.push(`ranking row ${i + 1} (${order[i]}): Reason must start with "Above ${below}:"`);
207
+ return;
208
+ }
209
+ if (normalise(m[1]) !== below) {
210
+ errors.push(
211
+ `ranking row ${i + 1} (${order[i]}): Reason compares against "${m[1].trim()}" but the row below is "${below}"`
212
+ );
213
+ }
214
+ if (m[2].trim().length < 15) {
215
+ errors.push(`ranking row ${i + 1} (${order[i]}): Reason has a prefix but no justification after it`);
216
+ }
217
+ });
218
+
219
+ return { errors, order };
220
+ }
221
+
222
+ function checkWorksheet(markdown, order, { implicit, composites }) {
223
+ const errors = [];
224
+ const driving = (parseTable(markdown, /driving/i) || []).filter((r) => r.length >= 2 && !isPlaceholder(r));
225
+ const implicitRows = (parseTable(markdown, /implicit/i) || []).filter(
226
+ (r) => r.length >= 1 && !isPlaceholder(r)
227
+ );
228
+
229
+ if (!driving.length) return ['worksheet: no Driving Characteristics rows found'];
230
+
231
+ const names = driving.map((r) => normalise(r[1] !== undefined && r.length >= 3 ? r[1] : r[0]));
232
+
233
+ // Rule 5 — no backfill. Composing shrinks the section; it never refills.
234
+ if (names.length > DRIVING_LIMIT) {
235
+ errors.push(`worksheet: ${names.length} driving characteristics, maximum is ${DRIVING_LIMIT}`);
236
+ }
237
+
238
+ // Listed twice, so the count is a lie and the top 7 cannot all be present.
239
+ // Said plainly here, or it surfaces later as a confusing pile of "missing".
240
+ const twice = new Set();
241
+ names.forEach((name, i) => {
242
+ if (names.indexOf(name) !== i) twice.add(name);
243
+ });
244
+ for (const name of twice) errors.push(`worksheet: "${name}" is listed more than once`);
245
+
246
+ // Without a readable ranking the remaining rules have nothing to derive from.
247
+ if (!order.length) return errors;
248
+
249
+ const top = order.slice(0, DRIVING_LIMIT);
250
+ const absorbed = new Set();
251
+
252
+ for (const name of names) {
253
+ const parts = composites[name];
254
+
255
+ if (parts) {
256
+ // Rule 7 — a composite stands only when the whole of it ranked top 7.
257
+ const missing = parts.filter((p) => !top.includes(p));
258
+ if (missing.length) {
259
+ errors.push(
260
+ `worksheet: "${name}" needs all of [${parts.join(', ')}] in the top 7, missing [${missing.join(', ')}]`
261
+ );
262
+ }
263
+ parts.forEach((p) => absorbed.add(p));
264
+
265
+ // Rule 8 — a composite and its own parts must never be listed side by side.
266
+ const both = parts.filter((p) => names.includes(p));
267
+ if (both.length) {
268
+ errors.push(`worksheet: "${name}" is listed alongside its own component [${both.join(', ')}]`);
269
+ }
270
+ continue;
271
+ }
272
+
273
+ // Rule 6 — everything driving traces back to the top 7.
274
+ if (!top.includes(name)) {
275
+ const rank = order.indexOf(name);
276
+ errors.push(
277
+ rank === -1
278
+ ? `worksheet: "${name}" is not in the ranking at all`
279
+ : `worksheet: "${name}" is ranked ${rank + 1}, outside the top 7`
280
+ );
281
+ }
282
+ }
283
+
284
+ const present = new Set([...names, ...absorbed]);
285
+
286
+ // Rule 6b — the top 7 is taken whole. Every one of them is either listed or
287
+ // swallowed by a composite; none may be quietly dropped.
288
+ for (const name of top) {
289
+ if (!present.has(name)) {
290
+ errors.push(`worksheet: "${name}" is ranked ${order.indexOf(name) + 1} but is missing from the worksheet`);
291
+ }
292
+ }
293
+
294
+ // Rule 9 — the implicit set is a pure derivation: the catalog's implicit
295
+ // characteristics, less any that reached the top 7, counting those a
296
+ // composite swallowed.
297
+ const expected = implicit.filter((c) => !present.has(c));
298
+ const actual = implicitRows.map((r) => normalise(r[0])).filter((c) => implicit.includes(c));
299
+
300
+ for (const c of expected) {
301
+ if (!actual.includes(c)) errors.push(`worksheet: implicit characteristic "${c}" is missing`);
302
+ }
303
+ for (const c of actual) {
304
+ if (!expected.includes(c)) {
305
+ errors.push(`worksheet: "${c}" is in the top 7, so it must not be listed as implicit`);
306
+ }
307
+ }
308
+
309
+ return errors;
310
+ }
311
+
312
+ // ranking and worksheet are file contents; either may be null when absent.
313
+ // The catalog sets default to the shipped markdown; pass `catalog` to inject.
314
+ function check({ ranking = null, worksheet = null, catalog = null, catalogPath } = {}) {
315
+ let sets = catalog;
316
+
317
+ if (!sets) {
318
+ try {
319
+ sets = loadCatalog(catalogPath);
320
+ } catch (err) {
321
+ return [`cannot read the characteristics catalog, so nothing can be checked: ${err.message}`];
322
+ }
323
+ }
324
+
325
+ let errors = [];
326
+ let order = [];
327
+
328
+ if (ranking !== null) {
329
+ const res = checkRanking(ranking, sets);
330
+ errors = errors.concat(res.errors);
331
+ order = res.order;
332
+ }
333
+
334
+ if (worksheet !== null) {
335
+ if (ranking === null) {
336
+ errors.push('worksheet: no ranking file to derive from, write the ranking first');
337
+ } else {
338
+ errors = errors.concat(checkWorksheet(worksheet, order, sets));
339
+ }
340
+ }
341
+
342
+ return errors;
343
+ }
344
+
345
+ module.exports = {
346
+ check,
347
+ checkRanking,
348
+ checkWorksheet,
349
+ parseTable,
350
+ locateTable,
351
+ splitCells,
352
+ isSeparator,
353
+ normalise,
354
+ loadCatalog,
355
+ RANKING_HEADER,
356
+ DEFAULT_CATALOG_PATH,
357
+ };
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse hook: after any `adr` command, rebuild docs/adr/README.md from the
3
+ // ADR files themselves — one row per ADR with its title and current status.
4
+ //
5
+ // The index is fully derived, so it can never drift: every run re-reads the
6
+ // source. That is why there is no need to be clever about which adr subcommand
7
+ // ran, or to protect the file from being rewritten.
8
+ //
9
+ // Also runnable directly: node sync-adr-toc.cjs --write
10
+ //
11
+ // Fails open at exit 0 on any problem — a hook must never block a session.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
+ const ADR_FILE = /^\d+-.*\.md$/;
18
+
19
+ // adr-tools reads its directory from a committed `.adr-dir` (default `doc/adr`).
20
+ function adrDir() {
21
+ try {
22
+ return fs.readFileSync(path.join(root, '.adr-dir'), 'utf8').trim() || 'doc/adr';
23
+ } catch {
24
+ return 'doc/adr';
25
+ }
26
+ }
27
+
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.)
30
+ function isAdrCommand() {
31
+ try {
32
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
33
+ return /\badr\s/.test(payload?.tool_input?.command || '');
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
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
41
+ // stem in the ADR column the only number in the file, so nothing here can be
42
+ // mistaken for the stem `adr new -s` expects.
43
+ function title(body, fallback) {
44
+ const first = body.split(/\r?\n/)[0] || '';
45
+ return first.replace(/^#\s*(\d+\s*[:.]\s*)?/, '').trim() || fallback;
46
+ }
47
+
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`.
50
+ function status(body) {
51
+ const lines = body.split(/\r?\n/);
52
+ const start = lines.findIndex((l) => /^##\s+Status\s*$/i.test(l));
53
+ if (start === -1) return 'Unknown';
54
+ for (const line of lines.slice(start + 1)) {
55
+ if (/^##\s/.test(line)) break;
56
+ if (line.trim()) return line.trim();
57
+ }
58
+ return 'Unknown';
59
+ }
60
+
61
+ function render(dir, files) {
62
+ const rows = files.map((name) => {
63
+ const body = fs.readFileSync(path.join(root, dir, name), 'utf8');
64
+ const stem = name.replace(/\.md$/, ''); // padded — what `adr new -s` needs
65
+ const cell = (s) => s.replace(/\|/g, '\\|'); // never break the table
66
+ return `| [${stem}](./${name}) | ${cell(title(body, stem))} | ${cell(status(body))} |`;
67
+ });
68
+
69
+ return [
70
+ '# Architecture Decision Records',
71
+ '',
72
+ 'Generated from the ADR files by the `sync-adr-toc` hook — do not hand-edit.',
73
+ '',
74
+ '| ADR | Title | Status |',
75
+ '|-----|-------|--------|',
76
+ ...rows,
77
+ '',
78
+ ].join('\n');
79
+ }
80
+
81
+ function main() {
82
+ if (!process.argv.includes('--write') && !isAdrCommand()) return;
83
+
84
+ const dir = adrDir();
85
+ const files = fs
86
+ .readdirSync(path.join(root, dir))
87
+ .filter((f) => ADR_FILE.test(f))
88
+ .sort();
89
+ if (files.length === 0) return;
90
+
91
+ const target = path.join(root, dir, 'README.md');
92
+ const next = render(dir, files);
93
+
94
+ let current = null;
95
+ try {
96
+ current = fs.readFileSync(target, 'utf8');
97
+ } catch {
98
+ /* no index yet */
99
+ }
100
+ if (current === next) return; // already in sync
101
+
102
+ fs.writeFileSync(target, next);
103
+ process.stdout.write(`Regenerated ${dir}/README.md (${files.length} ADRs).\n`);
104
+ }
105
+
106
+ try {
107
+ main();
108
+ } catch {
109
+ /* fail open */
110
+ }
111
+ process.exit(0);