@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.
Files changed (67) hide show
  1. package/README.md +7 -3
  2. package/obj/template/.claude/commands/grant-push.md +19 -0
  3. package/obj/template/.claude/commands/init-project.md +26 -4
  4. package/obj/template/.claude/hooks/consent_gate_grant.mjs +107 -0
  5. package/obj/template/.claude/hooks/git_commit_guard.mjs +224 -0
  6. package/obj/template/.claude/hooks/harness_continuation.sh +101 -34
  7. package/obj/template/.claude/hooks/lib/common.mjs +283 -0
  8. package/obj/template/.claude/hooks/lib/common.sh +1 -1
  9. package/obj/template/.claude/hooks/memory_session_start.sh +20 -6
  10. package/obj/template/.claude/hooks/memory_stop.sh +161 -2
  11. package/obj/template/.claude/hooks/spec_approval_guard.sh +1 -1
  12. package/obj/template/.claude/hooks/swarm_approval_guard.sh +1 -1
  13. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +7 -7
  14. package/obj/template/.claude/hooks/tests/fixtures/memory_stop_landmark_baseline.txt +21 -0
  15. package/obj/template/.claude/hooks/tests/fixtures/regenerate-ac008.sh +47 -0
  16. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +7 -3
  17. package/obj/template/.claude/hooks/tests/memory_stop_intent_test.sh +329 -0
  18. package/obj/template/.claude/hooks/tests/regenerate_ac008_test.sh +99 -0
  19. package/obj/template/.claude/memory/README.md +8 -3
  20. package/obj/template/.claude/memory/backlog.md +12 -0
  21. package/obj/template/.claude/project.json +6 -1
  22. package/obj/template/.claude/settings.json +3 -4
  23. package/obj/template/.claude/skills/audit-baseline/audit.sh +28 -16
  24. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/_pending_opener_only.md +3 -0
  25. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_empty_body.md +4 -0
  26. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_full_with_entries.md +9 -0
  27. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_no_opener.md +3 -0
  28. package/obj/template/.claude/skills/audit-baseline/tests/fixtures/preamble_opener_only.md +3 -0
  29. package/obj/template/.claude/skills/audit-baseline/tests/preamble_check_test.sh +147 -0
  30. package/obj/template/.claude/skills/chore/SKILL.md +5 -3
  31. package/obj/template/.claude/skills/commit/SKILL.md +5 -4
  32. package/obj/template/.claude/skills/copywriting/LICENSE +21 -0
  33. package/obj/template/.claude/skills/copywriting/NOTICE +23 -0
  34. package/obj/template/.claude/skills/copywriting/SKILL.md +1 -1
  35. package/obj/template/.claude/skills/design-ui/SKILL.md +23 -5
  36. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +26 -5
  37. package/obj/template/.claude/skills/design-ui/references/orchestration.md +1 -0
  38. package/obj/template/.claude/skills/design-ui/references/state-machine.md +5 -3
  39. package/obj/template/.claude/skills/documentation/LICENSE +202 -0
  40. package/obj/template/.claude/skills/documentation/NOTICE +22 -0
  41. package/obj/template/.claude/skills/harness/SKILL.md +3 -1
  42. package/obj/template/.claude/skills/humanizer/LICENSE +21 -0
  43. package/obj/template/.claude/skills/humanizer/NOTICE +21 -0
  44. package/obj/template/.claude/skills/impeccable/LICENSE +202 -0
  45. package/obj/template/.claude/skills/impeccable/NOTICE +24 -0
  46. package/obj/template/.claude/skills/memory-flush/SKILL.md +20 -4
  47. package/obj/template/.claude/skills/memory-flush/sweep.py +74 -6
  48. package/obj/template/.claude/skills/memory-flush/tests/run.sh +300 -1
  49. package/obj/template/.claude/skills/tdd/SKILL.md +2 -1
  50. package/obj/template/.claude/skills/tdd/drift_check.py +180 -0
  51. package/obj/template/.claude/skills/tdd/tests/drift_check_test.sh +190 -0
  52. package/obj/template/.claude/skills/tdd/tests/run.sh +21 -0
  53. package/obj/template/.claude/skills/technical-tutorials/LICENSE +21 -0
  54. package/obj/template/.claude/skills/technical-tutorials/NOTICE +23 -0
  55. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +1 -1
  56. package/obj/template/.claude/skills/triage/SKILL.md +8 -3
  57. package/obj/template/CLAUDE.md +34 -23
  58. package/obj/template/docs/init/seed.md +36 -21
  59. package/obj/template/manifest.json +59 -33
  60. package/package.json +1 -1
  61. package/src/CLAUDE.template.md +34 -23
  62. package/src/memory/backlog.template.md +12 -0
  63. package/src/project.template.json +6 -1
  64. package/src/seed.template.md +36 -21
  65. package/src/settings.template.json +3 -4
  66. package/obj/template/.claude/hooks/consent_gate_grant.sh +0 -89
  67. 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.sh
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
- if pending_count > 0:
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{"" if pending_count == 1 else "s"} pending in `_pending.md`** — '
173
- 'run `/memory-flush` to review and commit keepers before starting workflow phases.'
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.sh on /approve-spec invocation, OUTSIDE Claude's
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.sh on /approve-swarm. Validated + consumed via
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: 32 · stale (>=30 commits old): 0
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` | 19 | 0 | ok |
8
- | `libraries.md` | 3 | 0 | ok |
9
- | `decisions.md` | 1 | 0 | ok |
10
- | `landmines.md` | 5 | 0 | ok |
11
- | `conventions.md` | 3 | 0 | ok |
12
- | `pending-questions.md` | 1 | 0 | ok |
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"