@warnyin/sdlc 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -172
- package/LICENSE +21 -21
- package/README.md +1 -1
- package/bin/cli.mjs +668 -596
- package/lib/active.mjs +199 -0
- package/lib/caps.mjs +45 -45
- package/lib/config.mjs +41 -41
- package/lib/delta.mjs +227 -227
- package/lib/frontmatter.mjs +59 -59
- package/lib/glob.mjs +29 -29
- package/lib/journal.mjs +128 -0
- package/lib/manifest.mjs +99 -99
- package/lib/observe.mjs +19 -16
- package/lib/settings-merge.mjs +63 -63
- package/lib/validate.mjs +196 -196
- package/package.json +42 -42
- package/payload/adapters/agents-md.md +8 -8
- package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
- package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
- package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
- package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
- package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
- package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
- package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
- package/payload/adapters/claude/agents/sdlc-security.md +12 -12
- package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
- package/payload/adapters/claude/commands/sdlc/init.md +4 -4
- package/payload/adapters/claude/commands/sdlc/next.md +4 -4
- package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
- package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
- package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +29 -26
- package/payload/adapters/cline.md +8 -8
- package/payload/adapters/copilot.md +8 -8
- package/payload/adapters/cursor.mdc +7 -7
- package/payload/adapters/gemini.md +8 -8
- package/payload/adapters/windsurf.md +4 -4
- package/payload/hooks/_shared.mjs +138 -154
- package/payload/hooks/guard-writes.mjs +87 -83
- package/payload/hooks/inject-context.mjs +57 -55
- package/payload/hooks/journal.mjs +66 -58
- package/payload/hooks/session-summary.mjs +52 -50
- package/payload/hooks/validate-artifact.mjs +84 -80
- package/payload/playbook/auto.md +12 -0
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/next.md +24 -14
- package/payload/playbook/observe.md +20 -20
- package/payload/playbook/principles.md +28 -28
- package/payload/playbook/routing.md +19 -19
- package/payload/playbook/rules-card.md +16 -16
- package/payload/playbook/ship.md +35 -35
- package/payload/playbook/steer.md +21 -21
- package/payload/templates/change-deep.md +29 -29
- package/payload/templates/change-standard.md +28 -28
- package/payload/templates/change-vibe.md +19 -19
- package/payload/templates/config.yaml +8 -8
- package/payload/templates/constitution.md +14 -14
- package/payload/templates/contract-evals.md +9 -9
- package/payload/templates/contract-tests.md +9 -9
- package/payload/templates/harness.md +33 -33
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- package/scripts/validate.mjs +47 -47
package/lib/journal.mjs
ADDED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
}
|
package/lib/settings-merge.mjs
CHANGED
|
@@ -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
|
+
}
|