@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
@@ -1,83 +1,83 @@
1
- #!/usr/bin/env node
2
- // PreToolUse hook — the deterministic guardrail ("things the agent should
3
- // never forget but often does"). Denies direct edits to:
4
- // sdlc/specs/** outside an open ship gate
5
- // sdlc/changes/archive/** outside an open ship gate
6
- // sdlc/context/constitution.md (existing) outside an open steer gate
7
- // sdlc/.state/** and any journal.ndjson always (machine-owned)
8
- // The sanctioned paths are the CLI (`warnyin-sdlc archive`) and the gates
9
- // opened by `journal.mjs open-ship|open-steer`.
10
-
11
- import fs from 'node:fs';
12
- import process from 'node:process';
13
- import path from 'node:path';
14
- import {
15
- resolveRoots, readStdinJson, readPhase, activeChange, appendJournal, toPosixRel, lexicalPosixRel,
16
- } from './_shared.mjs';
17
-
18
- const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
-
20
- function deny(reason, rel) {
21
- appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'guard', action: 'deny', path: rel, reason });
22
- console.log(JSON.stringify({
23
- hookSpecificOutput: {
24
- hookEventName: 'PreToolUse',
25
- permissionDecision: 'deny',
26
- permissionDecisionReason: reason,
27
- },
28
- }));
29
- }
30
-
31
- // Evaluate the lock rules against ONE view of the path. Returns true when a
32
- // deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
33
- // realpath-resolved view — a symlink must never weaken a lock.
34
- function guard(rel, phase) {
35
- if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
36
- deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel);
37
- return true;
38
- }
39
- if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
40
- if (phase?.phase === 'ship') return false;
41
- deny(
42
- `"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
43
- + 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
44
- rel,
45
- );
46
- return true;
47
- }
48
- if (rel === 'sdlc/context/constitution.md' && fs.existsSync(path.join(projectRoot, rel))) {
49
- if (phase?.phase === 'steer' || phase?.phase === 'ship') return false;
50
- deny(
51
- 'The constitution is always-loaded context — edits go through /sdlc:steer '
52
- + '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
53
- rel,
54
- );
55
- return true;
56
- }
57
- return false;
58
- }
59
-
60
- async function main() {
61
- const input = await readStdinJson();
62
- const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
63
- if (!filePath || !fs.existsSync(sdlcRoot)) return;
64
-
65
- const abs = path.resolve(projectRoot, filePath);
66
- const relLexical = lexicalPosixRel(projectRoot, abs);
67
- const relReal = toPosixRel(projectRoot, abs);
68
-
69
- // A path that CLAIMS to live under sdlc/ but resolves elsewhere (or out of
70
- // the project) went through a symlink — deny conservatively; a symlink must
71
- // never disable the write-lock.
72
- if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
73
- deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical);
74
- return;
75
- }
76
-
77
- const phase = readPhase(sdlcRoot);
78
- for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
79
- if (rel.startsWith('sdlc/') && guard(rel, phase)) return;
80
- }
81
- }
82
-
83
- main().catch(() => process.exit(0)); // fail open
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook — the deterministic guardrail ("things the agent should
3
+ // never forget but often does"). Denies direct edits to:
4
+ // sdlc/specs/** outside an open ship gate
5
+ // sdlc/changes/archive/** outside an open ship gate
6
+ // sdlc/context/constitution.md (existing) outside an open steer gate
7
+ // sdlc/.state/** and any journal.ndjson always (machine-owned)
8
+ // The sanctioned paths are the CLI (`warnyin-sdlc archive`) and the gates
9
+ // opened by `journal.mjs open-ship|open-steer`.
10
+
11
+ import fs from 'node:fs';
12
+ import process from 'node:process';
13
+ import path from 'node:path';
14
+ import {
15
+ resolveRoots, readStdinJson, readPhase, activeChange, appendJournal, toPosixRel, lexicalPosixRel,
16
+ } from './_shared.mjs';
17
+
18
+ const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
19
+
20
+ function deny(reason, rel) {
21
+ appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: 'guard', action: 'deny', path: rel, reason });
22
+ console.log(JSON.stringify({
23
+ hookSpecificOutput: {
24
+ hookEventName: 'PreToolUse',
25
+ permissionDecision: 'deny',
26
+ permissionDecisionReason: reason,
27
+ },
28
+ }));
29
+ }
30
+
31
+ // Evaluate the lock rules against ONE view of the path. Returns true when a
32
+ // deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
33
+ // realpath-resolved view — a symlink must never weaken a lock.
34
+ function guard(rel, phase) {
35
+ if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
36
+ deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel);
37
+ return true;
38
+ }
39
+ if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
40
+ if (phase?.phase === 'ship') return false;
41
+ deny(
42
+ `"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
43
+ + 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
44
+ rel,
45
+ );
46
+ return true;
47
+ }
48
+ if (rel === 'sdlc/context/constitution.md' && fs.existsSync(path.join(projectRoot, rel))) {
49
+ if (phase?.phase === 'steer' || phase?.phase === 'ship') return false;
50
+ deny(
51
+ 'The constitution is always-loaded context — edits go through /sdlc:steer '
52
+ + '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
53
+ rel,
54
+ );
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ async function main() {
61
+ const input = await readStdinJson();
62
+ const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
63
+ if (!filePath || !fs.existsSync(sdlcRoot)) return;
64
+
65
+ const abs = path.resolve(projectRoot, filePath);
66
+ const relLexical = lexicalPosixRel(projectRoot, abs);
67
+ const relReal = toPosixRel(projectRoot, abs);
68
+
69
+ // A path that CLAIMS to live under sdlc/ but resolves elsewhere (or out of
70
+ // the project) went through a symlink — deny conservatively; a symlink must
71
+ // never disable the write-lock.
72
+ if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
73
+ deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical);
74
+ return;
75
+ }
76
+
77
+ const phase = readPhase(sdlcRoot);
78
+ for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
79
+ if (rel.startsWith('sdlc/') && guard(rel, phase)) return;
80
+ }
81
+ }
82
+
83
+ main().catch(() => process.exit(0)); // fail open
@@ -1,55 +1,55 @@
1
- #!/usr/bin/env node
2
- // SessionStart hook — THE static-context loader. Emits (hard cap 60 lines):
3
- // constitution + every `inclusion: always` steering file + a one-line
4
- // pointer to the active change. Everything else stays dynamic.
5
- // Journals what was injected so /sdlc:observe can price residency honestly.
6
-
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import process from 'node:process';
10
- import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
- import { parseFrontmatter } from './lib/frontmatter.mjs';
12
- import { CAPS } from './lib/caps.mjs';
13
-
14
- const { sdlcRoot } = resolveRoots(import.meta.url);
15
-
16
- async function main() {
17
- await readStdinJson(); // drain; content not needed
18
- if (!fs.existsSync(sdlcRoot)) return;
19
-
20
- const injected = [];
21
- const out = [];
22
-
23
- const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
24
- if (fs.existsSync(constitutionPath)) {
25
- out.push(fs.readFileSync(constitutionPath, 'utf8').trim());
26
- injected.push('context/constitution.md');
27
- }
28
-
29
- const steeringDir = path.join(sdlcRoot, 'context', 'steering');
30
- if (fs.existsSync(steeringDir)) {
31
- for (const f of fs.readdirSync(steeringDir).filter((n) => n.endsWith('.md')).sort()) {
32
- const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
33
- const { data, body } = parseFrontmatter(raw);
34
- if (data.inclusion !== 'always') continue;
35
- out.push(body.trim());
36
- injected.push(`context/steering/${f}`);
37
- }
38
- }
39
-
40
- const active = activeChange(sdlcRoot);
41
- if (active) out.push(`Active change: sdlc/changes/${active}/change.md — run /sdlc:next for status.`);
42
-
43
- if (!out.length) return;
44
-
45
- let lines = out.join('\n\n').split('\n');
46
- if (lines.length > CAPS.alwaysBudget) {
47
- lines = lines.slice(0, CAPS.alwaysBudget);
48
- lines.push(`[sdlc] static context truncated at ${CAPS.alwaysBudget} lines — run /sdlc:steer to distill (validate also flags this).`);
49
- }
50
- console.log(lines.join('\n'));
51
-
52
- appendJournal(sdlcRoot, active, { event: 'inject', files: injected, lines: lines.length });
53
- }
54
-
55
- main().catch(() => process.exit(0)); // fail open
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — THE static-context loader. Emits (hard cap 60 lines):
3
+ // constitution + every `inclusion: always` steering file + a one-line
4
+ // pointer to the active change. Everything else stays dynamic.
5
+ // Journals what was injected so /sdlc:observe can price residency honestly.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
12
+ import { CAPS } from './lib/caps.mjs';
13
+
14
+ const { sdlcRoot } = resolveRoots(import.meta.url);
15
+
16
+ async function main() {
17
+ await readStdinJson(); // drain; content not needed
18
+ if (!fs.existsSync(sdlcRoot)) return;
19
+
20
+ const injected = [];
21
+ const out = [];
22
+
23
+ const constitutionPath = path.join(sdlcRoot, 'context', 'constitution.md');
24
+ if (fs.existsSync(constitutionPath)) {
25
+ out.push(fs.readFileSync(constitutionPath, 'utf8').trim());
26
+ injected.push('context/constitution.md');
27
+ }
28
+
29
+ const steeringDir = path.join(sdlcRoot, 'context', 'steering');
30
+ if (fs.existsSync(steeringDir)) {
31
+ for (const f of fs.readdirSync(steeringDir).filter((n) => n.endsWith('.md')).sort()) {
32
+ const raw = fs.readFileSync(path.join(steeringDir, f), 'utf8');
33
+ const { data, body } = parseFrontmatter(raw);
34
+ if (data.inclusion !== 'always') continue;
35
+ out.push(body.trim());
36
+ injected.push(`context/steering/${f}`);
37
+ }
38
+ }
39
+
40
+ const active = activeChange(sdlcRoot);
41
+ if (active) out.push(`Active change: sdlc/changes/${active}/change.md — run /sdlc:next for status.`);
42
+
43
+ if (!out.length) return;
44
+
45
+ let lines = out.join('\n\n').split('\n');
46
+ if (lines.length > CAPS.alwaysBudget) {
47
+ lines = lines.slice(0, CAPS.alwaysBudget);
48
+ lines.push(`[sdlc] static context truncated at ${CAPS.alwaysBudget} lines — run /sdlc:steer to distill (validate also flags this).`);
49
+ }
50
+ console.log(lines.join('\n'));
51
+
52
+ appendJournal(sdlcRoot, active, { event: 'inject', files: injected, lines: lines.length });
53
+ }
54
+
55
+ main().catch(() => process.exit(0)); // fail open
@@ -1,58 +1,58 @@
1
- #!/usr/bin/env node
2
- // Gate + journal utility. Called by playbooks (sanctioned phase transitions)
3
- // and wired as the PreCompact hook (`journal.mjs note compact`).
4
- //
5
- // node sdlc/.hooks/journal.mjs open-ship <change-id> unlock specs/archive writes (TTL 30m)
6
- // node sdlc/.hooks/journal.mjs open-steer unlock constitution edits (TTL 30m)
7
- // node sdlc/.hooks/journal.mjs close close any open gate
8
- // node sdlc/.hooks/journal.mjs set-active <change-id> attribute sessions/events to a change
9
- // node sdlc/.hooks/journal.mjs note <name> [k=v ...] append a journal event
10
-
11
- import fs from 'node:fs';
12
- import path from 'node:path';
13
- import process from 'node:process';
14
- import {
15
- resolveRoots, readStdinJson, writePhase, clearPhase, activeChange, appendJournal,
16
- } from './_shared.mjs';
17
-
18
- const { sdlcRoot } = resolveRoots(import.meta.url);
19
-
20
- async function main() {
21
- const [cmd, ...rest] = process.argv.slice(2);
22
-
23
- if (cmd === 'open-ship') {
24
- const change = rest[0];
25
- if (!change) { console.error('usage: journal.mjs open-ship <change-id>'); process.exit(2); }
26
- const phase = writePhase(sdlcRoot, { phase: 'ship', change });
27
- appendJournal(sdlcRoot, change, { event: 'gate', gate: 'ship', action: 'open', expires: phase.expires });
28
- console.log(`ship gate open for "${change}" until ${phase.expires}`);
29
- } else if (cmd === 'open-steer') {
30
- const phase = writePhase(sdlcRoot, { phase: 'steer' });
31
- appendJournal(sdlcRoot, null, { event: 'gate', gate: 'steer', action: 'open', expires: phase.expires });
32
- console.log(`steer gate open until ${phase.expires}`);
33
- } else if (cmd === 'close') {
34
- clearPhase(sdlcRoot);
35
- console.log('gate closed');
36
- } else if (cmd === 'set-active') {
37
- const change = rest[0];
38
- if (!change) { console.error('usage: journal.mjs set-active <change-id>'); process.exit(2); }
39
- fs.mkdirSync(path.join(sdlcRoot, '.state'), { recursive: true });
40
- fs.writeFileSync(path.join(sdlcRoot, '.state', 'active.json'), JSON.stringify({ change }));
41
- console.log(`active change: ${change}`);
42
- } else if (cmd === 'note') {
43
- // When used as a hook, drain stdin so the harness never blocks on us.
44
- if (!process.stdin.isTTY) await readStdinJson();
45
- const name = rest[0] ?? 'note';
46
- const extra = {};
47
- for (const kv of rest.slice(1)) {
48
- const [k, ...v] = kv.split('=');
49
- if (k && v.length) extra[k] = v.join('=');
50
- }
51
- appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: name, ...extra });
52
- } else {
53
- console.error('usage: journal.mjs open-ship|open-steer|close|set-active|note ...');
54
- process.exit(2);
55
- }
56
- }
57
-
58
- main().catch(() => process.exit(0)); // fail open
1
+ #!/usr/bin/env node
2
+ // Gate + journal utility. Called by playbooks (sanctioned phase transitions)
3
+ // and wired as the PreCompact hook (`journal.mjs note compact`).
4
+ //
5
+ // node sdlc/.hooks/journal.mjs open-ship <change-id> unlock specs/archive writes (TTL 30m)
6
+ // node sdlc/.hooks/journal.mjs open-steer unlock constitution edits (TTL 30m)
7
+ // node sdlc/.hooks/journal.mjs close close any open gate
8
+ // node sdlc/.hooks/journal.mjs set-active <change-id> attribute sessions/events to a change
9
+ // node sdlc/.hooks/journal.mjs note <name> [k=v ...] append a journal event
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+ import {
15
+ resolveRoots, readStdinJson, writePhase, clearPhase, activeChange, appendJournal,
16
+ } from './_shared.mjs';
17
+
18
+ const { sdlcRoot } = resolveRoots(import.meta.url);
19
+
20
+ async function main() {
21
+ const [cmd, ...rest] = process.argv.slice(2);
22
+
23
+ if (cmd === 'open-ship') {
24
+ const change = rest[0];
25
+ if (!change) { console.error('usage: journal.mjs open-ship <change-id>'); process.exit(2); }
26
+ const phase = writePhase(sdlcRoot, { phase: 'ship', change });
27
+ appendJournal(sdlcRoot, change, { event: 'gate', gate: 'ship', action: 'open', expires: phase.expires });
28
+ console.log(`ship gate open for "${change}" until ${phase.expires}`);
29
+ } else if (cmd === 'open-steer') {
30
+ const phase = writePhase(sdlcRoot, { phase: 'steer' });
31
+ appendJournal(sdlcRoot, null, { event: 'gate', gate: 'steer', action: 'open', expires: phase.expires });
32
+ console.log(`steer gate open until ${phase.expires}`);
33
+ } else if (cmd === 'close') {
34
+ clearPhase(sdlcRoot);
35
+ console.log('gate closed');
36
+ } else if (cmd === 'set-active') {
37
+ const change = rest[0];
38
+ if (!change) { console.error('usage: journal.mjs set-active <change-id>'); process.exit(2); }
39
+ fs.mkdirSync(path.join(sdlcRoot, '.state'), { recursive: true });
40
+ fs.writeFileSync(path.join(sdlcRoot, '.state', 'active.json'), JSON.stringify({ change }));
41
+ console.log(`active change: ${change}`);
42
+ } else if (cmd === 'note') {
43
+ // When used as a hook, drain stdin so the harness never blocks on us.
44
+ if (!process.stdin.isTTY) await readStdinJson();
45
+ const name = rest[0] ?? 'note';
46
+ const extra = {};
47
+ for (const kv of rest.slice(1)) {
48
+ const [k, ...v] = kv.split('=');
49
+ if (k && v.length) extra[k] = v.join('=');
50
+ }
51
+ appendJournal(sdlcRoot, activeChange(sdlcRoot), { event: name, ...extra });
52
+ } else {
53
+ console.error('usage: journal.mjs open-ship|open-steer|close|set-active|note ...');
54
+ process.exit(2);
55
+ }
56
+ }
57
+
58
+ main().catch(() => process.exit(0)); // fail open
@@ -1,50 +1,50 @@
1
- #!/usr/bin/env node
2
- // Stop hook — the observability tap. Parses the session transcript's real
3
- // usage numbers, attributes them to the active change, appends a `session`
4
- // journal event, and prints a one-line summary. Cost is computed only when
5
- // sdlc/config.yaml provides a price table — otherwise reported as n/a.
6
-
7
- import fs from 'node:fs';
8
- import path from 'node:path';
9
- import process from 'node:process';
10
- import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
- import { parseTranscriptUsage, costUsd } from './lib/usage.mjs';
12
- import { parseConfig } from './lib/config.mjs';
13
-
14
- const { sdlcRoot } = resolveRoots(import.meta.url);
15
-
16
- const fmt = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M`
17
- : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n));
18
-
19
- async function main() {
20
- const input = await readStdinJson();
21
- const transcriptPath = input?.transcript_path;
22
- if (!transcriptPath || !fs.existsSync(transcriptPath) || !fs.existsSync(sdlcRoot)) return;
23
-
24
- const usage = parseTranscriptUsage(fs.readFileSync(transcriptPath, 'utf8'));
25
- if (!usage.totals.input && !usage.totals.output) return;
26
-
27
- let prices = null;
28
- try {
29
- prices = parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8')).prices;
30
- } catch { /* no config, no cost */ }
31
- const usd = costUsd(usage, prices);
32
-
33
- const change = activeChange(sdlcRoot);
34
- appendJournal(sdlcRoot, change, {
35
- event: 'session',
36
- session: input?.session_id ?? null,
37
- totals: usage.totals,
38
- models: usage.models,
39
- costUsd: usd,
40
- });
41
-
42
- const t = usage.totals;
43
- console.log(
44
- `[sdlc] session: ${fmt(t.input)} in / ${fmt(t.output)} out / ${fmt(t.cacheRead)} cache-read`
45
- + ` · cost ${usd == null ? 'n/a' : `$${usd}`}`
46
- + (change ? ` · change ${change}` : ''),
47
- );
48
- }
49
-
50
- main().catch(() => process.exit(0)); // fail open
1
+ #!/usr/bin/env node
2
+ // Stop hook — the observability tap. Parses the session transcript's real
3
+ // usage numbers, attributes them to the active change, appends a `session`
4
+ // journal event, and prints a one-line summary. Cost is computed only when
5
+ // sdlc/config.yaml provides a price table — otherwise reported as n/a.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { resolveRoots, readStdinJson, activeChange, appendJournal } from './_shared.mjs';
11
+ import { parseTranscriptUsage, costUsd } from './lib/usage.mjs';
12
+ import { parseConfig } from './lib/config.mjs';
13
+
14
+ const { sdlcRoot } = resolveRoots(import.meta.url);
15
+
16
+ const fmt = (n) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M`
17
+ : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : String(n));
18
+
19
+ async function main() {
20
+ const input = await readStdinJson();
21
+ const transcriptPath = input?.transcript_path;
22
+ if (!transcriptPath || !fs.existsSync(transcriptPath) || !fs.existsSync(sdlcRoot)) return;
23
+
24
+ const usage = parseTranscriptUsage(fs.readFileSync(transcriptPath, 'utf8'));
25
+ if (!usage.totals.input && !usage.totals.output) return;
26
+
27
+ let prices = null;
28
+ try {
29
+ prices = parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8')).prices;
30
+ } catch { /* no config, no cost */ }
31
+ const usd = costUsd(usage, prices);
32
+
33
+ const change = activeChange(sdlcRoot);
34
+ appendJournal(sdlcRoot, change, {
35
+ event: 'session',
36
+ session: input?.session_id ?? null,
37
+ totals: usage.totals,
38
+ models: usage.models,
39
+ costUsd: usd,
40
+ });
41
+
42
+ const t = usage.totals;
43
+ console.log(
44
+ `[sdlc] session: ${fmt(t.input)} in / ${fmt(t.output)} out / ${fmt(t.cacheRead)} cache-read`
45
+ + ` · cost ${usd == null ? 'n/a' : `$${usd}`}`
46
+ + (change ? ` · change ${change}` : ''),
47
+ );
48
+ }
49
+
50
+ main().catch(() => process.exit(0)); // fail open