@friedbotstudio/create-baseline 0.2.1 → 0.3.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/README.md +7 -3
- package/obj/template/.claude/commands/grant-push.md +19 -0
- package/obj/template/.claude/commands/init-project.md +26 -4
- package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
- package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
- package/obj/template/.claude/hooks/lib/common.mjs +283 -0
- package/obj/template/.claude/hooks/lib/common.sh +1 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
- package/obj/template/.claude/hooks/memory_stop.sh +161 -2
- package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
- package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
- package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
- package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
- package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
- package/obj/template/.claude/memory/README.md +8 -3
- package/obj/template/.claude/memory/backlog.md +12 -0
- package/obj/template/.claude/project.json +6 -1
- package/obj/template/.claude/settings.json +3 -4
- package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
- package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
- package/obj/template/.claude/skills/chore/SKILL.md +5 -3
- package/obj/template/.claude/skills/commit/SKILL.md +5 -4
- package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
- package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
- package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
- package/obj/template/.claude/skills/documentation/LICENSE +202 -0
- package/obj/template/.claude/skills/documentation/NOTICE +22 -0
- package/obj/template/.claude/skills/harness/SKILL.md +3 -1
- package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
- package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
- package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
- package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
- package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
- package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
- package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
- package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
- package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
- package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
- package/obj/template/.claude/skills/triage/SKILL.md +8 -3
- package/obj/template/CLAUDE.md +34 -23
- package/obj/template/docs/init/seed.md +36 -21
- package/obj/template/manifest.json +59 -33
- package/package.json +1 -1
- package/src/CLAUDE.template.md +34 -23
- package/src/memory/backlog.template.md +12 -0
- package/src/project.template.json +6 -1
- package/src/seed.template.md +36 -21
- package/src/settings.template.json +3 -4
- package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
- package/obj/template/.claude/hooks/git_commit_guard.sh +0 -93
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Shared helpers for baseline Claude Code hook scripts (JS port pilot).
|
|
2
|
+
// Imported by .mjs hooks in .claude/hooks/. Bash hooks still source lib/common.sh.
|
|
3
|
+
//
|
|
4
|
+
// Contract:
|
|
5
|
+
// - Hooks receive a JSON payload on stdin (the Claude Code hook event).
|
|
6
|
+
// - Hooks emit JSON to stdout for structured decisions, or exit non-zero with
|
|
7
|
+
// a stderr message to block/warn.
|
|
8
|
+
// - All hooks must be resilient to a missing/invalid project.json.
|
|
9
|
+
//
|
|
10
|
+
// Behavior is preserved verbatim from lib/common.sh. The one addition is
|
|
11
|
+
// matchAnyGlob — needed by git_commit_guard.mjs for branch policy.
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
15
|
+
|
|
16
|
+
export const CLAUDE_PROJECT_ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
17
|
+
export const CLAUDE_DOTDIR = join(CLAUDE_PROJECT_ROOT, '.claude');
|
|
18
|
+
export const PROJECT_JSON = join(CLAUDE_DOTDIR, 'project.json');
|
|
19
|
+
export const STATE_DIR = join(CLAUDE_DOTDIR, 'state');
|
|
20
|
+
export const LOG_DIR = join(STATE_DIR, 'logs');
|
|
21
|
+
|
|
22
|
+
try { mkdirSync(STATE_DIR, { recursive: true }); } catch {}
|
|
23
|
+
try { mkdirSync(LOG_DIR, { recursive: true }); } catch {}
|
|
24
|
+
|
|
25
|
+
// Consent-gate marker file paths — written ONLY by consent_gate_grant.mjs
|
|
26
|
+
// (UserPromptSubmit), read by the gate guards. Hooks reference these constants
|
|
27
|
+
// rather than literal paths so a rename is one-line.
|
|
28
|
+
export const CONSENT_MARKER_SPEC = join(STATE_DIR, '.spec_approval_grant');
|
|
29
|
+
export const CONSENT_MARKER_SWARM = join(STATE_DIR, '.swarm_approval_grant');
|
|
30
|
+
export const CONSENT_MARKER_COMMIT = join(STATE_DIR, '.commit_consent_grant');
|
|
31
|
+
export const CONSENT_MARKER_PUSH = join(STATE_DIR, '.push_consent_grant');
|
|
32
|
+
export const CONSENT_MARKER_SPEC_REL = '.claude/state/.spec_approval_grant';
|
|
33
|
+
export const CONSENT_MARKER_SWARM_REL = '.claude/state/.swarm_approval_grant';
|
|
34
|
+
export const CONSENT_MARKER_COMMIT_REL = '.claude/state/.commit_consent_grant';
|
|
35
|
+
export const CONSENT_MARKER_PUSH_REL = '.claude/state/.push_consent_grant';
|
|
36
|
+
|
|
37
|
+
// Read the raw hook JSON payload from stdin. Returns a plain object (empty on parse error).
|
|
38
|
+
export async function readPayload() {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
41
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
42
|
+
if (!raw.trim()) return {};
|
|
43
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dottedLookup(obj, path) {
|
|
47
|
+
if (obj == null) return undefined;
|
|
48
|
+
let cur = obj;
|
|
49
|
+
for (const part of String(path).replace(/^\.+|\.+$/g, '').split('.')) {
|
|
50
|
+
if (part === '') continue;
|
|
51
|
+
if (cur && typeof cur === 'object' && !Array.isArray(cur) && part in cur) {
|
|
52
|
+
cur = cur[part];
|
|
53
|
+
} else {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return cur;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract a field from the hook payload using a dotted path.
|
|
61
|
+
// Usage: payloadGet(payload, '.tool_input.command')
|
|
62
|
+
export function payloadGet(payload, path) {
|
|
63
|
+
return dottedLookup(payload, path);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Lazily-loaded project.json cache.
|
|
67
|
+
let _projectJsonCache;
|
|
68
|
+
let _projectJsonLoaded = false;
|
|
69
|
+
function loadProjectJson() {
|
|
70
|
+
if (_projectJsonLoaded) return _projectJsonCache;
|
|
71
|
+
_projectJsonLoaded = true;
|
|
72
|
+
try {
|
|
73
|
+
_projectJsonCache = JSON.parse(readFileSync(PROJECT_JSON, 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
_projectJsonCache = undefined;
|
|
76
|
+
}
|
|
77
|
+
return _projectJsonCache;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Read a field from .claude/project.json at a dotted path.
|
|
81
|
+
// Returns undefined if project.json or the key is missing.
|
|
82
|
+
export function projectGet(path) {
|
|
83
|
+
return dottedLookup(loadProjectJson(), path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Emit a structured block decision (PreToolUse). Prints JSON to stdout, exits 0.
|
|
87
|
+
export function emitBlock(reason) {
|
|
88
|
+
const out = {
|
|
89
|
+
hookSpecificOutput: {
|
|
90
|
+
hookEventName: 'PreToolUse',
|
|
91
|
+
permissionDecision: 'deny',
|
|
92
|
+
permissionDecisionReason: String(reason),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
96
|
+
process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Emit a structured ask decision.
|
|
100
|
+
export function emitAsk(reason) {
|
|
101
|
+
const out = {
|
|
102
|
+
hookSpecificOutput: {
|
|
103
|
+
hookEventName: 'PreToolUse',
|
|
104
|
+
permissionDecision: 'ask',
|
|
105
|
+
permissionDecisionReason: String(reason),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Emit allow (no-op decision). Equivalent to exit 0 with no output.
|
|
113
|
+
export function emitAllow() {
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Informational message (stderr, non-blocking).
|
|
118
|
+
export function emitInfo(msg) {
|
|
119
|
+
process.stderr.write(String(msg) + '\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Append a line to a hook-specific log. Best-effort, never throws.
|
|
123
|
+
export function logLine(hook, msg) {
|
|
124
|
+
try {
|
|
125
|
+
const ts = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
126
|
+
appendFileSync(join(LOG_DIR, `${hook}.log`), `${ts} ${msg}\n`);
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Canonicalize a filepath and make it relative to CLAUDE_PROJECT_ROOT.
|
|
131
|
+
// Lexical only — does NOT resolve symlinks (matches the bash helper's
|
|
132
|
+
// deliberate choice; symlink-swap defense is a separate hardening).
|
|
133
|
+
// Returns the project-relative canonical path, or an absolute canonical
|
|
134
|
+
// path if the input escapes the root, or '' if input equals the root.
|
|
135
|
+
export function canonicalRel(filepath) {
|
|
136
|
+
if (!filepath) return '';
|
|
137
|
+
const norm = resolve(normalize(filepath));
|
|
138
|
+
const normRoot = resolve(normalize(CLAUDE_PROJECT_ROOT));
|
|
139
|
+
if (norm === normRoot) return '';
|
|
140
|
+
if (norm.startsWith(normRoot + sep)) return norm.slice(normRoot.length + 1);
|
|
141
|
+
return norm;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Reduce a user-typed approval arg (bare slug, filename, or path) to a slug.
|
|
145
|
+
// docs/specs/foo.md -> foo
|
|
146
|
+
// foo.md -> foo
|
|
147
|
+
// foo -> foo
|
|
148
|
+
export function canonicalSlug(s) {
|
|
149
|
+
if (s == null) return '';
|
|
150
|
+
let base = String(s);
|
|
151
|
+
const slash = base.lastIndexOf('/');
|
|
152
|
+
if (slash >= 0) base = base.slice(slash + 1);
|
|
153
|
+
if (base.endsWith('.md')) base = base.slice(0, -3);
|
|
154
|
+
return base;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Atomic marker write: temp file + rename. Returns true on success.
|
|
158
|
+
export function writeMarkerAtomic(markerPath, ...lines) {
|
|
159
|
+
const tmp = `${markerPath}.tmp.${process.pid}`;
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(tmp, lines.join('\n') + '\n');
|
|
162
|
+
renameSync(tmp, markerPath);
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
try { unlinkSync(tmp); } catch {}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Block Claude from writing a consent-marker file via Write/Edit/MultiEdit.
|
|
171
|
+
// The marker's unforgeability is what makes consent gates structural — only
|
|
172
|
+
// consent_gate_grant (UserPromptSubmit, outside Claude's tool boundary) may
|
|
173
|
+
// produce it. Calls emitBlock (which exits) on match.
|
|
174
|
+
//
|
|
175
|
+
// Args: rel — the relative path being written; markerRel — the marker's REL
|
|
176
|
+
// constant; gateLabel — human label for the error message ("Git Commit Guard");
|
|
177
|
+
// cmdHint — the user command users should run ("/grant-commit").
|
|
178
|
+
export function blockMarkerSelfWrite(rel, markerRel, gateLabel, cmdHint) {
|
|
179
|
+
const hookLog = gateLabel.toLowerCase().replace(/\s+/g, '_');
|
|
180
|
+
if (rel === markerRel) {
|
|
181
|
+
logLine(hookLog, `BLOCKED direct write to consent marker: ${rel}`);
|
|
182
|
+
emitBlock(`${gateLabel}: '${rel}' is a consent marker written by the consent_gate_grant UserPromptSubmit hook in response to \`${cmdHint}\`. Claude is not permitted to create or edit this marker — its unforgeability is what makes the gate structurally enforced.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Validate a consent marker (freshness + optional slug match) and consume it.
|
|
187
|
+
// emitBlocks (exits) on any failure; returns on success after deleting the marker.
|
|
188
|
+
// TTL comes from .consent.gate_marker_ttl_seconds (default 120).
|
|
189
|
+
//
|
|
190
|
+
// Marker shape:
|
|
191
|
+
// - With expectedSlug: line 1 = slug, line 2 = epoch.
|
|
192
|
+
// - Epoch-only: line 1 = epoch.
|
|
193
|
+
export function validateConsentMarker(markerPath, gateLabel, cmdHint, expectedSlug = '') {
|
|
194
|
+
const hookLog = gateLabel.toLowerCase().replace(/\s+/g, '_');
|
|
195
|
+
let ttl = projectGet('.consent.gate_marker_ttl_seconds');
|
|
196
|
+
if (typeof ttl !== 'number' || !Number.isFinite(ttl)) ttl = 120;
|
|
197
|
+
|
|
198
|
+
if (!existsSync(markerPath)) {
|
|
199
|
+
logLine(hookLog, `BLOCKED no marker: ${markerPath}`);
|
|
200
|
+
emitBlock(`${gateLabel}: requires a fresh consent marker at ${markerPath}. The marker is produced by the consent_gate_grant hook when the user runs \`${cmdHint}\` — Claude cannot create it.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let markerSlug = '';
|
|
204
|
+
let markerEpoch;
|
|
205
|
+
try {
|
|
206
|
+
const text = readFileSync(markerPath, 'utf8');
|
|
207
|
+
const lines = text.split(/\r?\n/);
|
|
208
|
+
if (expectedSlug) {
|
|
209
|
+
markerSlug = (lines[0] ?? '').trim();
|
|
210
|
+
markerEpoch = (lines[1] ?? '').trim();
|
|
211
|
+
} else {
|
|
212
|
+
markerEpoch = (lines[0] ?? '').trim();
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
markerEpoch = '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!/^\d+$/.test(markerEpoch)) {
|
|
219
|
+
logLine(hookLog, `BLOCKED malformed marker: ${markerPath}`);
|
|
220
|
+
emitBlock(`${gateLabel}: marker at ${markerPath} is malformed. Ask the user to re-run \`${cmdHint}\`.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const now = Math.floor(Date.now() / 1000);
|
|
224
|
+
const age = now - parseInt(markerEpoch, 10);
|
|
225
|
+
if (age > ttl) {
|
|
226
|
+
logLine(hookLog, `BLOCKED marker expired age=${age}s ttl=${ttl}s`);
|
|
227
|
+
try { unlinkSync(markerPath); } catch {}
|
|
228
|
+
emitBlock(`${gateLabel}: consent marker expired (${age}s old, TTL ${ttl}s). Ask the user to re-run \`${cmdHint}\`.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (expectedSlug && markerSlug !== expectedSlug) {
|
|
232
|
+
logLine(hookLog, `BLOCKED slug mismatch marker=${markerSlug} expected=${expectedSlug}`);
|
|
233
|
+
emitBlock(`${gateLabel}: marker slug (${markerSlug}) does not match expected (${expectedSlug}). Ask the user to re-run \`${cmdHint}\` with the correct argument.`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
logLine(hookLog, `ALLOWED marker=${markerPath} age=${age}s slug=${markerSlug || 'N/A'}`);
|
|
237
|
+
try { unlinkSync(markerPath); } catch {}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Hand-rolled shell-glob → RegExp matcher. Used for git.protected_branches.
|
|
241
|
+
// `*` matches anything except `/`; `**` matches anything including `/`;
|
|
242
|
+
// `?` matches a single non-`/` char; `[...]` is a character class.
|
|
243
|
+
// Returns false if globs is null/undefined/empty.
|
|
244
|
+
export function matchAnyGlob(name, globs) {
|
|
245
|
+
if (!Array.isArray(globs) || globs.length === 0) return false;
|
|
246
|
+
for (const glob of globs) {
|
|
247
|
+
if (typeof glob !== 'string' || glob === '') continue;
|
|
248
|
+
if (globToRegex(glob).test(name)) return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function globToRegex(glob) {
|
|
254
|
+
let pattern = '^';
|
|
255
|
+
for (let i = 0; i < glob.length; i++) {
|
|
256
|
+
const c = glob[i];
|
|
257
|
+
if (c === '*') {
|
|
258
|
+
if (glob[i + 1] === '*') {
|
|
259
|
+
pattern += '.*';
|
|
260
|
+
i += 1;
|
|
261
|
+
} else {
|
|
262
|
+
pattern += '[^/]*';
|
|
263
|
+
}
|
|
264
|
+
} else if (c === '?') {
|
|
265
|
+
pattern += '[^/]';
|
|
266
|
+
} else if (c === '[') {
|
|
267
|
+
let j = i + 1;
|
|
268
|
+
while (j < glob.length && glob[j] !== ']') j++;
|
|
269
|
+
if (j >= glob.length) {
|
|
270
|
+
pattern += '\\[';
|
|
271
|
+
} else {
|
|
272
|
+
pattern += glob.slice(i, j + 1);
|
|
273
|
+
i = j;
|
|
274
|
+
}
|
|
275
|
+
} else if ('.+()^$|\\{}'.includes(c)) {
|
|
276
|
+
pattern += '\\' + c;
|
|
277
|
+
} else {
|
|
278
|
+
pattern += c;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
pattern += '$';
|
|
282
|
+
return new RegExp(pattern);
|
|
283
|
+
}
|
|
@@ -229,7 +229,7 @@ else:
|
|
|
229
229
|
PY
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
# Consent-gate marker file paths — written ONLY by consent_gate_grant.
|
|
232
|
+
# Consent-gate marker file paths — written ONLY by consent_gate_grant.mjs
|
|
233
233
|
# (UserPromptSubmit) when the user invokes the corresponding slash command,
|
|
234
234
|
# read by the gate guards (PreToolUse) before allowing approval-token writes.
|
|
235
235
|
# Hooks reference these constants instead of literal paths so a rename is a
|
|
@@ -54,8 +54,9 @@ except Exception:
|
|
|
54
54
|
head = ''
|
|
55
55
|
|
|
56
56
|
# Files in canonical order. _pending.md handled separately.
|
|
57
|
-
canonical = ['landmarks', 'libraries', 'decisions', 'landmines', 'conventions', 'pending-questions']
|
|
57
|
+
canonical = ['landmarks', 'libraries', 'decisions', 'landmines', 'conventions', 'pending-questions', 'backlog']
|
|
58
58
|
PENDING_FILE = 'pending-questions'
|
|
59
|
+
STALE_EXEMPT_FILES = {'backlog'}
|
|
59
60
|
STALE_COMMITS = 30
|
|
60
61
|
STALE_DAYS = 30 # non-git fallback threshold
|
|
61
62
|
|
|
@@ -96,6 +97,8 @@ def _split_blocks(body):
|
|
|
96
97
|
|
|
97
98
|
|
|
98
99
|
def _is_stale(block, name):
|
|
100
|
+
if name in STALE_EXEMPT_FILES:
|
|
101
|
+
return False
|
|
99
102
|
closure_field = 'resolved-at' if name == PENDING_FILE else 'superseded-at'
|
|
100
103
|
if _field(block, closure_field):
|
|
101
104
|
return False
|
|
@@ -151,6 +154,10 @@ lines = [
|
|
|
151
154
|
]
|
|
152
155
|
for name, n, stale, status in rows:
|
|
153
156
|
lines.append(f'| `{name}.md` | {n} | {stale} | {status} |')
|
|
157
|
+
# Phase 10.6: surface the _pending.md row in the index so K=0 / K>0 are
|
|
158
|
+
# visible without the prose nag. The body content is gitignored; only the
|
|
159
|
+
# count matters here.
|
|
160
|
+
lines.append(f'| `_pending.md` | {pending_count} | — | ok |')
|
|
154
161
|
|
|
155
162
|
if stale_records:
|
|
156
163
|
stale_records.sort(key=lambda r: (r[2] or '', f'{r[0]}:{r[1]}'))
|
|
@@ -167,13 +174,20 @@ if stale_records:
|
|
|
167
174
|
|
|
168
175
|
lines.append('')
|
|
169
176
|
|
|
170
|
-
|
|
177
|
+
# Phase 10.6 (memory-flush as workflow phase) downgraded the SessionStart nag to
|
|
178
|
+
# debt-mode only: fire when _pending.md has unflushed candidates AND no active
|
|
179
|
+
# workflow is on disk. During an active workflow, Phase 10.6 will handle them;
|
|
180
|
+
# the nag would be redundant. On K=0, stay silent — the index table above already
|
|
181
|
+
# shows the _pending.md row count.
|
|
182
|
+
workflow_json = root / '.claude/state/workflow.json'
|
|
183
|
+
active_workflow = workflow_json.is_file()
|
|
184
|
+
|
|
185
|
+
if pending_count > 0 and not active_workflow:
|
|
186
|
+
plural = '' if pending_count == 1 else 's'
|
|
171
187
|
lines.append(
|
|
172
|
-
f'**{pending_count} candidate{
|
|
173
|
-
'run `/memory-flush` to
|
|
188
|
+
f'**{pending_count} pending memory candidate{plural} carried over from a prior workflow** — '
|
|
189
|
+
'run `/memory-flush` to clear before starting new work.'
|
|
174
190
|
)
|
|
175
|
-
else:
|
|
176
|
-
lines.append('No pending memory candidates.')
|
|
177
191
|
|
|
178
192
|
lines.append('')
|
|
179
193
|
lines.append(
|
|
@@ -29,7 +29,7 @@ PENDING="$MEM_DIR/_pending.md"
|
|
|
29
29
|
|
|
30
30
|
# Extract candidates with python; never fail the hook (it's advisory).
|
|
31
31
|
TRANSCRIPT="$TRANSCRIPT" PENDING="$PENDING" python3 <<'PY' || true
|
|
32
|
-
import json, os, re, sys, time
|
|
32
|
+
import hashlib, json, os, re, sys, time
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from datetime import datetime, timezone
|
|
35
35
|
|
|
@@ -42,7 +42,7 @@ existing_keys = set(re.findall(r'(?m)^##\s+CANDIDATE:\s*(\S+)', existing))
|
|
|
42
42
|
|
|
43
43
|
candidates = [] # (key, category, body_lines)
|
|
44
44
|
|
|
45
|
-
# Source-dir prefixes that are interesting for landmark candidates.
|
|
45
|
+
# --- Source-dir prefixes that are interesting for landmark candidates. -------
|
|
46
46
|
SRC_PREFIXES = ('src/', 'lib/', 'app/', 'pkg/', 'internal/', 'cmd/', '.claude/hooks/', '.claude/skills/')
|
|
47
47
|
SKIP_PREFIXES = ('.claude/memory/', '.claude/state/', 'docs/scout/', 'docs/research/', 'docs/intake/',
|
|
48
48
|
'docs/specs/', 'docs/brd/', 'docs/rca/', 'docs/security/', 'docs/archive/')
|
|
@@ -56,10 +56,112 @@ def is_source(path: str) -> bool:
|
|
|
56
56
|
return True
|
|
57
57
|
return False
|
|
58
58
|
|
|
59
|
+
# --- Intent-extraction constants + helpers (backlog candidates). -------------
|
|
60
|
+
# Anchored line-start patterns. USER patterns accept an optional Markdown
|
|
61
|
+
# bullet prefix; ASSISTANT patterns require strict line-start to suppress
|
|
62
|
+
# Claude's natural tendency to write narrative summaries containing trigger
|
|
63
|
+
# phrases. Precision-favoring per the user constraint; mid-sentence matches
|
|
64
|
+
# MUST NOT fire.
|
|
65
|
+
_USER_BULLET = r'^(?:\s*[-*]\s*)?'
|
|
66
|
+
_ASSISTANT_BULLET = r'^'
|
|
67
|
+
_INTENT_TRIGGERS = [
|
|
68
|
+
r'TODO[:\s]',
|
|
69
|
+
r'next\s+we\s+(?:should|need\s+to|must)\b',
|
|
70
|
+
r"let'?s\s+also\b",
|
|
71
|
+
r'we\s+should\s+also\b',
|
|
72
|
+
r'backlog\s+this\b',
|
|
73
|
+
r'after\s+this(?:\s+lands)?\b',
|
|
74
|
+
]
|
|
75
|
+
USER_INTENT_PATTERNS = [re.compile(_USER_BULLET + t, re.I) for t in _INTENT_TRIGGERS]
|
|
76
|
+
ASSISTANT_INTENT_PATTERNS = [re.compile(_ASSISTANT_BULLET + t, re.I) for t in _INTENT_TRIGGERS]
|
|
77
|
+
|
|
78
|
+
# Stripped from the matched line before slug derivation so the slug captures
|
|
79
|
+
# the *intent payload*, not the trigger phrase.
|
|
80
|
+
TRIGGER_STRIP = re.compile(
|
|
81
|
+
r"^(?:\s*[-*]\s*)?"
|
|
82
|
+
r"(?:TODO[:\s]+"
|
|
83
|
+
r"|next\s+we\s+(?:should|need\s+to|must)\s+"
|
|
84
|
+
r"|let'?s\s+also\s+"
|
|
85
|
+
r"|we\s+should\s+also\s+"
|
|
86
|
+
r"|backlog\s+this[:\s]*"
|
|
87
|
+
r"|after\s+this(?:\s+lands)?[\s,]*)",
|
|
88
|
+
re.I,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
NOISE_PREFIXES = ('<system-reminder>', '<command-name>', '<local-command-')
|
|
92
|
+
MAX_INTENT_TEXT_LEN = 240
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_text_blocks(content):
|
|
96
|
+
"""Walk a message content list and return trimmed text-block strings.
|
|
97
|
+
Mirrors lib/resume_writer.py:72-88."""
|
|
98
|
+
out = []
|
|
99
|
+
if isinstance(content, str):
|
|
100
|
+
if content.strip():
|
|
101
|
+
out.append(content.strip())
|
|
102
|
+
return out
|
|
103
|
+
if not isinstance(content, list):
|
|
104
|
+
return out
|
|
105
|
+
for block in content:
|
|
106
|
+
if not isinstance(block, dict):
|
|
107
|
+
continue
|
|
108
|
+
if block.get('type') == 'text':
|
|
109
|
+
t = block.get('text', '')
|
|
110
|
+
if isinstance(t, str) and t.strip():
|
|
111
|
+
out.append(t.strip())
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _filter_noise(text: str) -> bool:
|
|
116
|
+
"""True when the text is hook-injected noise that must not produce candidates."""
|
|
117
|
+
head = text.lstrip()[:64]
|
|
118
|
+
return any(head.startswith(p) for p in NOISE_PREFIXES)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _iter_intent_matches(text: str, patterns):
|
|
122
|
+
"""Yield each line of `text` whose start matches any of `patterns`."""
|
|
123
|
+
for line in text.splitlines():
|
|
124
|
+
for pat in patterns:
|
|
125
|
+
if pat.match(line):
|
|
126
|
+
yield line
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalize_intent(line: str) -> str:
|
|
131
|
+
"""Lowercase, whitespace-collapse, trigger-strip. Empty result = discard."""
|
|
132
|
+
stripped = TRIGGER_STRIP.sub('', line).strip()
|
|
133
|
+
if not stripped:
|
|
134
|
+
return ''
|
|
135
|
+
return re.sub(r'\s+', ' ', stripped).lower()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _slug_words(normalized: str, max_words: int = 8) -> str:
|
|
139
|
+
"""Kebab-case slug from the first `max_words` ASCII-alphanumeric words."""
|
|
140
|
+
words = re.findall(r'[a-z0-9]+', normalized)
|
|
141
|
+
if not words:
|
|
142
|
+
return ''
|
|
143
|
+
return '-'.join(words[:max_words])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _derive_key(line: str):
|
|
147
|
+
"""Return (key, normalized) where key = `<slug>-<4-char-sha256>` or
|
|
148
|
+
(None, '') if the line has no extractable intent payload."""
|
|
149
|
+
normalized = _normalize_intent(line)
|
|
150
|
+
if not normalized:
|
|
151
|
+
return None, ''
|
|
152
|
+
slug = _slug_words(normalized)
|
|
153
|
+
if not slug:
|
|
154
|
+
return None, ''
|
|
155
|
+
hsh = hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:4]
|
|
156
|
+
return f'{slug}-{hsh}', normalized
|
|
157
|
+
|
|
158
|
+
|
|
59
159
|
# Track per-path edit counts so we only candidate paths touched ≥1 time
|
|
60
160
|
# (loose threshold; the curator decides what's worth keeping).
|
|
61
161
|
path_touches = {} # path -> count
|
|
62
162
|
lib_queries = [] # list of dicts {library, topic}
|
|
163
|
+
intent_candidates = [] # list of dicts {key, verbatim, role, source}
|
|
164
|
+
seen_intent_keys = set() # within-session dedup keyed on f'{key}::{source}'
|
|
63
165
|
|
|
64
166
|
# Walk the transcript JSONL.
|
|
65
167
|
try:
|
|
@@ -79,6 +181,7 @@ try:
|
|
|
79
181
|
content = msg.get('content')
|
|
80
182
|
if not isinstance(content, list):
|
|
81
183
|
continue
|
|
184
|
+
# tool_use blocks → landmark / library candidates.
|
|
82
185
|
for block in content:
|
|
83
186
|
if not isinstance(block, dict):
|
|
84
187
|
continue
|
|
@@ -98,6 +201,36 @@ try:
|
|
|
98
201
|
topic = inp.get('topic') or inp.get('query') or ''
|
|
99
202
|
if lib:
|
|
100
203
|
lib_queries.append({'library': str(lib), 'topic': str(topic)[:80]})
|
|
204
|
+
|
|
205
|
+
# text blocks → backlog (intent) candidates. Errors here MUST NOT
|
|
206
|
+
# crash the hook — preserve the never-fail contract.
|
|
207
|
+
try:
|
|
208
|
+
role = msg.get('role') or (ev.get('role') if isinstance(ev, dict) else None)
|
|
209
|
+
if role in ('user', 'assistant'):
|
|
210
|
+
patterns = USER_INTENT_PATTERNS if role == 'user' else ASSISTANT_INTENT_PATTERNS
|
|
211
|
+
source_value = 'user-instruction' if role == 'user' else 'assistant-deferral'
|
|
212
|
+
for text in _extract_text_blocks(content):
|
|
213
|
+
if _filter_noise(text):
|
|
214
|
+
continue
|
|
215
|
+
for matched_line in _iter_intent_matches(text, patterns):
|
|
216
|
+
key, normalized = _derive_key(matched_line)
|
|
217
|
+
if not key:
|
|
218
|
+
continue
|
|
219
|
+
dedup_key = f'{key}::{source_value}'
|
|
220
|
+
if dedup_key in seen_intent_keys:
|
|
221
|
+
continue
|
|
222
|
+
seen_intent_keys.add(dedup_key)
|
|
223
|
+
verbatim = matched_line.strip()
|
|
224
|
+
if len(verbatim) > MAX_INTENT_TEXT_LEN:
|
|
225
|
+
verbatim = verbatim[:MAX_INTENT_TEXT_LEN].rstrip() + '…'
|
|
226
|
+
intent_candidates.append({
|
|
227
|
+
'key': key,
|
|
228
|
+
'verbatim': verbatim,
|
|
229
|
+
'role': role,
|
|
230
|
+
'source': source_value,
|
|
231
|
+
})
|
|
232
|
+
except Exception as e:
|
|
233
|
+
sys.stderr.write(f'memory_stop: intent extraction failed for one event: {e}\n')
|
|
101
234
|
except Exception as e:
|
|
102
235
|
sys.stderr.write(f'memory_stop: transcript walk failed: {e}\n')
|
|
103
236
|
|
|
@@ -145,6 +278,32 @@ for q in lib_queries:
|
|
|
145
278
|
]
|
|
146
279
|
candidates.append((key, 'libraries', body))
|
|
147
280
|
|
|
281
|
+
# Backlog (intent) candidates from user/assistant text blocks.
|
|
282
|
+
workflow_slug = ''
|
|
283
|
+
try:
|
|
284
|
+
wf_path = Path(os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd()) / '.claude/state/workflow.json'
|
|
285
|
+
if wf_path.is_file():
|
|
286
|
+
wf = json.loads(wf_path.read_text(encoding='utf-8'))
|
|
287
|
+
workflow_slug = wf.get('slug') or ''
|
|
288
|
+
except Exception:
|
|
289
|
+
workflow_slug = ''
|
|
290
|
+
|
|
291
|
+
for cand in intent_candidates:
|
|
292
|
+
key = cand['key']
|
|
293
|
+
full_key = f'backlog → {key}'
|
|
294
|
+
if full_key in existing_keys:
|
|
295
|
+
continue
|
|
296
|
+
body = [
|
|
297
|
+
f'## CANDIDATE: backlog → {key}',
|
|
298
|
+
f'- Intent: {cand["verbatim"]}',
|
|
299
|
+
f'- Role: {cand["role"]}',
|
|
300
|
+
f'- Source: {cand["source"]}',
|
|
301
|
+
f'- Context: {workflow_slug or "(no active workflow)"}',
|
|
302
|
+
f'- Emitted-at: {ts}',
|
|
303
|
+
'',
|
|
304
|
+
]
|
|
305
|
+
candidates.append((full_key, 'backlog', body))
|
|
306
|
+
|
|
148
307
|
# If nothing to add, exit cleanly.
|
|
149
308
|
if not candidates:
|
|
150
309
|
sys.exit(0)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# 1. Approval artifacts (.claude/state/spec_approvals/*.approval) — only
|
|
7
7
|
# writable when a fresh slug-matched consent marker exists at
|
|
8
8
|
# .claude/state/.spec_approval_grant. The marker is written by
|
|
9
|
-
# consent_gate_grant.
|
|
9
|
+
# consent_gate_grant.mjs on /approve-spec invocation, OUTSIDE Claude's
|
|
10
10
|
# tool boundary. Validated and consumed via validate_consent_marker.
|
|
11
11
|
#
|
|
12
12
|
# 2. The marker file itself — Claude SHALL NEVER write it via tool. The
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# 1. Approval artifacts (.claude/state/swarm_approvals/<slug>.approval) —
|
|
7
7
|
# writable only when a fresh slug-matched marker at
|
|
8
8
|
# .claude/state/.swarm_approval_grant exists. Marker is written by
|
|
9
|
-
# consent_gate_grant.
|
|
9
|
+
# consent_gate_grant.mjs on /approve-swarm. Validated + consumed via
|
|
10
10
|
# validate_consent_marker.
|
|
11
11
|
#
|
|
12
12
|
# 2. The marker file itself — Claude SHALL NEVER write it via tool.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
## Project memory — index (.claude/memory/)
|
|
2
2
|
|
|
3
|
-
HEAD: `n/a` · total entries:
|
|
3
|
+
HEAD: `n/a` · total entries: 68 · stale (>=30 commits old): 0
|
|
4
4
|
|
|
5
5
|
| File | Entries | Stale | Status |
|
|
6
6
|
|---|---:|---:|---|
|
|
7
|
-
| `landmarks.md` |
|
|
8
|
-
| `libraries.md` |
|
|
9
|
-
| `decisions.md` |
|
|
10
|
-
| `landmines.md` |
|
|
11
|
-
| `conventions.md` |
|
|
12
|
-
| `pending-questions.md` |
|
|
7
|
+
| `landmarks.md` | 34 | 0 | ok |
|
|
8
|
+
| `libraries.md` | 5 | 0 | ok |
|
|
9
|
+
| `decisions.md` | 2 | 0 | ok |
|
|
10
|
+
| `landmines.md` | 8 | 0 | ok |
|
|
11
|
+
| `conventions.md` | 7 | 0 | ok |
|
|
12
|
+
| `pending-questions.md` | 6 | 0 | ok |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
owners: [memory_stop.sh writes; /memory-flush clears]
|
|
3
|
+
category: auto-extracted candidates awaiting curation
|
|
4
|
+
verifies-against: none
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Pending memory candidates
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
<!-- session 2026-05-17T09:35Z -->
|
|
13
|
+
## CANDIDATE: src/foo.py → landmarks.md
|
|
14
|
+
- Touched in this session: 2 times
|
|
15
|
+
- Suggested role: <fill in from session context>
|
|
16
|
+
- Source: file written/edited at 2026-05-17T09:35Z
|
|
17
|
+
|
|
18
|
+
## CANDIDATE: src/bar.py → landmarks.md
|
|
19
|
+
- Touched in this session: 1 time
|
|
20
|
+
- Suggested role: <fill in from session context>
|
|
21
|
+
- Source: file written/edited at 2026-05-17T09:35Z
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regenerate the AC-008 byte-equality fixture from the live .claude/memory/ tree.
|
|
3
|
+
#
|
|
4
|
+
# Runs memory_session_start.sh against the current repo, extracts the
|
|
5
|
+
# "## Project memory" header through the "| `pending-questions.md`" row, and
|
|
6
|
+
# normalizes the HEAD short SHA to the literal sentinel "n/a". The fixture is
|
|
7
|
+
# HEAD-agnostic; memory_session_start_test.sh's AC-008 case applies the same
|
|
8
|
+
# normalization to the live capture before byte-comparing.
|
|
9
|
+
#
|
|
10
|
+
# Re-run this whenever the canonical .claude/memory/ tree drifts in entry
|
|
11
|
+
# counts. Idempotent — same tree state produces identical bytes.
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
16
|
+
REPO_ROOT="$(cd "$HERE/../../../.." && pwd)"
|
|
17
|
+
HOOK="$REPO_ROOT/.claude/hooks/memory_session_start.sh"
|
|
18
|
+
FIXTURE="$HERE/ac008_byte_equal_reference.txt"
|
|
19
|
+
|
|
20
|
+
if [ ! -f "$HOOK" ]; then
|
|
21
|
+
echo "regenerate-ac008.sh: hook not found at $HOOK" >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
block="$(CLAUDE_PROJECT_DIR="$REPO_ROOT" bash "$HOOK" <<< '{}' | python3 -c '
|
|
26
|
+
import json, re, sys
|
|
27
|
+
HEAD_RE = re.compile(r"^(HEAD:\s*`)[^`]+(`)")
|
|
28
|
+
data = sys.stdin.read().strip()
|
|
29
|
+
if not data:
|
|
30
|
+
sys.exit("regenerate-ac008.sh: memory_session_start.sh emitted no output")
|
|
31
|
+
j = json.loads(data)
|
|
32
|
+
ctx = j["hookSpecificOutput"]["additionalContext"]
|
|
33
|
+
out = []
|
|
34
|
+
started = False
|
|
35
|
+
for ln in ctx.split("\n"):
|
|
36
|
+
if ln.startswith("## Project memory"):
|
|
37
|
+
started = True
|
|
38
|
+
if not started:
|
|
39
|
+
continue
|
|
40
|
+
out.append(HEAD_RE.sub(r"\1n/a\2", ln))
|
|
41
|
+
if ln.startswith("| `pending-questions.md`"):
|
|
42
|
+
break
|
|
43
|
+
sys.stdout.write("\n".join(out) + "\n")
|
|
44
|
+
')"
|
|
45
|
+
|
|
46
|
+
printf '%s\n' "$block" > "$FIXTURE"
|
|
47
|
+
echo "regenerated $FIXTURE"
|