@warnyin/sdlc 0.5.1 → 0.5.2
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 +15 -0
- package/LICENSE +21 -21
- package/bin/cli.mjs +12 -1
- package/lib/caps.mjs +45 -45
- package/lib/config.mjs +41 -41
- package/lib/delta.mjs +227 -160
- package/lib/frontmatter.mjs +59 -59
- package/lib/glob.mjs +29 -29
- package/lib/manifest.mjs +99 -99
- package/lib/settings-merge.mjs +63 -63
- package/lib/validate.mjs +196 -186
- 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 -33
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +26 -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 +154 -154
- package/payload/hooks/guard-writes.mjs +83 -83
- package/payload/hooks/inject-context.mjs +55 -55
- package/payload/hooks/journal.mjs +58 -58
- package/payload/hooks/session-summary.mjs +50 -50
- package/payload/hooks/validate-artifact.mjs +80 -80
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/init.md +22 -22
- 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 +2 -0
- 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/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
|
+
}
|