@warnyin/sdlc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +74 -0
  4. package/bin/cli.mjs +470 -0
  5. package/lib/caps.mjs +45 -0
  6. package/lib/config.mjs +41 -0
  7. package/lib/delta.mjs +160 -0
  8. package/lib/frontmatter.mjs +59 -0
  9. package/lib/glob.mjs +29 -0
  10. package/lib/manifest.mjs +99 -0
  11. package/lib/observe.mjs +174 -0
  12. package/lib/settings-merge.mjs +63 -0
  13. package/lib/usage.mjs +46 -0
  14. package/lib/validate.mjs +186 -0
  15. package/package.json +42 -0
  16. package/payload/adapters/agents-md.md +8 -0
  17. package/payload/adapters/claude/agents/sdlc-architect.md +12 -0
  18. package/payload/adapters/claude/agents/sdlc-builder.md +14 -0
  19. package/payload/adapters/claude/agents/sdlc-contractor.md +13 -0
  20. package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -0
  21. package/payload/adapters/claude/agents/sdlc-learner.md +16 -0
  22. package/payload/adapters/claude/agents/sdlc-ops.md +11 -0
  23. package/payload/adapters/claude/agents/sdlc-quality.md +13 -0
  24. package/payload/adapters/claude/agents/sdlc-security.md +12 -0
  25. package/payload/adapters/claude/commands/sdlc/auto.md +5 -0
  26. package/payload/adapters/claude/commands/sdlc/build.md +5 -0
  27. package/payload/adapters/claude/commands/sdlc/contract.md +5 -0
  28. package/payload/adapters/claude/commands/sdlc/converge.md +5 -0
  29. package/payload/adapters/claude/commands/sdlc/design.md +5 -0
  30. package/payload/adapters/claude/commands/sdlc/init.md +4 -0
  31. package/payload/adapters/claude/commands/sdlc/new.md +5 -0
  32. package/payload/adapters/claude/commands/sdlc/next.md +4 -0
  33. package/payload/adapters/claude/commands/sdlc/observe.md +4 -0
  34. package/payload/adapters/claude/commands/sdlc/review.md +5 -0
  35. package/payload/adapters/claude/commands/sdlc/ship.md +5 -0
  36. package/payload/adapters/claude/commands/sdlc/steer.md +4 -0
  37. package/payload/adapters/claude/commands/sdlc/verify.md +5 -0
  38. package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -0
  39. package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +33 -0
  40. package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +26 -0
  41. package/payload/adapters/cline.md +8 -0
  42. package/payload/adapters/copilot.md +8 -0
  43. package/payload/adapters/cursor.mdc +7 -0
  44. package/payload/adapters/gemini.md +8 -0
  45. package/payload/adapters/windsurf.md +4 -0
  46. package/payload/hooks/_shared.mjs +141 -0
  47. package/payload/hooks/guard-writes.mjs +83 -0
  48. package/payload/hooks/inject-context.mjs +55 -0
  49. package/payload/hooks/journal.mjs +58 -0
  50. package/payload/hooks/session-summary.mjs +50 -0
  51. package/payload/hooks/validate-artifact.mjs +80 -0
  52. package/payload/playbook/README.md +30 -0
  53. package/payload/playbook/auto.md +21 -0
  54. package/payload/playbook/build.md +23 -0
  55. package/payload/playbook/context.md +26 -0
  56. package/payload/playbook/contract.md +23 -0
  57. package/payload/playbook/converge.md +19 -0
  58. package/payload/playbook/design.md +20 -0
  59. package/payload/playbook/init.md +22 -0
  60. package/payload/playbook/new.md +22 -0
  61. package/payload/playbook/next.md +12 -0
  62. package/payload/playbook/observe.md +20 -0
  63. package/payload/playbook/principles.md +28 -0
  64. package/payload/playbook/review.md +17 -0
  65. package/payload/playbook/routing.md +19 -0
  66. package/payload/playbook/rules-card.md +16 -0
  67. package/payload/playbook/ship.md +24 -0
  68. package/payload/playbook/steer.md +21 -0
  69. package/payload/playbook/verify.md +24 -0
  70. package/payload/templates/change-deep.md +29 -0
  71. package/payload/templates/change-standard.md +28 -0
  72. package/payload/templates/change-vibe.md +19 -0
  73. package/payload/templates/config.yaml +8 -0
  74. package/payload/templates/constitution.md +14 -0
  75. package/payload/templates/contract-evals.md +9 -0
  76. package/payload/templates/contract-tests.md +9 -0
  77. package/payload/templates/harness.md +33 -0
  78. package/payload/templates/spec.md +14 -0
  79. package/payload/templates/steering.md +9 -0
  80. package/scripts/validate.mjs +38 -0
