@warnyin/sdlc 0.7.0 → 0.9.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 +274 -228
- package/LICENSE +21 -21
- package/README.md +1 -0
- package/bin/cli.mjs +682 -668
- package/lib/active.mjs +199 -199
- package/lib/caps.mjs +46 -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/lenses.mjs +48 -0
- package/lib/manifest.mjs +99 -99
- package/lib/settings-merge.mjs +63 -63
- package/lib/skills.mjs +148 -0
- package/lib/validate.mjs +198 -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 +30 -29
- 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 -138
- package/payload/hooks/guard-writes.mjs +87 -87
- package/payload/hooks/inject-context.mjs +57 -57
- package/payload/hooks/journal.mjs +66 -66
- package/payload/hooks/session-summary.mjs +52 -52
- package/payload/hooks/validate-artifact.mjs +84 -84
- package/payload/playbook/README.md +1 -1
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/contract.md +3 -0
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/design.md +6 -1
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/lenses.md +64 -0
- package/payload/playbook/new.md +19 -3
- package/payload/playbook/next.md +24 -24
- package/payload/playbook/observe.md +20 -20
- package/payload/playbook/principles.md +28 -28
- package/payload/playbook/review.md +7 -2
- package/payload/playbook/routing.md +19 -19
- package/payload/playbook/rules-card.md +17 -16
- package/payload/playbook/ship.md +35 -35
- package/payload/playbook/steer.md +21 -21
- package/payload/playbook/verify.md +6 -1
- 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 +34 -33
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- package/scripts/validate.mjs +47 -47
package/lib/lenses.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Canonical lens list — the single source of truth for which expert lenses a change may
|
|
2
|
+
// record. `payload/playbook/lenses.md` describes each one under `## Lens: <name>`, and
|
|
3
|
+
// tests/lenses.test.mjs fails the build if the two drift.
|
|
4
|
+
//
|
|
5
|
+
// A change records lenses in frontmatter as `<lens>@builtin`, `<lens>@project:<skill>` or
|
|
6
|
+
// `<lens>@user:<skill>`; the skill name uses the inventory's own name rule, so whatever
|
|
7
|
+
// `warnyin-sdlc skills` lists is exactly what a change can record.
|
|
8
|
+
|
|
9
|
+
// Names are only ever added: removing or renaming one turns open changes that recorded it
|
|
10
|
+
// into validation errors, so that needs a migration, not an edit here.
|
|
11
|
+
//
|
|
12
|
+
// SKILL_NAME_RE is owned here (not by the inventory) so the validator, which hooks load on
|
|
13
|
+
// every write, does not pull in filesystem scanning. `.` and `..` are refused so a recorded
|
|
14
|
+
// name can never be a path hop.
|
|
15
|
+
export const SKILL_NAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,64}$/;
|
|
16
|
+
|
|
17
|
+
export const LENSES = Object.freeze(['ux-ui', 'api', 'data']);
|
|
18
|
+
|
|
19
|
+
const ENTRY_RE = /^([^@\s]+)@(builtin|project:(.*)|user:(.*))$/;
|
|
20
|
+
|
|
21
|
+
// Returns error strings, each naming the offending entry. An absent or empty list is valid:
|
|
22
|
+
// no lens means no stage loads one.
|
|
23
|
+
export function lensErrors(value) {
|
|
24
|
+
if (value === undefined) return [];
|
|
25
|
+
if (!Array.isArray(value)) return [`lenses must be a list, got "${value}"`];
|
|
26
|
+
const errors = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const raw of value) {
|
|
29
|
+
const entry = String(raw);
|
|
30
|
+
const m = entry.match(ENTRY_RE);
|
|
31
|
+
if (!m) {
|
|
32
|
+
errors.push(`lens entry "${entry}" must be <lens>@builtin, <lens>@project:<skill> or <lens>@user:<skill>`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const [, lens, , projectSkill, userSkill] = m;
|
|
36
|
+
const skill = projectSkill ?? userSkill;
|
|
37
|
+
if (!LENSES.includes(lens)) {
|
|
38
|
+
errors.push(`lens entry "${entry}" names unknown lens "${lens}" (catalog: ${LENSES.join('|')})`);
|
|
39
|
+
} else if (seen.has(lens)) {
|
|
40
|
+
errors.push(`lens "${lens}" is recorded more than once — keep one source per lens`);
|
|
41
|
+
}
|
|
42
|
+
if (skill !== undefined && !SKILL_NAME_RE.test(skill)) {
|
|
43
|
+
errors.push(`lens entry "${entry}" has an invalid skill name (allowed: ${SKILL_NAME_RE.source})`);
|
|
44
|
+
}
|
|
45
|
+
seen.add(lens);
|
|
46
|
+
}
|
|
47
|
+
return errors;
|
|
48
|
+
}
|
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/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
|
+
}
|
package/lib/skills.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Skill/agent inventory — what expertise is already installed for this project and user.
|
|
2
|
+
// Used by: CLI (`warnyin-sdlc skills`), which the opening playbook reads to resolve lenses.
|
|
3
|
+
//
|
|
4
|
+
// Skill files are third-party content, so the inventory is deliberately shallow: it reads a
|
|
5
|
+
// bounded prefix of each file, keeps only frontmatter `name` + `description`, and never
|
|
6
|
+
// emits body text. Project entries whose real location leaves the project are skipped (a
|
|
7
|
+
// repo can ship a link); the user's own home may link wherever the user put their skills.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
13
|
+
import { containedIn } from './manifest.mjs';
|
|
14
|
+
import { SKILL_NAME_RE } from './lenses.mjs';
|
|
15
|
+
|
|
16
|
+
export { SKILL_NAME_RE };
|
|
17
|
+
export const INVENTORY_CEILING = 200;
|
|
18
|
+
// Per-directory bound on how many candidates are opened at all: a checked-out repo can plant
|
|
19
|
+
// thousands of folders, and the ceiling alone would still pay to read every one of them.
|
|
20
|
+
export const SCAN_LIMIT_PER_DIR = 1000;
|
|
21
|
+
export const DESCRIPTION_MAX = 160;
|
|
22
|
+
export const READ_PREFIX_BYTES = 8 * 1024;
|
|
23
|
+
|
|
24
|
+
const KINDS = Object.freeze(['skill', 'agent']);
|
|
25
|
+
// Built from code points so the source file itself carries no invisible characters: C0, DEL,
|
|
26
|
+
// C1, zero-width and bidi marks/overrides, line/paragraph separators, BOM — anything that
|
|
27
|
+
// can move a terminal cursor or visually reorder the line a human or model reads.
|
|
28
|
+
const cp = (n) => String.fromCharCode(n);
|
|
29
|
+
const CONTROL_CHARS = new RegExp(
|
|
30
|
+
`[${cp(0)}-${cp(0x1f)}${cp(0x7f)}-${cp(0x9f)}${cp(0x200b)}-${cp(0x200f)}${cp(0x2028)}-${cp(0x202e)}${cp(0x2066)}-${cp(0x2069)}${cp(0xfeff)}]+`,
|
|
31
|
+
'g');
|
|
32
|
+
const LEADING_BOM = new RegExp(`^${String.fromCharCode(0xfeff)}`);
|
|
33
|
+
|
|
34
|
+
// Reads at most READ_PREFIX_BYTES; a frontmatter that does not close inside that prefix is
|
|
35
|
+
// treated as absent rather than read further. The open is non-blocking where the platform
|
|
36
|
+
// has it, and the descriptor itself must be a regular file, so a FIFO or device swapped in
|
|
37
|
+
// after the earlier checks can neither hang nor feed the read.
|
|
38
|
+
function readPrefix(file) {
|
|
39
|
+
let fd;
|
|
40
|
+
try {
|
|
41
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
42
|
+
if (!fs.fstatSync(fd).isFile()) return null;
|
|
43
|
+
const buf = Buffer.alloc(READ_PREFIX_BYTES);
|
|
44
|
+
const n = fs.readSync(fd, buf, 0, READ_PREFIX_BYTES, 0);
|
|
45
|
+
return buf.subarray(0, n).toString('utf8').replace(LEADING_BOM, '');
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
} finally {
|
|
49
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One line, no control characters (terminal escapes included), cut with a visible marker.
|
|
54
|
+
export function cleanDescription(raw) {
|
|
55
|
+
const flat = String(raw).replace(CONTROL_CHARS, ' ').replace(/\s+/g, ' ').trim();
|
|
56
|
+
if (flat.length <= DESCRIPTION_MAX) return { description: flat, truncated: false };
|
|
57
|
+
return { description: `${flat.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…`, truncated: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readEntry(file) {
|
|
61
|
+
const text = readPrefix(file);
|
|
62
|
+
if (text === null) return null;
|
|
63
|
+
const { data } = parseFrontmatter(text);
|
|
64
|
+
const name = typeof data.name === 'string' ? data.name.trim() : '';
|
|
65
|
+
if (!SKILL_NAME_RE.test(name)) return null;
|
|
66
|
+
if (typeof data.description !== 'string' || data.description.trim() === '') return null;
|
|
67
|
+
return { name, ...cleanDescription(data.description) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isInside(rootReal, target) {
|
|
71
|
+
try {
|
|
72
|
+
return containedIn(rootReal, fs.realpathSync.native(target));
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The first SCAN_LIMIT_PER_DIR names (sorted), and how many were left unopened.
|
|
79
|
+
function listDir(dir) {
|
|
80
|
+
let names;
|
|
81
|
+
try {
|
|
82
|
+
names = fs.readdirSync(dir).sort();
|
|
83
|
+
} catch {
|
|
84
|
+
return { names: [], unscanned: 0 };
|
|
85
|
+
}
|
|
86
|
+
return { names: names.slice(0, SCAN_LIMIT_PER_DIR), unscanned: Math.max(0, names.length - SCAN_LIMIT_PER_DIR) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isFile(p) {
|
|
90
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Candidate files for one `.claude` root, each paired with every path that must stay contained.
|
|
94
|
+
function candidates(claudeDir, kind) {
|
|
95
|
+
if (kind === 'skill') {
|
|
96
|
+
const skillsDir = path.join(claudeDir, 'skills');
|
|
97
|
+
const { names, unscanned } = listDir(skillsDir);
|
|
98
|
+
const items = names.map((d) => {
|
|
99
|
+
const folder = path.join(skillsDir, d);
|
|
100
|
+
const file = path.join(folder, 'SKILL.md');
|
|
101
|
+
return { file, checks: [folder, file] };
|
|
102
|
+
});
|
|
103
|
+
return { items, unscanned };
|
|
104
|
+
}
|
|
105
|
+
const agentsDir = path.join(claudeDir, 'agents');
|
|
106
|
+
const { names, unscanned } = listDir(agentsDir);
|
|
107
|
+
const items = names
|
|
108
|
+
.filter((f) => f.endsWith('.md'))
|
|
109
|
+
.map((f) => ({ file: path.join(agentsDir, f), checks: [path.join(agentsDir, f)] }));
|
|
110
|
+
return { items, unscanned };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scanRoot(claudeDir, source, containRoot) {
|
|
114
|
+
const entries = [];
|
|
115
|
+
let unscanned = 0;
|
|
116
|
+
for (const kind of KINDS) {
|
|
117
|
+
const found = candidates(claudeDir, kind);
|
|
118
|
+
unscanned += found.unscanned;
|
|
119
|
+
for (const { file, checks } of found.items) {
|
|
120
|
+
if (containRoot && !checks.every((c) => isInside(containRoot, c))) continue;
|
|
121
|
+
if (!isFile(file)) continue;
|
|
122
|
+
const entry = readEntry(file);
|
|
123
|
+
if (entry) entries.push({ source, kind, ...entry });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
entries.sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || a.name.localeCompare(b.name));
|
|
127
|
+
return { entries, unscanned };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function scanInventory(projectRoot, { home = os.homedir() } = {}) {
|
|
131
|
+
let projectReal = null;
|
|
132
|
+
try { projectReal = fs.realpathSync.native(projectRoot); } catch { /* unreadable root → no project entries */ }
|
|
133
|
+
const none = { entries: [], unscanned: 0 };
|
|
134
|
+
const project = projectReal ? scanRoot(path.join(projectRoot, '.claude'), 'project', projectReal) : none;
|
|
135
|
+
const user = home ? scanRoot(path.join(home, '.claude'), 'user', null) : none;
|
|
136
|
+
const all = [...project.entries, ...user.entries];
|
|
137
|
+
// `omitted` = valid entries past the ceiling + candidates never opened (scan limit).
|
|
138
|
+
return {
|
|
139
|
+
entries: all.slice(0, INVENTORY_CEILING),
|
|
140
|
+
omitted: Math.max(0, all.length - INVENTORY_CEILING) + project.unscanned + user.unscanned,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderInventory({ entries, omitted }) {
|
|
145
|
+
const lines = entries.map((e) => `${e.source} ${e.kind} ${e.name} ${e.description}`);
|
|
146
|
+
if (omitted > 0) lines.push(`… ${omitted} more not listed`);
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|