@warnyin/sdlc 0.8.0 → 0.10.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 +293 -258
- package/LICENSE +21 -21
- package/README.md +118 -92
- package/bin/cli.mjs +694 -682
- package/lib/active.mjs +199 -199
- package/lib/caps.mjs +46 -46
- 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 -48
- package/lib/manifest.mjs +99 -99
- package/lib/settings-merge.mjs +63 -63
- package/lib/skills.mjs +148 -148
- package/lib/update-notice.mjs +42 -0
- package/lib/validate.mjs +198 -198
- package/lib/version.mjs +23 -0
- 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 -30
- 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/_update-notice.mjs +81 -0
- package/payload/hooks/check-update.mjs +45 -0
- package/payload/hooks/guard-writes.mjs +87 -87
- package/payload/hooks/inject-context.mjs +61 -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 +32 -32
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/contract.md +29 -29
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/design.md +28 -28
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/lenses.md +64 -64
- package/payload/playbook/new.md +41 -33
- 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 +31 -31
- 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 +42 -42
- 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 +12 -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 -34
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- package/scripts/validate.mjs +47 -47
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
// Shared plumbing for installed hooks. This file lives at
|
|
2
|
-
// <project>/sdlc/.hooks/_shared.mjs with lib/ as a sibling directory.
|
|
3
|
-
// Every hook must be fail-open: on any unexpected condition, exit 0 silently
|
|
4
|
-
// so the harness is never blocked by our tooling.
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import process from 'node:process';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { liveJournalPath, globalJournalPath, appendEvent } from './lib/journal.mjs';
|
|
11
|
-
import { resolveActive } from './lib/active.mjs';
|
|
12
|
-
|
|
13
|
-
export function resolveRoots(importMetaUrl) {
|
|
14
|
-
const hooksDir = path.dirname(fileURLToPath(importMetaUrl));
|
|
15
|
-
const sdlcRoot = path.dirname(hooksDir);
|
|
16
|
-
const projectRoot = path.dirname(sdlcRoot);
|
|
17
|
-
return { hooksDir, sdlcRoot, projectRoot };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Reads the hook payload from stdin. Must NEVER hang: when a playbook or a
|
|
21
|
-
// user script invokes a hook utility with stdin open-but-idle (no piped JSON),
|
|
22
|
-
// resolve null after a short grace period instead of blocking forever.
|
|
23
|
-
export function readStdinJson({ timeoutMs = 1000 } = {}) {
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
let data = '';
|
|
26
|
-
let done = false;
|
|
27
|
-
const finish = () => {
|
|
28
|
-
if (done) return;
|
|
29
|
-
done = true;
|
|
30
|
-
// Release stdin so an open-idle stream cannot keep the event loop alive.
|
|
31
|
-
process.stdin.pause();
|
|
32
|
-
if (typeof process.stdin.unref === 'function') process.stdin.unref();
|
|
33
|
-
try { resolve(data.trim() ? JSON.parse(data) : null); } catch { resolve(null); }
|
|
34
|
-
};
|
|
35
|
-
const timer = setTimeout(finish, timeoutMs);
|
|
36
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
37
|
-
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
38
|
-
process.stdin.on('end', finish);
|
|
39
|
-
process.stdin.on('error', finish);
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Resolve symlinks on the deepest EXISTING ancestor, then re-attach the tail.
|
|
44
|
-
// Needed because import.meta.url is symlink-resolved while tool file_paths may
|
|
45
|
-
// arrive through a symlink (/tmp → /private/tmp on macOS).
|
|
46
|
-
export function realResolve(p) {
|
|
47
|
-
let cur = path.resolve(p);
|
|
48
|
-
const tail = [];
|
|
49
|
-
while (!fs.existsSync(cur)) {
|
|
50
|
-
const parent = path.dirname(cur);
|
|
51
|
-
if (parent === cur) break;
|
|
52
|
-
tail.unshift(path.basename(cur));
|
|
53
|
-
cur = parent;
|
|
54
|
-
}
|
|
55
|
-
try { cur = fs.realpathSync.native(cur); } catch { /* keep as-is */ }
|
|
56
|
-
return tail.length ? path.join(cur, ...tail) : cur;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function toPosixRel(projectRoot, absPath) {
|
|
60
|
-
const rel = path.relative(realResolve(projectRoot), realResolve(absPath));
|
|
61
|
-
if (rel.startsWith('..')) return null;
|
|
62
|
-
return rel.split(path.sep).join('/');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Lexical (no-symlink-resolution) relative path: what the path CLAIMS to be.
|
|
66
|
-
// Tried against both the raw and the realpathed project root so /tmp-style
|
|
67
|
-
// root symlinks don't break matching. Guards must compare this against
|
|
68
|
-
// toPosixRel — a divergence means a symlink sits inside the project.
|
|
69
|
-
export function lexicalPosixRel(projectRoot, absPath) {
|
|
70
|
-
const abs = path.resolve(absPath);
|
|
71
|
-
const realRoot = realResolve(projectRoot);
|
|
72
|
-
for (const base of [path.resolve(projectRoot), realRoot]) {
|
|
73
|
-
const rel = path.relative(base, abs);
|
|
74
|
-
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
75
|
-
return rel.split(path.sep).join('/');
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// Root-level symlinks (/tmp → /private/tmp): find the SHALLOWEST ancestor of
|
|
79
|
-
// abs whose realpath IS the project root; the remaining tail is the lexical
|
|
80
|
-
// claim. In-project symlinks are deliberately not resolved here.
|
|
81
|
-
const segs = abs.split(path.sep);
|
|
82
|
-
for (let i = 1; i < segs.length; i++) {
|
|
83
|
-
const ancestor = segs.slice(0, i).join(path.sep) || path.sep;
|
|
84
|
-
let real;
|
|
85
|
-
try { real = fs.realpathSync.native(ancestor); } catch { continue; }
|
|
86
|
-
if (real === realRoot) {
|
|
87
|
-
const tail = segs.slice(i).join('/');
|
|
88
|
-
return tail || null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Gate state written by `journal.mjs open-<phase>` — {phase, change?, expires}.
|
|
95
|
-
export function readPhase(sdlcRoot) {
|
|
96
|
-
try {
|
|
97
|
-
const raw = fs.readFileSync(path.join(sdlcRoot, '.state', 'phase.json'), 'utf8');
|
|
98
|
-
const phase = JSON.parse(raw);
|
|
99
|
-
if (phase.expires && Date.parse(phase.expires) < Date.now()) return null;
|
|
100
|
-
return phase;
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function writePhase(sdlcRoot, phase, ttlMinutes = 30) {
|
|
107
|
-
const stateDir = path.join(sdlcRoot, '.state');
|
|
108
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
109
|
-
const payload = { ...phase, expires: new Date(Date.now() + ttlMinutes * 60_000).toISOString() };
|
|
110
|
-
fs.writeFileSync(path.join(stateDir, 'phase.json'), JSON.stringify(payload));
|
|
111
|
-
return payload;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function clearPhase(sdlcRoot) {
|
|
115
|
-
fs.rmSync(path.join(sdlcRoot, '.state', 'phase.json'), { force: true });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Active change: session pointer, then project pointer, then the most recently
|
|
119
|
-
// modified changes/*/change.md — resolution lives in lib/active.mjs so the CLI's
|
|
120
|
-
// `status` answers the same question the hooks do. Callers still get just the id
|
|
121
|
-
// (the `recent` fallback still attributes hook events, it just isn't reported as
|
|
122
|
-
// confirmed by `status`).
|
|
123
|
-
export function activeChange(sdlcRoot, sessionId = null) {
|
|
124
|
-
return resolveActive(sdlcRoot, { sessionId })?.change ?? null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Journal: per-change ndjson when a change is active, else a global one — both under
|
|
128
|
-
// .state/, which is gitignored, so a session never dirties a version-controlled file
|
|
129
|
-
// just by running. Hook-written only — agents never hand-edit.
|
|
130
|
-
//
|
|
131
|
-
// Attribution does not depend on the change folder existing: a stale active pointer
|
|
132
|
-
// still records the event under that id rather than silently reattributing it.
|
|
133
|
-
export function appendJournal(sdlcRoot, change, event) {
|
|
134
|
-
try {
|
|
135
|
-
const target = (change && liveJournalPath(sdlcRoot, change)) || globalJournalPath(sdlcRoot);
|
|
136
|
-
appendEvent(target, { ts: new Date().toISOString(), ...event });
|
|
137
|
-
} catch { /* fail open */ }
|
|
138
|
-
}
|
|
1
|
+
// Shared plumbing for installed hooks. This file lives at
|
|
2
|
+
// <project>/sdlc/.hooks/_shared.mjs with lib/ as a sibling directory.
|
|
3
|
+
// Every hook must be fail-open: on any unexpected condition, exit 0 silently
|
|
4
|
+
// so the harness is never blocked by our tooling.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { liveJournalPath, globalJournalPath, appendEvent } from './lib/journal.mjs';
|
|
11
|
+
import { resolveActive } from './lib/active.mjs';
|
|
12
|
+
|
|
13
|
+
export function resolveRoots(importMetaUrl) {
|
|
14
|
+
const hooksDir = path.dirname(fileURLToPath(importMetaUrl));
|
|
15
|
+
const sdlcRoot = path.dirname(hooksDir);
|
|
16
|
+
const projectRoot = path.dirname(sdlcRoot);
|
|
17
|
+
return { hooksDir, sdlcRoot, projectRoot };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Reads the hook payload from stdin. Must NEVER hang: when a playbook or a
|
|
21
|
+
// user script invokes a hook utility with stdin open-but-idle (no piped JSON),
|
|
22
|
+
// resolve null after a short grace period instead of blocking forever.
|
|
23
|
+
export function readStdinJson({ timeoutMs = 1000 } = {}) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let data = '';
|
|
26
|
+
let done = false;
|
|
27
|
+
const finish = () => {
|
|
28
|
+
if (done) return;
|
|
29
|
+
done = true;
|
|
30
|
+
// Release stdin so an open-idle stream cannot keep the event loop alive.
|
|
31
|
+
process.stdin.pause();
|
|
32
|
+
if (typeof process.stdin.unref === 'function') process.stdin.unref();
|
|
33
|
+
try { resolve(data.trim() ? JSON.parse(data) : null); } catch { resolve(null); }
|
|
34
|
+
};
|
|
35
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
36
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
37
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
38
|
+
process.stdin.on('end', finish);
|
|
39
|
+
process.stdin.on('error', finish);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve symlinks on the deepest EXISTING ancestor, then re-attach the tail.
|
|
44
|
+
// Needed because import.meta.url is symlink-resolved while tool file_paths may
|
|
45
|
+
// arrive through a symlink (/tmp → /private/tmp on macOS).
|
|
46
|
+
export function realResolve(p) {
|
|
47
|
+
let cur = path.resolve(p);
|
|
48
|
+
const tail = [];
|
|
49
|
+
while (!fs.existsSync(cur)) {
|
|
50
|
+
const parent = path.dirname(cur);
|
|
51
|
+
if (parent === cur) break;
|
|
52
|
+
tail.unshift(path.basename(cur));
|
|
53
|
+
cur = parent;
|
|
54
|
+
}
|
|
55
|
+
try { cur = fs.realpathSync.native(cur); } catch { /* keep as-is */ }
|
|
56
|
+
return tail.length ? path.join(cur, ...tail) : cur;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toPosixRel(projectRoot, absPath) {
|
|
60
|
+
const rel = path.relative(realResolve(projectRoot), realResolve(absPath));
|
|
61
|
+
if (rel.startsWith('..')) return null;
|
|
62
|
+
return rel.split(path.sep).join('/');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Lexical (no-symlink-resolution) relative path: what the path CLAIMS to be.
|
|
66
|
+
// Tried against both the raw and the realpathed project root so /tmp-style
|
|
67
|
+
// root symlinks don't break matching. Guards must compare this against
|
|
68
|
+
// toPosixRel — a divergence means a symlink sits inside the project.
|
|
69
|
+
export function lexicalPosixRel(projectRoot, absPath) {
|
|
70
|
+
const abs = path.resolve(absPath);
|
|
71
|
+
const realRoot = realResolve(projectRoot);
|
|
72
|
+
for (const base of [path.resolve(projectRoot), realRoot]) {
|
|
73
|
+
const rel = path.relative(base, abs);
|
|
74
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
75
|
+
return rel.split(path.sep).join('/');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Root-level symlinks (/tmp → /private/tmp): find the SHALLOWEST ancestor of
|
|
79
|
+
// abs whose realpath IS the project root; the remaining tail is the lexical
|
|
80
|
+
// claim. In-project symlinks are deliberately not resolved here.
|
|
81
|
+
const segs = abs.split(path.sep);
|
|
82
|
+
for (let i = 1; i < segs.length; i++) {
|
|
83
|
+
const ancestor = segs.slice(0, i).join(path.sep) || path.sep;
|
|
84
|
+
let real;
|
|
85
|
+
try { real = fs.realpathSync.native(ancestor); } catch { continue; }
|
|
86
|
+
if (real === realRoot) {
|
|
87
|
+
const tail = segs.slice(i).join('/');
|
|
88
|
+
return tail || null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Gate state written by `journal.mjs open-<phase>` — {phase, change?, expires}.
|
|
95
|
+
export function readPhase(sdlcRoot) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = fs.readFileSync(path.join(sdlcRoot, '.state', 'phase.json'), 'utf8');
|
|
98
|
+
const phase = JSON.parse(raw);
|
|
99
|
+
if (phase.expires && Date.parse(phase.expires) < Date.now()) return null;
|
|
100
|
+
return phase;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function writePhase(sdlcRoot, phase, ttlMinutes = 30) {
|
|
107
|
+
const stateDir = path.join(sdlcRoot, '.state');
|
|
108
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
109
|
+
const payload = { ...phase, expires: new Date(Date.now() + ttlMinutes * 60_000).toISOString() };
|
|
110
|
+
fs.writeFileSync(path.join(stateDir, 'phase.json'), JSON.stringify(payload));
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function clearPhase(sdlcRoot) {
|
|
115
|
+
fs.rmSync(path.join(sdlcRoot, '.state', 'phase.json'), { force: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Active change: session pointer, then project pointer, then the most recently
|
|
119
|
+
// modified changes/*/change.md — resolution lives in lib/active.mjs so the CLI's
|
|
120
|
+
// `status` answers the same question the hooks do. Callers still get just the id
|
|
121
|
+
// (the `recent` fallback still attributes hook events, it just isn't reported as
|
|
122
|
+
// confirmed by `status`).
|
|
123
|
+
export function activeChange(sdlcRoot, sessionId = null) {
|
|
124
|
+
return resolveActive(sdlcRoot, { sessionId })?.change ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Journal: per-change ndjson when a change is active, else a global one — both under
|
|
128
|
+
// .state/, which is gitignored, so a session never dirties a version-controlled file
|
|
129
|
+
// just by running. Hook-written only — agents never hand-edit.
|
|
130
|
+
//
|
|
131
|
+
// Attribution does not depend on the change folder existing: a stale active pointer
|
|
132
|
+
// still records the event under that id rather than silently reattributing it.
|
|
133
|
+
export function appendJournal(sdlcRoot, change, event) {
|
|
134
|
+
try {
|
|
135
|
+
const target = (change && liveJournalPath(sdlcRoot, change)) || globalJournalPath(sdlcRoot);
|
|
136
|
+
appendEvent(target, { ts: new Date().toISOString(), ...event });
|
|
137
|
+
} catch { /* fail open */ }
|
|
138
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SessionStart side of the update notice: reads the cached check, and when it is due records
|
|
2
|
+
// the attempt and hands the network request to a detached `check-update.mjs`. Never waits on
|
|
3
|
+
// the network and never throws — any failure means no notice.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { parseConfig } from './lib/config.mjs';
|
|
10
|
+
import { parseVersion } from './lib/version.mjs';
|
|
11
|
+
import { isCheckDisabled, isCheckDue, noticeLine } from './lib/update-notice.mjs';
|
|
12
|
+
|
|
13
|
+
export const cachePath = (sdlcRoot) => path.join(sdlcRoot, '.state', 'update-check.json');
|
|
14
|
+
|
|
15
|
+
export function readJsonFile(p) {
|
|
16
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// `.state/` is gitignored, not unwritable: a repo can ship a link there. Write only into a
|
|
20
|
+
// `.state/` that already exists and really is `<sdlcRoot>/.state` (same rule as the change
|
|
21
|
+
// pointers in lib/active.mjs); never create it, so a deleted project is not resurrected.
|
|
22
|
+
function isRealStateDir(sdlcRoot) {
|
|
23
|
+
try {
|
|
24
|
+
const stateDir = path.join(sdlcRoot, '.state');
|
|
25
|
+
return fs.lstatSync(stateDir).isDirectory()
|
|
26
|
+
&& fs.realpathSync.native(stateDir) === path.join(fs.realpathSync.native(sdlcRoot), '.state');
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Temp file + rename: a symlink at the destination is replaced rather than written through,
|
|
33
|
+
// and a concurrent reader sees the old file or the new one, never half of either.
|
|
34
|
+
export function writeCache(sdlcRoot, value) {
|
|
35
|
+
const p = cachePath(sdlcRoot);
|
|
36
|
+
if (!isRealStateDir(sdlcRoot)) return false;
|
|
37
|
+
const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { flag: 'wx' });
|
|
40
|
+
fs.renameSync(tmp, p);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
fs.rmSync(tmp, { force: true });
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readConfig(sdlcRoot) {
|
|
49
|
+
try { return parseConfig(fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8')); } catch { return {}; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The checker talks to a third-party host; it gets only what a request needs, not the
|
|
53
|
+
// session's tokens. SYSTEMROOT keeps Windows sockets working; the CA var keeps corporate TLS.
|
|
54
|
+
const CHECKER_ENV = ['PATH', 'SYSTEMROOT', 'NODE_EXTRA_CA_CERTS', 'WARNYIN_SDLC_REGISTRY_URL'];
|
|
55
|
+
const checkerEnv = (env) => Object.fromEntries(CHECKER_ENV.filter((k) => env[k] !== undefined).map((k) => [k, env[k]]));
|
|
56
|
+
|
|
57
|
+
function startCheck(hooksDir) {
|
|
58
|
+
const child = spawn(process.execPath, [path.join(hooksDir, 'check-update.mjs')], {
|
|
59
|
+
detached: true, stdio: 'ignore', windowsHide: true, env: checkerEnv(process.env),
|
|
60
|
+
});
|
|
61
|
+
child.on('error', () => {});
|
|
62
|
+
child.unref();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function updateNotice({ sdlcRoot, hooksDir, now = Date.now() }) {
|
|
66
|
+
try {
|
|
67
|
+
if (isCheckDisabled(process.env, readConfig(sdlcRoot))) return null;
|
|
68
|
+
const installed = readJsonFile(path.join(hooksDir, 'version.json'))?.version;
|
|
69
|
+
if (!parseVersion(installed)) return null;
|
|
70
|
+
const cache = readJsonFile(cachePath(sdlcRoot));
|
|
71
|
+
const latest = parseVersion(cache?.latest) ? cache.latest : undefined;
|
|
72
|
+
// No recorded attempt, no request: a cache that cannot be written would otherwise mean a
|
|
73
|
+
// request on every session.
|
|
74
|
+
if (isCheckDue(cache, now) && writeCache(sdlcRoot, { checkedAt: new Date(now).toISOString(), latest })) {
|
|
75
|
+
startCheck(hooksDir);
|
|
76
|
+
}
|
|
77
|
+
return noticeLine(installed, latest);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached from SessionStart by _update-notice.mjs — not a registered hook. Asks the registry
|
|
3
|
+
// for the latest published version and caches it only when it is a plain X.Y.Z. Prints
|
|
4
|
+
// nothing, follows no redirect, reads at most MAX_BODY_BYTES, and gives up after
|
|
5
|
+
// FETCH_TIMEOUT_MS. Every failure leaves the cache as the hook left it.
|
|
6
|
+
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { resolveRoots } from './_shared.mjs';
|
|
9
|
+
import { cachePath, readJsonFile, writeCache } from './_update-notice.mjs';
|
|
10
|
+
import { parseVersion } from './lib/version.mjs';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_REGISTRY, FETCH_TIMEOUT_MS, MAX_BODY_BYTES, latestUrl,
|
|
13
|
+
} from './lib/update-notice.mjs';
|
|
14
|
+
|
|
15
|
+
const { sdlcRoot } = resolveRoots(import.meta.url);
|
|
16
|
+
|
|
17
|
+
async function readCapped(body, limit) {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
for await (const chunk of body) {
|
|
21
|
+
size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.byteLength;
|
|
22
|
+
if (size > limit) return null;
|
|
23
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
24
|
+
}
|
|
25
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const url = new URL(latestUrl(process.env.WARNYIN_SDLC_REGISTRY_URL || DEFAULT_REGISTRY));
|
|
30
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return; // fetch also reads data:
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
redirect: 'error',
|
|
33
|
+
headers: { accept: 'application/json' },
|
|
34
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok || !res.body) return;
|
|
37
|
+
const text = await readCapped(res.body, MAX_BODY_BYTES);
|
|
38
|
+
if (text === null) return;
|
|
39
|
+
const version = JSON.parse(text)?.version;
|
|
40
|
+
if (!parseVersion(version)) return;
|
|
41
|
+
const cache = readJsonFile(cachePath(sdlcRoot));
|
|
42
|
+
writeCache(sdlcRoot, { checkedAt: cache?.checkedAt ?? new Date().toISOString(), latest: version });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch(() => {}).finally(() => process.exit(0)); // fail open, silently
|
|
@@ -1,87 +1,87 @@
|
|
|
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
|
-
import { pickSessionId } from './lib/active.mjs';
|
|
18
|
-
|
|
19
|
-
const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
|
|
20
|
-
|
|
21
|
-
function deny(reason, rel, sessionId) {
|
|
22
|
-
appendJournal(sdlcRoot, activeChange(sdlcRoot, sessionId), { event: 'guard', action: 'deny', path: rel, reason });
|
|
23
|
-
console.log(JSON.stringify({
|
|
24
|
-
hookSpecificOutput: {
|
|
25
|
-
hookEventName: 'PreToolUse',
|
|
26
|
-
permissionDecision: 'deny',
|
|
27
|
-
permissionDecisionReason: reason,
|
|
28
|
-
},
|
|
29
|
-
}));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Evaluate the lock rules against ONE view of the path. Returns true when a
|
|
33
|
-
// deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
|
|
34
|
-
// realpath-resolved view — a symlink must never weaken a lock.
|
|
35
|
-
function guard(rel, phase, sessionId) {
|
|
36
|
-
if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
|
|
37
|
-
deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel, sessionId);
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
|
|
41
|
-
if (phase?.phase === 'ship') return false;
|
|
42
|
-
deny(
|
|
43
|
-
`"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
|
|
44
|
-
+ 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
|
|
45
|
-
rel,
|
|
46
|
-
sessionId,
|
|
47
|
-
);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
if (rel === 'sdlc/context/constitution.md' && fs.existsSync(path.join(projectRoot, rel))) {
|
|
51
|
-
if (phase?.phase === 'steer' || phase?.phase === 'ship') return false;
|
|
52
|
-
deny(
|
|
53
|
-
'The constitution is always-loaded context — edits go through /sdlc:steer '
|
|
54
|
-
+ '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
|
|
55
|
-
rel,
|
|
56
|
-
sessionId,
|
|
57
|
-
);
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function main() {
|
|
64
|
-
const input = await readStdinJson();
|
|
65
|
-
const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
|
|
66
|
-
if (!filePath || !fs.existsSync(sdlcRoot)) return;
|
|
67
|
-
|
|
68
|
-
const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
|
|
69
|
-
const abs = path.resolve(projectRoot, filePath);
|
|
70
|
-
const relLexical = lexicalPosixRel(projectRoot, abs);
|
|
71
|
-
const relReal = toPosixRel(projectRoot, abs);
|
|
72
|
-
|
|
73
|
-
// A path that CLAIMS to live under sdlc/ but resolves elsewhere (or out of
|
|
74
|
-
// the project) went through a symlink — deny conservatively; a symlink must
|
|
75
|
-
// never disable the write-lock.
|
|
76
|
-
if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
|
|
77
|
-
deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical, sessionId);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const phase = readPhase(sdlcRoot);
|
|
82
|
-
for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
|
|
83
|
-
if (rel.startsWith('sdlc/') && guard(rel, phase, sessionId)) return;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
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
|
+
import { pickSessionId } from './lib/active.mjs';
|
|
18
|
+
|
|
19
|
+
const { sdlcRoot, projectRoot } = resolveRoots(import.meta.url);
|
|
20
|
+
|
|
21
|
+
function deny(reason, rel, sessionId) {
|
|
22
|
+
appendJournal(sdlcRoot, activeChange(sdlcRoot, sessionId), { event: 'guard', action: 'deny', path: rel, reason });
|
|
23
|
+
console.log(JSON.stringify({
|
|
24
|
+
hookSpecificOutput: {
|
|
25
|
+
hookEventName: 'PreToolUse',
|
|
26
|
+
permissionDecision: 'deny',
|
|
27
|
+
permissionDecisionReason: reason,
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Evaluate the lock rules against ONE view of the path. Returns true when a
|
|
33
|
+
// deny was emitted. Rules must hold for BOTH the lexical (claimed) and the
|
|
34
|
+
// realpath-resolved view — a symlink must never weaken a lock.
|
|
35
|
+
function guard(rel, phase, sessionId) {
|
|
36
|
+
if (rel.startsWith('sdlc/.state/') || rel.endsWith('journal.ndjson')) {
|
|
37
|
+
deny(`"${rel}" is machine-owned (hooks/CLI write it) — never edit it by hand.`, rel, sessionId);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (rel.startsWith('sdlc/specs/') || rel.startsWith('sdlc/changes/archive/')) {
|
|
41
|
+
if (phase?.phase === 'ship') return false;
|
|
42
|
+
deny(
|
|
43
|
+
`"${rel}" is write-locked outside ship. Living specs change only by merging a change's Delta: `
|
|
44
|
+
+ 'run `warnyin-sdlc archive <id>` (or `node sdlc/.hooks/journal.mjs open-ship <id>` first if you must edit).',
|
|
45
|
+
rel,
|
|
46
|
+
sessionId,
|
|
47
|
+
);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (rel === 'sdlc/context/constitution.md' && fs.existsSync(path.join(projectRoot, rel))) {
|
|
51
|
+
if (phase?.phase === 'steer' || phase?.phase === 'ship') return false;
|
|
52
|
+
deny(
|
|
53
|
+
'The constitution is always-loaded context — edits go through /sdlc:steer '
|
|
54
|
+
+ '(`node sdlc/.hooks/journal.mjs open-steer` opens the gate).',
|
|
55
|
+
rel,
|
|
56
|
+
sessionId,
|
|
57
|
+
);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const input = await readStdinJson();
|
|
65
|
+
const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path;
|
|
66
|
+
if (!filePath || !fs.existsSync(sdlcRoot)) return;
|
|
67
|
+
|
|
68
|
+
const sessionId = pickSessionId(input?.session_id, process.env.CLAUDE_CODE_SESSION_ID);
|
|
69
|
+
const abs = path.resolve(projectRoot, filePath);
|
|
70
|
+
const relLexical = lexicalPosixRel(projectRoot, abs);
|
|
71
|
+
const relReal = toPosixRel(projectRoot, abs);
|
|
72
|
+
|
|
73
|
+
// A path that CLAIMS to live under sdlc/ but resolves elsewhere (or out of
|
|
74
|
+
// the project) went through a symlink — deny conservatively; a symlink must
|
|
75
|
+
// never disable the write-lock.
|
|
76
|
+
if (relLexical?.startsWith('sdlc/') && relReal !== relLexical) {
|
|
77
|
+
deny(`"${relLexical}" resolves through a symlink to "${relReal ?? 'outside the project'}" — refusing to touch it.`, relLexical, sessionId);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const phase = readPhase(sdlcRoot);
|
|
82
|
+
for (const rel of new Set([relLexical, relReal].filter(Boolean))) {
|
|
83
|
+
if (rel.startsWith('sdlc/') && guard(rel, phase, sessionId)) return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
main().catch(() => process.exit(0)); // fail open
|