@warnyin/sdlc 0.5.2 → 0.6.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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -21
  3. package/README.md +1 -1
  4. package/bin/cli.mjs +49 -4
  5. package/lib/caps.mjs +45 -45
  6. package/lib/config.mjs +41 -41
  7. package/lib/delta.mjs +227 -227
  8. package/lib/frontmatter.mjs +59 -59
  9. package/lib/glob.mjs +29 -29
  10. package/lib/journal.mjs +128 -0
  11. package/lib/manifest.mjs +99 -99
  12. package/lib/observe.mjs +19 -16
  13. package/lib/settings-merge.mjs +63 -63
  14. package/lib/validate.mjs +196 -196
  15. package/package.json +1 -1
  16. package/payload/adapters/agents-md.md +8 -8
  17. package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
  18. package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
  19. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
  20. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
  21. package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
  22. package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
  23. package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
  24. package/payload/adapters/claude/agents/sdlc-security.md +12 -12
  25. package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
  26. package/payload/adapters/claude/commands/sdlc/init.md +4 -4
  27. package/payload/adapters/claude/commands/sdlc/next.md +4 -4
  28. package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
  29. package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
  30. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
  31. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
  32. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +2 -1
  33. package/payload/adapters/cline.md +8 -8
  34. package/payload/adapters/copilot.md +8 -8
  35. package/payload/adapters/cursor.mdc +7 -7
  36. package/payload/adapters/gemini.md +8 -8
  37. package/payload/adapters/windsurf.md +4 -4
  38. package/payload/hooks/_shared.mjs +150 -154
  39. package/payload/hooks/guard-writes.mjs +83 -83
  40. package/payload/hooks/inject-context.mjs +55 -55
  41. package/payload/hooks/journal.mjs +58 -58
  42. package/payload/hooks/session-summary.mjs +50 -50
  43. package/payload/hooks/validate-artifact.mjs +80 -80
  44. package/payload/playbook/auto.md +12 -0
  45. package/payload/playbook/context.md +26 -26
  46. package/payload/playbook/converge.md +19 -19
  47. package/payload/playbook/init.md +22 -22
  48. package/payload/playbook/observe.md +20 -20
  49. package/payload/playbook/principles.md +28 -28
  50. package/payload/playbook/routing.md +19 -19
  51. package/payload/playbook/rules-card.md +16 -16
  52. package/payload/playbook/ship.md +35 -35
  53. package/payload/playbook/steer.md +21 -21
  54. package/payload/templates/change-deep.md +29 -29
  55. package/payload/templates/change-standard.md +28 -28
  56. package/payload/templates/change-vibe.md +19 -19
  57. package/payload/templates/config.yaml +8 -8
  58. package/payload/templates/constitution.md +14 -14
  59. package/payload/templates/contract-evals.md +9 -9
  60. package/payload/templates/contract-tests.md +9 -9
  61. package/payload/templates/harness.md +33 -33
  62. package/payload/templates/spec.md +14 -14
  63. package/payload/templates/steering.md +9 -9
  64. package/scripts/validate.mjs +47 -47