package/lib/delta.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // Delta-spec grammar (parser keys are frozen English regardless of any
2
+ // UI language):
3
+ //
4
+ // ## Delta: <capability>
5
+ // ### ADDED Requirement: <name>
6
+ // The system SHALL <behavior>.
7
+ // #### Scenario: <name>
8
+ // - WHEN <condition>
9
+ // - THEN <outcome>
10
+ // ### MODIFIED Requirement: <existing name> ← full replacement body
11
+ // ### REMOVED Requirement: <existing name>
12
+ //
13
+ // Requirement heading text is the identity key (OpenSpec convention).
14
+ // `mergeDelta` applies ops mechanically to a living spec; a missing key on
15
+ // MODIFIED/REMOVED is a hard error — never merge silently.
16
+
17
+ const DELTA_HEAD = /^## Delta:\s*(.+?)\s*$/;
18
+ const OP_HEAD = /^### (ADDED|MODIFIED|REMOVED) Requirement:\s*(.+?)\s*$/;
19
+ const BAD_OP_HEAD = /^### (\w+) Requirement:/;
20
+ const SPEC_REQ_HEAD = /^### Requirement:\s*(.+?)\s*$/;
21
+
22
+ export function parseDelta(changeText) {
23
+ const lines = (changeText ?? '').split(/\r?\n/);
24
+ const deltas = [];
25
+ const errors = [];
26
+ let current = null; // { capability, ops }
27
+ let currentOp = null; // { op, name, bodyLines }
28
+
29
+ const closeOp = () => {
30
+ if (!currentOp) return;
31
+ const body = currentOp.bodyLines.join('\n').trim();
32
+ if (currentOp.op !== 'REMOVED' && body === '') {
33
+ errors.push(`${currentOp.op} Requirement "${currentOp.name}" has an empty body`);
34
+ }
35
+ current.ops.push({ op: currentOp.op, name: currentOp.name, body });
36
+ currentOp = null;
37
+ };
38
+
39
+ for (const line of lines) {
40
+ const deltaMatch = line.match(DELTA_HEAD);
41
+ if (deltaMatch) {
42
+ closeOp();
43
+ // Repeated `## Delta: <cap>` blocks merge into one entry — otherwise the
44
+ // last block would silently overwrite the first at ship time.
45
+ const existing = deltas.find((d) => d.capability.toLowerCase() === deltaMatch[1].toLowerCase());
46
+ current = existing ?? { capability: deltaMatch[1], ops: [] };
47
+ if (!existing) deltas.push(current);
48
+ continue;
49
+ }
50
+ if (/^## /.test(line) && !deltaMatch) {
51
+ closeOp();
52
+ current = null; // left the delta section
53
+ continue;
54
+ }
55
+ if (!current) continue;
56
+
57
+ const opMatch = line.match(OP_HEAD);
58
+ if (opMatch) {
59
+ closeOp();
60
+ currentOp = { op: opMatch[1], name: opMatch[2], bodyLines: [] };
61
+ continue;
62
+ }
63
+ const badOp = line.match(BAD_OP_HEAD);
64
+ if (badOp && !opMatch) {
65
+ errors.push(`Unknown delta operation "${badOp[1]}" (use ADDED, MODIFIED, or REMOVED)`);
66
+ continue;
67
+ }
68
+ if (currentOp) currentOp.bodyLines.push(line);
69
+ }
70
+ closeOp();
71
+
72
+ for (const d of deltas) {
73
+ const seen = new Set();
74
+ for (const op of d.ops) {
75
+ const key = op.name.toLowerCase();
76
+ if (seen.has(key)) errors.push(`Duplicate requirement "${op.name}" in Delta: ${d.capability}`);
77
+ seen.add(key);
78
+ }
79
+ if (d.ops.length === 0) errors.push(`Delta: ${d.capability} declares no requirement operations`);
80
+ }
81
+
82
+ return { deltas, errors };
83
+ }
84
+
85
+ export function parseSpec(specText) {
86
+ const lines = (specText ?? '').split(/\r?\n/);
87
+ const requirements = [];
88
+ const preamble = [];
89
+ let current = null;
90
+
91
+ for (const line of lines) {
92
+ const reqMatch = line.match(SPEC_REQ_HEAD);
93
+ if (reqMatch) {
94
+ current = { name: reqMatch[1], bodyLines: [] };
95
+ requirements.push(current);
96
+ continue;
97
+ }
98
+ if (current) current.bodyLines.push(line);
99
+ else preamble.push(line);
100
+ }
101
+
102
+ return {
103
+ preamble: preamble.join('\n').replace(/\n+$/, ''),
104
+ requirements: requirements.map((r) => ({
105
+ name: r.name,
106
+ body: r.bodyLines.join('\n').trim(),
107
+ })),
108
+ };
109
+ }
110
+
111
+ export function renderSpec({ preamble, requirements }) {
112
+ const parts = [preamble.replace(/\n+$/, '')];
113
+ for (const r of requirements) {
114
+ parts.push(`\n### Requirement: ${r.name}\n${r.body}`);
115
+ }
116
+ return parts.join('\n').replace(/\n+$/, '\n');
117
+ }
118
+
119
+ export function newSpecPreamble(capability) {
120
+ return [
121
+ `# Spec: ${capability}`,
122
+ '',
123
+ '## Purpose',
124
+ '<!-- one or two lines; commands grep this header first (progressive disclosure) -->',
125
+ '',
126
+ '## Requirements',
127
+ ].join('\n');
128
+ }
129
+
130
+ // Apply one capability's ops to a living spec (or null to create it).
131
+ // Returns { ok, content, errors }.
132
+ export function mergeDelta(specText, ops, capability) {
133
+ const errors = [];
134
+ const spec = specText == null
135
+ ? { preamble: newSpecPreamble(capability), requirements: [] }
136
+ : parseSpec(specText);
137
+
138
+ const byName = new Map(spec.requirements.map((r) => [r.name.toLowerCase(), r]));
139
+
140
+ for (const { op, name, body } of ops) {
141
+ const key = name.toLowerCase();
142
+ const existing = byName.get(key);
143
+ if (op === 'ADDED') {
144
+ if (existing) { errors.push(`ADDED Requirement "${name}" already exists in spec "${capability}"`); continue; }
145
+ const req = { name, body };
146
+ spec.requirements.push(req);
147
+ byName.set(key, req);
148
+ } else if (op === 'MODIFIED') {
149
+ if (!existing) { errors.push(`MODIFIED Requirement "${name}" not found in spec "${capability}"`); continue; }
150
+ existing.body = body;
151
+ } else if (op === 'REMOVED') {
152
+ if (!existing) { errors.push(`REMOVED Requirement "${name}" not found in spec "${capability}"`); continue; }
153
+ spec.requirements = spec.requirements.filter((r) => r.name.toLowerCase() !== key);
154
+ byName.delete(key);
155
+ }
156
+ }
157
+
158
+ if (errors.length) return { ok: false, content: null, errors };
159
+ return { ok: true, content: renderSpec(spec), errors: [] };
160
+ }
@@ -0,0 +1,59 @@
1
+ // Minimal YAML-frontmatter reader (zero-dep). Supports the subset this
2
+ // framework writes: strings, numbers, booleans, inline arrays, and
3
+ // simple `- item` lists. Anything fancier is a validation error upstream.
4
+
5
+ const FENCE = '---';
6
+
7
+ export function parseFrontmatter(text) {
8
+ if (typeof text !== 'string') return { data: {}, body: '' };
9
+ const lines = text.split(/\r?\n/);
10
+ if (lines[0]?.trim() !== FENCE) return { data: {}, body: text };
11
+
12
+ let end = -1;
13
+ for (let i = 1; i < lines.length; i++) {
14
+ if (lines[i].trim() === FENCE) { end = i; break; }
15
+ }
16
+ if (end === -1) return { data: {}, body: text };
17
+
18
+ const data = {};
19
+ let currentListKey = null;
20
+ for (let i = 1; i < end; i++) {
21
+ const raw = lines[i];
22
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
23
+
24
+ const listItem = raw.match(/^\s+-\s+(.*)$/);
25
+ if (listItem && currentListKey) {
26
+ data[currentListKey].push(coerce(listItem[1].trim()));
27
+ continue;
28
+ }
29
+
30
+ const kv = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
31
+ if (!kv) continue;
32
+ const [, key, rawValue] = kv;
33
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
34
+ const value = rawValue.trim();
35
+ if (value === '') {
36
+ data[key] = [];
37
+ currentListKey = key;
38
+ } else {
39
+ data[key] = coerce(value);
40
+ currentListKey = null;
41
+ }
42
+ }
43
+
44
+ return { data, body: lines.slice(end + 1).join('\n') };
45
+ }
46
+
47
+ function coerce(value) {
48
+ if (value.startsWith('[') && value.endsWith(']')) {
49
+ const inner = value.slice(1, -1).trim();
50
+ if (!inner) return [];
51
+ return inner.split(',').map((v) => coerce(v.trim()));
52
+ }
53
+ const unquoted = value.replace(/^["']|["']$/g, '');
54
+ if (unquoted !== value) return unquoted;
55
+ if (value === 'true') return true;
56
+ if (value === 'false') return false;
57
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
58
+ return value;
59
+ }
package/lib/glob.mjs ADDED
@@ -0,0 +1,29 @@
1
+ // Tiny glob matcher for steering pathMatch patterns. Supports the subset we
2
+ // document: `**` (any depth), `*` (within a segment), literal text.
3
+ // Paths are compared as POSIX, relative to the project root.
4
+
5
+ export function globToRegExp(glob) {
6
+ let re = '';
7
+ for (let i = 0; i < glob.length; i++) {
8
+ const c = glob[i];
9
+ if (c === '*') {
10
+ if (glob[i + 1] === '*') {
11
+ // `**/` or trailing `**` — match any depth including nothing
12
+ if (glob[i + 2] === '/') { re += '(?:[^/]+/)*'; i += 2; }
13
+ else { re += '.*'; i += 1; }
14
+ } else {
15
+ re += '[^/]*';
16
+ }
17
+ } else if ('.+^$()[]{}|\\?'.includes(c)) {
18
+ re += '\\' + c;
19
+ } else {
20
+ re += c;
21
+ }
22
+ }
23
+ return new RegExp(`^${re}$`);
24
+ }
25
+
26
+ export function matchGlob(relPosixPath, patterns) {
27
+ const list = Array.isArray(patterns) ? patterns : [patterns];
28
+ return list.some((p) => globToRegExp(String(p)).test(relPosixPath));
29
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,174 @@
1
+ // Observability aggregation — reads journals + context files, computes the
2
+ // numbers /sdlc:observe reports. Pure given an sdlc root; no LLM involved.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parseFrontmatter } from './frontmatter.mjs';
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);
15
+ }
16
+
17
+ function summarizeChange(dir, id, archived) {
18
+ const events = readJournal(dir);
19
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
20
+ let costUsd = 0;
21
+ let costKnown = false;
22
+ let sessions = 0;
23
+ const verify = { rounds: 0, firstPass: null };
24
+ let guards = 0;
25
+ let compacts = 0;
26
+
27
+ for (const e of events) {
28
+ if (e.event === 'session') {
29
+ sessions++;
30
+ for (const k of Object.keys(tokens)) tokens[k] += e.totals?.[k] ?? 0;
31
+ if (typeof e.costUsd === 'number') { costUsd += e.costUsd; costKnown = true; }
32
+ } else if (e.event === 'verify') {
33
+ verify.rounds++;
34
+ if (verify.firstPass === null) verify.firstPass = e.result === 'pass';
35
+ } else if (e.event === 'guard') guards++;
36
+ else if (e.event === 'compact') compacts++;
37
+ }
38
+
39
+ const first = events[0]?.ts ? Date.parse(events[0].ts) : null;
40
+ const shipEvent = events.find((e) => e.event === 'ship');
41
+ const shippedAt = shipEvent?.ts ? Date.parse(shipEvent.ts) : null;
42
+ const leadTimeMs = first != null && shippedAt != null ? shippedAt - first : null;
43
+
44
+ let tier = null;
45
+ let status = null;
46
+ const changePath = path.join(dir, 'change.md');
47
+ if (fs.existsSync(changePath)) {
48
+ const { data } = parseFrontmatter(fs.readFileSync(changePath, 'utf8'));
49
+ tier = data.tier ?? null;
50
+ status = data.status ?? null;
51
+ }
52
+
53
+ return {
54
+ id, archived, tier, status, sessions, tokens,
55
+ costUsd: costKnown ? Number(costUsd.toFixed(4)) : null,
56
+ verify, guards, compacts, leadTimeMs,
57
+ digest: archived ? fs.existsSync(path.join(dir, 'digest.md')) : null,
58
+ };
59
+ }
60
+
61
+ export function buildReport(sdlcRoot) {
62
+ const changes = [];
63
+
64
+ const changesDir = path.join(sdlcRoot, 'changes');
65
+ if (fs.existsSync(changesDir)) {
66
+ for (const d of fs.readdirSync(changesDir, { withFileTypes: true })) {
67
+ if (!d.isDirectory() || d.name === 'archive') continue;
68
+ changes.push(summarizeChange(path.join(changesDir, d.name), d.name, false));
69
+ }
70
+ const archiveDir = path.join(changesDir, 'archive');
71
+ if (fs.existsSync(archiveDir)) {
72
+ for (const d of fs.readdirSync(archiveDir, { withFileTypes: true })) {
73
+ if (!d.isDirectory()) continue;
74
+ changes.push(summarizeChange(path.join(archiveDir, d.name), d.name, true));
75
+ }
76
+ }
77
+ }
78
+
79
+ // Residency: constitution + always-steering effective lines vs budget.
80
+ let alwaysLines = 0;
81
+ const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
82
+ if (fs.existsSync(constitutionPath)) {
83
+ alwaysLines += countEffectiveLines(fs.readFileSync(constitutionPath, 'utf8'));
84
+ }
85
+ const steering = [];
86
+ const pointerHits = new Map();
87
+ for (const c of changes) {
88
+ const dir = c.archived
89
+ ? path.join(sdlcRoot, 'changes', 'archive', c.id)
90
+ : path.join(sdlcRoot, 'changes', c.id);
91
+ for (const e of readJournal(dir)) {
92
+ if (e.event === 'pointer' && e.steering) {
93
+ pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
94
+ }
95
+ }
96
+ }
97
+ // Global journal (events with no active change) counts too.
98
+ for (const e of readJournal(path.join(sdlcRoot, '.state'))) {
99
+ if (e.event === 'pointer' && e.steering) {
100
+ pointerHits.set(e.steering, (pointerHits.get(e.steering) ?? 0) + 1);
101
+ }
102
+ }
103
+
104
+ const steeringDir = path.join(sdlcRoot, 'context', 'steering');
105
+ if (fs.existsSync(steeringDir)) {
106
+ for (const f of fs.readdirSync(steeringDir).filter((n) => n.endsWith('.md')).sort()) {
107
+ const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
108
+ const { data } = parseFrontmatter(raw);
109
+ const inclusion = data.inclusion ?? 'manual';
110
+ if (inclusion === 'always') alwaysLines += countEffectiveLines(raw);
111
+ steering.push({ file: f, inclusion, pointerHits: pointerHits.get(f) ?? 0 });
112
+ }
113
+ }
114
+
115
+ // Flags — plain strings the playbook can surface with suggested fixes.
116
+ const flags = [];
117
+ if (alwaysLines > CAPS.alwaysBudget) {
118
+ flags.push(`residency: always-loaded is ${alwaysLines}/${CAPS.alwaysBudget} lines — distill via /sdlc:steer`);
119
+ }
120
+ const shipped = changes.filter((c) => c.archived);
121
+ for (const s of steering.filter((s) => s.inclusion === 'paths' && s.pointerHits === 0)) {
122
+ if (shipped.length >= 2) {
123
+ flags.push(`steering/${s.file}: zero pointer hits across ${shipped.length} shipped changes — demote or delete`);
124
+ }
125
+ }
126
+ for (const c of changes) {
127
+ if (c.compacts > 0) flags.push(`${c.id}: ${c.compacts} compact event(s) — context overflowed, find the resident artifact`);
128
+ if (c.verify.rounds > 3) flags.push(`${c.id}: ${c.verify.rounds} verify rounds — contract or routing needs attention`);
129
+ }
130
+ for (const c of shipped.filter((c) => c.digest === false)) {
131
+ flags.push(`${c.id}: archived without digest.md — ship playbook step 4 was skipped`);
132
+ }
133
+
134
+ const firstPassRuns = changes.filter((c) => c.verify.firstPass !== null);
135
+ const summary = {
136
+ active: changes.filter((c) => !c.archived).length,
137
+ shipped: shipped.length,
138
+ firstPassRate: firstPassRuns.length
139
+ ? Number((firstPassRuns.filter((c) => c.verify.firstPass).length / firstPassRuns.length).toFixed(2))
140
+ : null,
141
+ };
142
+
143
+ return {
144
+ summary,
145
+ changes,
146
+ residency: { alwaysLines, budget: CAPS.alwaysBudget },
147
+ steering,
148
+ flags,
149
+ };
150
+ }
151
+
152
+ const fmtK = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M`
153
+ : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n));
154
+
155
+ export function renderReport(report) {
156
+ const lines = [];
157
+ const { summary, residency } = report;
158
+ lines.push(`changes: ${summary.active} active · ${summary.shipped} shipped`
159
+ + (summary.firstPassRate != null ? ` · first-pass ${(summary.firstPassRate * 100).toFixed(0)}%` : ''));
160
+ lines.push(`residency: ${residency.alwaysLines}/${residency.budget} always-loaded lines`);
161
+ for (const c of report.changes) {
162
+ const t = c.tokens;
163
+ const lead = c.leadTimeMs != null ? ` · lead ${(c.leadTimeMs / 3_600_000).toFixed(1)}h` : '';
164
+ lines.push(` ${c.archived ? '✓' : '·'} ${c.id} [${c.tier ?? '?'}]`
165
+ + ` ${fmtK(t.input)}in/${fmtK(t.output)}out`
166
+ + (c.costUsd != null ? ` $${c.costUsd}` : '')
167
+ + ` · verify×${c.verify.rounds}${lead}`);
168
+ }
169
+ for (const s of report.steering) {
170
+ lines.push(` steering/${s.file} [${s.inclusion}] hits:${s.pointerHits}`);
171
+ }
172
+ for (const f of report.flags) lines.push(` ⚠ ${f}`);
173
+ return lines.join('\n');
174
+ }
@@ -0,0 +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
+ }
package/lib/usage.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // Transcript-usage parser: Claude Code transcripts are JSONL; assistant
2
+ // entries carry `message.usage` and `message.model`. We sum per model and
3
+ // price via the optional table in sdlc/config.yaml. Never guess: when a
4
+ // price is unknown, cost stays null (reported as n/a).
5
+
6
+ export function parseTranscriptUsage(jsonlText) {
7
+ const byModel = new Map();
8
+ for (const line of (jsonlText ?? '').split('\n')) {
9
+ if (!line.trim()) continue;
10
+ let entry;
11
+ try { entry = JSON.parse(line); } catch { continue; }
12
+ const msg = entry?.message;
13
+ const usage = msg?.usage;
14
+ if (!usage || typeof usage !== 'object') continue;
15
+ const model = msg.model ?? 'unknown';
16
+ const acc = byModel.get(model) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
+ acc.input += usage.input_tokens ?? 0;
18
+ acc.output += usage.output_tokens ?? 0;
19
+ acc.cacheRead += usage.cache_read_input_tokens ?? 0;
20
+ acc.cacheWrite += usage.cache_creation_input_tokens ?? 0;
21
+ byModel.set(model, acc);
22
+ }
23
+ const models = Object.fromEntries(byModel);
24
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
25
+ for (const m of byModel.values()) {
26
+ totals.input += m.input; totals.output += m.output;
27
+ totals.cacheRead += m.cacheRead; totals.cacheWrite += m.cacheWrite;
28
+ }
29
+ return { models, totals };
30
+ }
31
+
32
+ // prices: { "<model>": { input, output, cacheRead } } in USD per 1M tokens.
33
+ export function costUsd(usage, prices) {
34
+ if (!prices) return null;
35
+ let usd = 0;
36
+ let priced = false;
37
+ for (const [model, u] of Object.entries(usage.models)) {
38
+ const p = prices[model];
39
+ if (!p) continue;
40
+ priced = true;
41
+ usd += (u.input * (p.input ?? 0)
42
+ + u.output * (p.output ?? 0)
43
+ + u.cacheRead * (p.cacheRead ?? 0)) / 1_000_000;
44
+ }
45
+ return priced ? Number(usd.toFixed(4)) : null;
46
+ }