@@ -0,0 +1,128 @@
1
+ // Journal residency — where a change's telemetry lives, and how the two possible
2
+ // streams are read back as one.
3
+ //
4
+ // Telemetry is appended by hooks on their own schedule, so it must never live in a
5
+ // version-controlled file: a session would dirty the tree by merely running, and two
6
+ // people on one change would conflict on the appended tail. While a change is open it
7
+ // goes to `sdlc/.state/journal/<id>.ndjson` — `.state/` is gitignored in every
8
+ // installed project — and `archive` seals it into the shipped change folder, one
9
+ // write, at ship. Projects installed before this carry a legacy in-tree journal; it is
10
+ // still read, and consumed at ship.
11
+ //
12
+ // Shared by the CLI, the report builder and the installed hooks, so `node:*` only.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ // Windows resolves these to devices no matter what extension follows, so a write to
18
+ // `COM1.ndjson` goes to a serial port rather than a file.
19
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
20
+
21
+ // A change id reaches the path builder from `sdlc/.state/active.json` and from CLI
22
+ // argv — both user-writable. An id that is not a single safe path segment is refused
23
+ // outright rather than normalized: normalizing invites a `..` to be resolved into a
24
+ // write outside `.state/`, and there is no legitimate id that needs it.
25
+ //
26
+ // The Windows-specific rejections are not hypothetical for a CLI that installs itself
27
+ // into other people's checkouts: `:` makes NTFS treat the rest as an alternate data
28
+ // stream (`foo:bar.ndjson` writes a hidden stream on `foo`, invisible to a directory
29
+ // listing), and a trailing dot or space is stripped silently, so `add-2fa.` would
30
+ // alias onto the real `add-2fa` change and fold one change's telemetry into another's.
31
+ export function isSafeChangeId(id) {
32
+ return typeof id === 'string'
33
+ && id.length > 0
34
+ && id.length <= 100
35
+ && id !== '.'
36
+ && id !== '..'
37
+ && !id.includes('\0')
38
+ && !/[/\\:]/.test(id)
39
+ && !/[. ]$/.test(id)
40
+ && !WINDOWS_RESERVED.test(id);
41
+ }
42
+
43
+ // The stream a session appends to while the change is open. `null` for an unsafe id,
44
+ // so callers fall back to the global journal instead of writing somewhere surprising.
45
+ export function liveJournalPath(sdlcRoot, changeId) {
46
+ if (!isSafeChangeId(changeId)) return null;
47
+ return path.join(sdlcRoot, '.state', 'journal', `${changeId}.ndjson`);
48
+ }
49
+
50
+ // Events with no change to attribute them to.
51
+ export function globalJournalPath(sdlcRoot) {
52
+ return path.join(sdlcRoot, '.state', 'journal.ndjson');
53
+ }
54
+
55
+ // Where projects installed before this change already have telemetry. Read-only as far
56
+ // as new events are concerned — nothing appends here any more.
57
+ export function legacyJournalPath(sdlcRoot, changeId) {
58
+ if (!isSafeChangeId(changeId)) return null;
59
+ return path.join(sdlcRoot, 'changes', changeId, 'journal.ndjson');
60
+ }
61
+
62
+ // The sealed journal inside a shipped change folder.
63
+ export function sealedJournalPath(changeDir) {
64
+ return path.join(changeDir, 'journal.ndjson');
65
+ }
66
+
67
+ // One malformed line must not cost the rest of the stream: telemetry is best-effort
68
+ // evidence, and a truncated tail from a killed process is a normal way to find it.
69
+ export function parseJournal(text) {
70
+ const out = [];
71
+ for (const line of text.split('\n')) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed) continue;
74
+ try {
75
+ const event = JSON.parse(trimmed);
76
+ if (event && typeof event === 'object') out.push(event);
77
+ } catch { /* skip the line, keep the stream */ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export function readJournalFile(p) {
83
+ if (!p || !fs.existsSync(p)) return [];
84
+ try {
85
+ return parseJournal(fs.readFileSync(p, 'utf8'));
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ // Merge two streams by recorded time. The sort is stable and the tie-break is explicit
92
+ // — equal timestamps keep stream order (`older` first), then file order — so the sealed
93
+ // journal is byte-identical whatever order the reads happened in.
94
+ export function mergeByTime(older, newer) {
95
+ const decorated = [];
96
+ [older, newer].forEach((events, stream) => {
97
+ events.forEach((event, position) => decorated.push({ event, stream, position }));
98
+ });
99
+ decorated.sort((a, b) => {
100
+ const at = typeof a.event.ts === 'string' ? a.event.ts : '';
101
+ const bt = typeof b.event.ts === 'string' ? b.event.ts : '';
102
+ if (at !== bt) return at < bt ? -1 : 1;
103
+ if (a.stream !== b.stream) return a.stream - b.stream;
104
+ return a.position - b.position;
105
+ });
106
+ return decorated.map((d) => d.event);
107
+ }
108
+
109
+ // Everything recorded for an open change, legacy first so it wins a timestamp tie —
110
+ // it is by definition the older stream.
111
+ export function readChangeJournal(sdlcRoot, changeId) {
112
+ return mergeByTime(
113
+ readJournalFile(legacyJournalPath(sdlcRoot, changeId)),
114
+ readJournalFile(liveJournalPath(sdlcRoot, changeId)),
115
+ );
116
+ }
117
+
118
+ // Always `\n`: a legacy journal checked out on Windows can arrive with CRLF, and the
119
+ // sealed file is committed — mixing endings there would churn every diff after it.
120
+ export function serializeJournal(events) {
121
+ if (!events.length) return '';
122
+ return events.map((e) => JSON.stringify(e)).join('\n') + '\n';
123
+ }
124
+
125
+ export function appendEvent(filePath, event) {
126
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
127
+ fs.appendFileSync(filePath, JSON.stringify(event) + '\n');
128
+ }
package/lib/manifest.mjs CHANGED
@@ -1,99 +1,99 @@
1
- // Manifest + prune guards (ported from the battle-tested warnyin-agents
2
- // installer). The manifest records sha256 of every payload-owned file so
3
- // `update` can distinguish ours-unmodified (refresh), ours-modified-by-user
4
- // (keep + warn), and stale (prune with guards).
5
-
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
-
9
- export const PRUNE_BLAST_CAP = 50;
10
-
11
- // Payload-owned roots — prune may only ever touch paths under these.
12
- const PRUNABLE_PREFIXES = [
13
- 'sdlc/.playbook/',
14
- 'sdlc/.hooks/',
15
- '.claude/commands/sdlc/',
16
- ];
17
- const AGENT_ALLOW_RE = /^\.claude\/agents\/sdlc-[^/]+\.md$/;
18
- const SKILL_ALLOW_RE = /^\.claude\/skills\/(delta-spec-format|contract-writing|sdlc-conventions)\/[^/]+$/;
19
- const ADAPTER_ALLOW = new Set([
20
- '.cursor/rules/sdlc.mdc',
21
- '.windsurf/rules/sdlc.md',
22
- ]);
23
-
24
- export function parseManifest(text) {
25
- const map = new Map();
26
- for (const line of (text ?? '').split(/\r?\n/)) {
27
- if (!line.trim() || line.startsWith('#')) continue;
28
- const m = line.match(/^([a-f0-9]{64})\s{2}(.+)$/);
29
- if (!m) continue;
30
- map.set(m[2], m[1]);
31
- }
32
- return map;
33
- }
34
-
35
- export function renderManifest(map) {
36
- const lines = ['# @warnyin/sdlc manifest — sha256 path (posix, relative to project root)'];
37
- for (const [p, hash] of [...map.entries()].sort()) lines.push(`${hash} ${p}`);
38
- return lines.join('\n') + '\n';
39
- }
40
-
41
- // Guard 1: structural path safety (manifest is data — never trust it blindly).
42
- export function isSafeRelPath(relPosix) {
43
- if (typeof relPosix !== 'string' || relPosix === '') return false;
44
- if (relPosix.includes('\\') || relPosix.startsWith('/') || /^[A-Za-z]:/.test(relPosix)) return false;
45
- if (/[\u0000-\u001f]/.test(relPosix)) return false;
46
- const segments = relPosix.split('/');
47
- return segments.every((s) => s !== '' && s !== '.' && s !== '..');
48
- }
49
-
50
- // Guard 2: scope — only payload-owned locations are ever prunable.
51
- export function isPrunablePath(relPosix) {
52
- if (!isSafeRelPath(relPosix)) return false;
53
- if (PRUNABLE_PREFIXES.some((p) => relPosix.startsWith(p))) return true;
54
- if (AGENT_ALLOW_RE.test(relPosix)) return true;
55
- if (SKILL_ALLOW_RE.test(relPosix)) return true;
56
- if (ADAPTER_ALLOW.has(relPosix)) return true;
57
- return false;
58
- }
59
-
60
- // Stale = in the old manifest but absent from the new payload set.
61
- // Every candidate must pass path + scope guards; the caller additionally
62
- // checks hash-match-on-disk and realpath containment before deleting.
63
- export function computeStale(oldManifest, newPaths) {
64
- const stale = [];
65
- const rejected = [];
66
- for (const [relPath, hash] of oldManifest.entries()) {
67
- if (newPaths.has(relPath)) continue;
68
- if (!isPrunablePath(relPath)) {
69
- rejected.push({ path: relPath, reason: 'outside prunable scope' });
70
- continue;
71
- }
72
- stale.push({ path: relPath, hash });
73
- }
74
- return { stale, rejected, overCap: stale.length > PRUNE_BLAST_CAP };
75
- }
76
-
77
- export function containedIn(rootAbs, targetAbs) {
78
- const rel = path.relative(rootAbs, targetAbs);
79
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
80
- }
81
-
82
- // Guard: no path segment between root and target may be a symlink. Without
83
- // this, a symlinked ancestor inside a prunable prefix redirects the delete to
84
- // whatever real file it points at (arbitrary-deletion class — the manifest is
85
- // untrusted, user-writable input).
86
- export function hasSymlinkSegment(rootAbs, targetAbs) {
87
- const rel = path.relative(rootAbs, targetAbs);
88
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return true; // suspicious → treat as unsafe
89
- let cur = rootAbs;
90
- for (const seg of rel.split(path.sep)) {
91
- cur = path.join(cur, seg);
92
- try {
93
- if (fs.lstatSync(cur).isSymbolicLink()) return true;
94
- } catch {
95
- return false; // component missing — nothing to follow
96
- }
97
- }
98
- return false;
99
- }
1
+ // Manifest + prune guards (ported from the battle-tested warnyin-agents
2
+ // installer). The manifest records sha256 of every payload-owned file so
3
+ // `update` can distinguish ours-unmodified (refresh), ours-modified-by-user
4
+ // (keep + warn), and stale (prune with guards).
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export const PRUNE_BLAST_CAP = 50;
10
+
11
+ // Payload-owned roots — prune may only ever touch paths under these.
12
+ const PRUNABLE_PREFIXES = [
13
+ 'sdlc/.playbook/',
14
+ 'sdlc/.hooks/',
15
+ '.claude/commands/sdlc/',
16
+ ];
17
+ const AGENT_ALLOW_RE = /^\.claude\/agents\/sdlc-[^/]+\.md$/;
18
+ const SKILL_ALLOW_RE = /^\.claude\/skills\/(delta-spec-format|contract-writing|sdlc-conventions)\/[^/]+$/;
19
+ const ADAPTER_ALLOW = new Set([
20
+ '.cursor/rules/sdlc.mdc',
21
+ '.windsurf/rules/sdlc.md',
22
+ ]);
23
+
24
+ export function parseManifest(text) {
25
+ const map = new Map();
26
+ for (const line of (text ?? '').split(/\r?\n/)) {
27
+ if (!line.trim() || line.startsWith('#')) continue;
28
+ const m = line.match(/^([a-f0-9]{64})\s{2}(.+)$/);
29
+ if (!m) continue;
30
+ map.set(m[2], m[1]);
31
+ }
32
+ return map;
33
+ }
34
+
35
+ export function renderManifest(map) {
36
+ const lines = ['# @warnyin/sdlc manifest — sha256 path (posix, relative to project root)'];
37
+ for (const [p, hash] of [...map.entries()].sort()) lines.push(`${hash} ${p}`);
38
+ return lines.join('\n') + '\n';
39
+ }
40
+
41
+ // Guard 1: structural path safety (manifest is data — never trust it blindly).
42
+ export function isSafeRelPath(relPosix) {
43
+ if (typeof relPosix !== 'string' || relPosix === '') return false;
44
+ if (relPosix.includes('\\') || relPosix.startsWith('/') || /^[A-Za-z]:/.test(relPosix)) return false;
45
+ if (/[\u0000-\u001f]/.test(relPosix)) return false;
46
+ const segments = relPosix.split('/');
47
+ return segments.every((s) => s !== '' && s !== '.' && s !== '..');
48
+ }
49
+
50
+ // Guard 2: scope — only payload-owned locations are ever prunable.
51
+ export function isPrunablePath(relPosix) {
52
+ if (!isSafeRelPath(relPosix)) return false;
53
+ if (PRUNABLE_PREFIXES.some((p) => relPosix.startsWith(p))) return true;
54
+ if (AGENT_ALLOW_RE.test(relPosix)) return true;
55
+ if (SKILL_ALLOW_RE.test(relPosix)) return true;
56
+ if (ADAPTER_ALLOW.has(relPosix)) return true;
57
+ return false;
58
+ }
59
+
60
+ // Stale = in the old manifest but absent from the new payload set.
61
+ // Every candidate must pass path + scope guards; the caller additionally
62
+ // checks hash-match-on-disk and realpath containment before deleting.
63
+ export function computeStale(oldManifest, newPaths) {
64
+ const stale = [];
65
+ const rejected = [];
66
+ for (const [relPath, hash] of oldManifest.entries()) {
67
+ if (newPaths.has(relPath)) continue;
68
+ if (!isPrunablePath(relPath)) {
69
+ rejected.push({ path: relPath, reason: 'outside prunable scope' });
70
+ continue;
71
+ }
72
+ stale.push({ path: relPath, hash });
73
+ }
74
+ return { stale, rejected, overCap: stale.length > PRUNE_BLAST_CAP };
75
+ }
76
+
77
+ export function containedIn(rootAbs, targetAbs) {
78
+ const rel = path.relative(rootAbs, targetAbs);
79
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
80
+ }
81
+
82
+ // Guard: no path segment between root and target may be a symlink. Without
83
+ // this, a symlinked ancestor inside a prunable prefix redirects the delete to
84
+ // whatever real file it points at (arbitrary-deletion class — the manifest is
85
+ // untrusted, user-writable input).
86
+ export function hasSymlinkSegment(rootAbs, targetAbs) {
87
+ const rel = path.relative(rootAbs, targetAbs);
88
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return true; // suspicious → treat as unsafe
89
+ let cur = rootAbs;
90
+ for (const seg of rel.split(path.sep)) {
91
+ cur = path.join(cur, seg);
92
+ try {
93
+ if (fs.lstatSync(cur).isSymbolicLink()) return true;
94
+ } catch {
95
+ return false; // component missing — nothing to follow
96
+ }
97
+ }
98
+ return false;
99
+ }
package/lib/observe.mjs CHANGED
@@ -5,17 +5,23 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { parseFrontmatter } from './frontmatter.mjs';
7
7
  import { CAPS, countEffectiveLines } from './caps.mjs';
8
-
9
- function readJournal(dir) {
10
- const p = path.join(dir, 'journal.ndjson');
11
- if (!fs.existsSync(p)) return [];
12
- return fs.readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
13
- try { return JSON.parse(l); } catch { return null; }
14
- }).filter(Boolean);
8
+ import { readChangeJournal, readJournalFile, sealedJournalPath, globalJournalPath } from './journal.mjs';
9
+
10
+ // An open change's telemetry lives out of tree (plus a legacy in-tree stream in
11
+ // projects installed before that); a shipped one carries its sealed journal in the
12
+ // archived folder. Reading only the folder would report an open change as having done
13
+ // nothing, which reads as a quiet change rather than as a broken reader.
14
+ //
15
+ // `id` means different things on the two branches — a change id when open, the dated
16
+ // archive folder name when shipped — so the directory is derived here rather than
17
+ // passed in, and the layout rule stays in one place.
18
+ function changeEvents(sdlcRoot, id, archived) {
19
+ if (archived) return readJournalFile(sealedJournalPath(path.join(sdlcRoot, 'changes', 'archive', id)));
20
+ return readChangeJournal(sdlcRoot, id);
15
21
  }
16
22
 
17
- function summarizeChange(dir, id, archived) {
18
- const events = readJournal(dir);
23
+ function summarizeChange(sdlcRoot, dir, id, archived) {
24
+ const events = changeEvents(sdlcRoot, id, archived);
19
25
  const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
20
26
  let costUsd = 0;
21
27
  let costKnown = false;
@@ -76,13 +82,13 @@ export function buildReport(sdlcRoot) {
76
82
  if (fs.existsSync(changesDir)) {
77
83
  for (const d of fs.readdirSync(changesDir, { withFileTypes: true })) {
78
84
  if (!d.isDirectory() || d.name === 'archive') continue;
79
- changes.push(summarizeChange(path.join(changesDir, d.name), d.name, false));
85
+ changes.push(summarizeChange(sdlcRoot, path.join(changesDir, d.name), d.name, false));
80
86
  }
81
87
  const archiveDir = path.join(changesDir, 'archive');
82
88
  if (fs.existsSync(archiveDir)) {
83
89
  for (const d of fs.readdirSync(archiveDir, { withFileTypes: true })) {
84
90
  if (!d.isDirectory()) continue;
85
- changes.push(summarizeChange(path.join(archiveDir, d.name), d.name, true));
91
+ changes.push(summarizeChange(sdlcRoot, path.join(archiveDir, d.name), d.name, true));
86
92
  }
87
93
  }
88
94
  }
@@ -96,17 +102,14 @@ export function buildReport(sdlcRoot) {
96
102
  const steering = [];
97
103
  const pointerHits = new Map();
98
104
  for (const c of changes) {
99
- const dir = c.archived
100
- ? path.join(sdlcRoot, 'changes', 'archive', c.id)
101
- : path.join(sdlcRoot, 'changes', c.id);
102
- for (const e of readJournal(dir)) {
105
+ for (const e of changeEvents(sdlcRoot, c.id, c.archived)) {
103
106
  if (e.event === 'pointer' && e.steering) {
104
107
  pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
105
108
  }
106
109
  }
107
110
  }
108
111
  // Global journal (events with no active change) counts too.
109
- for (const e of readJournal(path.join(sdlcRoot, '.state'))) {
112
+ for (const e of readJournalFile(globalJournalPath(sdlcRoot))) {
110
113
  if (e.event === 'pointer' && e.steering) {
111
114
  pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
112
115
  }
@@ -1,63 +1,63 @@
1
- // Non-destructive management of our hook entries inside the project's
2
- // .claude/settings.json. Ownership marker: any hook command that references
3
- // `sdlc/.hooks/` is ours; everything else is the user's and is never touched.
4
- // Merge is idempotent: remove ours, re-add current set, preserve the rest.
5
-
6
- const OWNERSHIP_MARKER = 'sdlc/.hooks/';
7
-
8
- const hookCmd = (script, extraArgs = '') =>
9
- `node "$CLAUDE_PROJECT_DIR/sdlc/.hooks/${script}"${extraArgs ? ' ' + extraArgs : ''}`;
10
-
11
- export function sdlcHookEntries() {
12
- return {
13
- SessionStart: [
14
- { hooks: [{ type: 'command', command: hookCmd('inject-context.mjs') }] },
15
- ],
16
- PreToolUse: [
17
- {
18
- matcher: 'Edit|Write|MultiEdit|NotebookEdit',
19
- hooks: [{ type: 'command', command: hookCmd('guard-writes.mjs') }],
20
- },
21
- ],
22
- PostToolUse: [
23
- {
24
- matcher: 'Edit|Write|MultiEdit',
25
- hooks: [{ type: 'command', command: hookCmd('validate-artifact.mjs') }],
26
- },
27
- ],
28
- Stop: [
29
- { hooks: [{ type: 'command', command: hookCmd('session-summary.mjs') }] },
30
- ],
31
- PreCompact: [
32
- { hooks: [{ type: 'command', command: hookCmd('journal.mjs', 'note compact') }] },
33
- ],
34
- };
35
- }
36
-
37
- function isOurs(matcherEntry) {
38
- return (matcherEntry?.hooks ?? []).some(
39
- (h) => typeof h?.command === 'string' && h.command.includes(OWNERSHIP_MARKER),
40
- );
41
- }
42
-
43
- // settingsJson: parsed object (or {}). Returns a NEW object (immutability).
44
- export function mergeHookSettings(settingsJson) {
45
- const settings = structuredClone(settingsJson ?? {});
46
- const hooks = { ...(settings.hooks ?? {}) };
47
- for (const [event, entries] of Object.entries(sdlcHookEntries())) {
48
- const existing = (hooks[event] ?? []).filter((e) => !isOurs(e));
49
- hooks[event] = [...existing, ...entries];
50
- }
51
- return { ...settings, hooks };
52
- }
53
-
54
- export function removeHookSettings(settingsJson) {
55
- const settings = structuredClone(settingsJson ?? {});
56
- if (!settings.hooks) return settings;
57
- const hooks = {};
58
- for (const [event, entries] of Object.entries(settings.hooks)) {
59
- const kept = entries.filter((e) => !isOurs(e));
60
- if (kept.length) hooks[event] = kept;
61
- }
62
- return { ...settings, hooks };
63
- }
1
+ // Non-destructive management of our hook entries inside the project's
2
+ // .claude/settings.json. Ownership marker: any hook command that references
3
+ // `sdlc/.hooks/` is ours; everything else is the user's and is never touched.
4
+ // Merge is idempotent: remove ours, re-add current set, preserve the rest.
5
+
6
+ const OWNERSHIP_MARKER = 'sdlc/.hooks/';
7
+
8
+ const hookCmd = (script, extraArgs = '') =>
9
+ `node "$CLAUDE_PROJECT_DIR/sdlc/.hooks/${script}"${extraArgs ? ' ' + extraArgs : ''}`;
10
+
11
+ export function sdlcHookEntries() {
12
+ return {
13
+ SessionStart: [
14
+ { hooks: [{ type: 'command', command: hookCmd('inject-context.mjs') }] },
15
+ ],
16
+ PreToolUse: [
17
+ {
18
+ matcher: 'Edit|Write|MultiEdit|NotebookEdit',
19
+ hooks: [{ type: 'command', command: hookCmd('guard-writes.mjs') }],
20
+ },
21
+ ],
22
+ PostToolUse: [
23
+ {
24
+ matcher: 'Edit|Write|MultiEdit',
25
+ hooks: [{ type: 'command', command: hookCmd('validate-artifact.mjs') }],
26
+ },
27
+ ],
28
+ Stop: [
29
+ { hooks: [{ type: 'command', command: hookCmd('session-summary.mjs') }] },
30
+ ],
31
+ PreCompact: [
32
+ { hooks: [{ type: 'command', command: hookCmd('journal.mjs', 'note compact') }] },
33
+ ],
34
+ };
35
+ }
36
+
37
+ function isOurs(matcherEntry) {
38
+ return (matcherEntry?.hooks ?? []).some(
39
+ (h) => typeof h?.command === 'string' && h.command.includes(OWNERSHIP_MARKER),
40
+ );
41
+ }
42
+
43
+ // settingsJson: parsed object (or {}). Returns a NEW object (immutability).
44
+ export function mergeHookSettings(settingsJson) {
45
+ const settings = structuredClone(settingsJson ?? {});
46
+ const hooks = { ...(settings.hooks ?? {}) };
47
+ for (const [event, entries] of Object.entries(sdlcHookEntries())) {
48
+ const existing = (hooks[event] ?? []).filter((e) => !isOurs(e));
49
+ hooks[event] = [...existing, ...entries];
50
+ }
51
+ return { ...settings, hooks };
52
+ }
53
+
54
+ export function removeHookSettings(settingsJson) {
55
+ const settings = structuredClone(settingsJson ?? {});
56
+ if (!settings.hooks) return settings;
57
+ const hooks = {};
58
+ for (const [event, entries] of Object.entries(settings.hooks)) {
59
+ const kept = entries.filter((e) => !isOurs(e));
60
+ if (kept.length) hooks[event] = kept;
61
+ }
62
+ return { ...settings, hooks };
63
+ }