deepflow 0.1.87 → 0.1.89

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/bin/install.js CHANGED
@@ -187,7 +187,7 @@ async function main() {
187
187
  console.log(' skills/ — gap-discovery, atomic-commits, code-completeness, browse-fetch, browse-verify');
188
188
  console.log(' agents/ — reasoner (/df:auto — autonomous execution via /loop)');
189
189
  if (level === 'global') {
190
- console.log(' hooks/ — statusline, update checker, invariant checker');
190
+ console.log(' hooks/ — statusline, update checker, invariant checker, worktree guard');
191
191
  }
192
192
  console.log(' hooks/df-spec-* — spec validation (auto-enforced by /df:spec and /df:plan)');
193
193
  console.log(' env/ — ENABLE_LSP_TOOL (code navigation via goToDefinition, findReferences, workspaceSymbol)');
@@ -238,6 +238,11 @@ async function configureHooks(claudeDir) {
238
238
  const updateCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-check-update.js')}"`;
239
239
  const consolidationCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-consolidation-check.js')}"`;
240
240
  const quotaLoggerCmd = `node "${path.join(claudeDir, 'hooks', 'df-quota-logger.js')}"`;
241
+ const toolUsageCmd = `node "${path.join(claudeDir, 'hooks', 'df-tool-usage.js')}"`;
242
+ const dashboardPushCmd = `node "${path.join(claudeDir, 'hooks', 'df-dashboard-push.js')}"`;
243
+ const executionHistoryCmd = `node "${path.join(claudeDir, 'hooks', 'df-execution-history.js')}"`;
244
+ const worktreeGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-worktree-guard.js')}"`;
245
+ const invariantCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-invariant-check.js')}"`;
241
246
 
242
247
  let settings = {};
243
248
 
@@ -323,10 +328,10 @@ async function configureHooks(claudeDir) {
323
328
  settings.hooks.SessionEnd = [];
324
329
  }
325
330
 
326
- // Remove any existing quota logger from SessionEnd
331
+ // Remove any existing quota logger / dashboard push from SessionEnd
327
332
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
328
333
  const cmd = hook.hooks?.[0]?.command || '';
329
- return !cmd.includes('df-quota-logger');
334
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
330
335
  });
331
336
 
332
337
  // Add quota logger to SessionEnd
@@ -336,7 +341,59 @@ async function configureHooks(claudeDir) {
336
341
  command: quotaLoggerCmd
337
342
  }]
338
343
  });
339
- log('Quota logger configured');
344
+
345
+ // Add dashboard push to SessionEnd (fire-and-forget, skips when dashboard_url unset)
346
+ settings.hooks.SessionEnd.push({
347
+ hooks: [{
348
+ type: 'command',
349
+ command: dashboardPushCmd
350
+ }]
351
+ });
352
+ log('Quota logger + dashboard push configured (SessionEnd)');
353
+
354
+ // Configure PostToolUse hook for tool usage instrumentation
355
+ if (!settings.hooks.PostToolUse) {
356
+ settings.hooks.PostToolUse = [];
357
+ }
358
+
359
+ // Remove any existing deepflow tool usage / execution history / worktree guard / invariant check hooks from PostToolUse
360
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
361
+ const cmd = hook.hooks?.[0]?.command || '';
362
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
363
+ });
364
+
365
+ // Add tool usage hook
366
+ settings.hooks.PostToolUse.push({
367
+ hooks: [{
368
+ type: 'command',
369
+ command: toolUsageCmd
370
+ }]
371
+ });
372
+
373
+ // Add execution history hook
374
+ settings.hooks.PostToolUse.push({
375
+ hooks: [{
376
+ type: 'command',
377
+ command: executionHistoryCmd
378
+ }]
379
+ });
380
+
381
+ // Add worktree guard hook (blocks Write/Edit to main-branch files when df/* worktree exists)
382
+ settings.hooks.PostToolUse.push({
383
+ hooks: [{
384
+ type: 'command',
385
+ command: worktreeGuardCmd
386
+ }]
387
+ });
388
+
389
+ // Add invariant check hook (exits 1 on hard failures after git commit)
390
+ settings.hooks.PostToolUse.push({
391
+ hooks: [{
392
+ type: 'command',
393
+ command: invariantCheckCmd
394
+ }]
395
+ });
396
+ log('PostToolUse hook configured');
340
397
 
341
398
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
342
399
  }
@@ -518,7 +575,7 @@ async function uninstall() {
518
575
  ];
519
576
 
520
577
  if (level === 'global') {
521
- toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js');
578
+ toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-consolidation-check.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js');
522
579
  }
523
580
 
524
581
  for (const item of toRemove) {
@@ -556,17 +613,26 @@ async function uninstall() {
556
613
  if (settings.hooks?.SessionEnd) {
557
614
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
558
615
  const cmd = hook.hooks?.[0]?.command || '';
559
- return !cmd.includes('df-quota-logger');
616
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
560
617
  });
561
618
  if (settings.hooks.SessionEnd.length === 0) {
562
619
  delete settings.hooks.SessionEnd;
563
620
  }
564
621
  }
622
+ if (settings.hooks?.PostToolUse) {
623
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
624
+ const cmd = hook.hooks?.[0]?.command || '';
625
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-invariant-check');
626
+ });
627
+ if (settings.hooks.PostToolUse.length === 0) {
628
+ delete settings.hooks.PostToolUse;
629
+ }
630
+ }
565
631
  if (settings.hooks && Object.keys(settings.hooks).length === 0) {
566
632
  delete settings.hooks;
567
633
  }
568
634
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
569
- console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd hooks`);
635
+ console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd/PostToolUse hooks`);
570
636
  } catch (e) {
571
637
  // Fail silently
572
638
  }
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow dashboard push — SessionEnd hook
4
+ * Collects session summary (tokens, duration, tool calls, model), gets
5
+ * git user.name, and POSTs to dashboard_url from .deepflow/config.yaml.
6
+ * Silently skips if dashboard_url is not configured.
7
+ * Fire-and-forget: exits immediately after spawning background worker.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ // Spawn background process so the hook returns immediately
13
+ if (process.argv[2] !== '--background') {
14
+ const { spawn } = require('child_process');
15
+ const child = spawn(process.execPath, [__filename, '--background'], {
16
+ detached: true,
17
+ stdio: 'ignore',
18
+ // Pass stdin data through env so background process can read it
19
+ env: { ...process.env, _DF_HOOK_INPUT: getStdinSync() }
20
+ });
21
+ child.unref();
22
+ process.exit(0);
23
+ }
24
+
25
+ // --- Background process ---
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const os = require('os');
30
+ const { execFileSync } = require('child_process');
31
+ const https = require('https');
32
+ const http = require('http');
33
+
34
+ function getStdinSync() {
35
+ // Non-blocking stdin read for the parent process (limited buffer)
36
+ try {
37
+ return fs.readFileSync('/dev/stdin', { encoding: 'utf8', flag: 'rs' }) || '';
38
+ } catch (_e) {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ /** Read .deepflow/config.yaml and extract dashboard_url (no yaml dep — regex parse). */
44
+ function getDashboardUrl(cwd) {
45
+ const configPath = path.join(cwd, '.deepflow', 'config.yaml');
46
+ if (!fs.existsSync(configPath)) return null;
47
+ try {
48
+ const content = fs.readFileSync(configPath, 'utf8');
49
+ const match = content.match(/^\s*dashboard_url\s*:\s*(.+)$/m);
50
+ if (!match) return null;
51
+ const val = match[1].trim().replace(/^['"]|['"]$/g, '');
52
+ return val || null;
53
+ } catch (_e) {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Get git user.name in the given directory. Returns 'unknown' on failure. */
59
+ function getGitUser(cwd) {
60
+ try {
61
+ return execFileSync('git', ['config', 'user.name'], {
62
+ cwd,
63
+ encoding: 'utf8',
64
+ timeout: 3000,
65
+ stdio: ['ignore', 'pipe', 'ignore']
66
+ }).trim() || 'unknown';
67
+ } catch (_e) {
68
+ return process.env.USER || 'unknown';
69
+ }
70
+ }
71
+
72
+ /** POST JSON payload to url. Returns true on 200. */
73
+ function postJson(url, payload) {
74
+ return new Promise((resolve) => {
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(url);
78
+ } catch (_e) {
79
+ resolve(false);
80
+ return;
81
+ }
82
+
83
+ const body = JSON.stringify(payload);
84
+ const isHttps = parsed.protocol === 'https:';
85
+ const lib = isHttps ? https : http;
86
+
87
+ const options = {
88
+ hostname: parsed.hostname,
89
+ port: parsed.port || (isHttps ? 443 : 80),
90
+ path: parsed.pathname,
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ 'Content-Length': Buffer.byteLength(body)
95
+ },
96
+ timeout: 10000
97
+ };
98
+
99
+ const req = lib.request(options, (res) => {
100
+ res.resume();
101
+ res.on('end', () => resolve(res.statusCode === 200));
102
+ });
103
+
104
+ req.on('error', () => resolve(false));
105
+ req.on('timeout', () => { req.destroy(); resolve(false); });
106
+ req.write(body);
107
+ req.end();
108
+ });
109
+ }
110
+
111
+ async function main() {
112
+ try {
113
+ const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
114
+ const dashboardUrl = getDashboardUrl(cwd);
115
+
116
+ // Silently skip if not configured
117
+ if (!dashboardUrl) process.exit(0);
118
+
119
+ // Parse session data from hook input (passed via env)
120
+ let hookData = {};
121
+ try {
122
+ const raw = process.env._DF_HOOK_INPUT || '';
123
+ if (raw) hookData = JSON.parse(raw);
124
+ } catch (_e) {
125
+ // fallback: empty data, we'll still send what we know
126
+ }
127
+
128
+ const gitUser = getGitUser(cwd);
129
+ const projectName = path.basename(cwd);
130
+ const ts = new Date().toISOString();
131
+
132
+ // Extract token fields from hook data (Claude Code SessionEnd format)
133
+ const usage = hookData.usage || hookData.context_window?.current_usage || {};
134
+ const inputTokens = usage.input_tokens || 0;
135
+ const outputTokens = usage.output_tokens || 0;
136
+ const cacheReadTokens = usage.cache_read_input_tokens || usage.cache_read_tokens || 0;
137
+ const cacheCreationTokens = usage.cache_creation_input_tokens || usage.cache_creation_tokens || 0;
138
+ const model = hookData.model?.id || hookData.model?.display_name || hookData.model || 'unknown';
139
+ const sessionId = hookData.session_id || hookData.sessionId || `${gitUser}:${projectName}:${ts}`;
140
+ const durationMs = hookData.duration_ms || null;
141
+ const toolCalls = hookData.tool_calls || hookData.tool_use_count || 0;
142
+
143
+ const payload = {
144
+ user: gitUser,
145
+ project: projectName,
146
+ session_id: sessionId,
147
+ model,
148
+ tokens: {
149
+ [model]: {
150
+ input: inputTokens,
151
+ output: outputTokens,
152
+ cache_read: cacheReadTokens,
153
+ cache_creation: cacheCreationTokens
154
+ }
155
+ },
156
+ started_at: hookData.started_at || ts,
157
+ ended_at: ts,
158
+ duration_ms: durationMs,
159
+ tool_calls: toolCalls
160
+ };
161
+
162
+ const ingestUrl = dashboardUrl.replace(/\/$/, '') + '/api/ingest';
163
+ await postJson(ingestUrl, payload);
164
+ } catch (_e) {
165
+ // Never break session end
166
+ }
167
+ process.exit(0);
168
+ }
169
+
170
+ main();
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow execution history recorder
4
+ * PostToolUse hook: fires when the Agent tool completes.
5
+ * Appends task_start + task_end records to {cwd}/.deepflow/execution-history.jsonl.
6
+ * Exits silently (code 0) on all errors — never blocks tool execution (REQ-8).
7
+ *
8
+ * Output record fields:
9
+ * type, task_id, spec, session_id, timestamp, status
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ /**
18
+ * Extract task_id from Agent prompt.
19
+ * Pattern: T{n} anywhere in the prompt, e.g. "T21: fix bug" → "T21"
20
+ * Falls back to DEEPFLOW_TASK_ID env var (C-6).
21
+ */
22
+ function extractTaskId(prompt) {
23
+ if (prompt) {
24
+ const match = prompt.match(/T(\d+)/);
25
+ if (match) return `T${match[1]}`;
26
+ }
27
+ return process.env.DEEPFLOW_TASK_ID || null;
28
+ }
29
+
30
+ /**
31
+ * Extract spec name from Agent prompt.
32
+ * Looks for pattern: "spec: {name}" or "spec:{name}"
33
+ */
34
+ function extractSpec(prompt) {
35
+ if (!prompt) return null;
36
+ const match = prompt.match(/spec:\s*(\S+)/i);
37
+ return match ? match[1] : null;
38
+ }
39
+
40
+ /**
41
+ * Parse task status from tool_response content.
42
+ * Looks for TASK_STATUS:{pass|revert|fail} in the response text.
43
+ * Defaults to "unknown" if not found (REQ-2).
44
+ */
45
+ function extractStatus(toolResponse) {
46
+ const responseStr = JSON.stringify(toolResponse || '');
47
+ const match = responseStr.match(/TASK_STATUS:(pass|revert|fail)/);
48
+ return match ? match[1] : 'unknown';
49
+ }
50
+
51
+ /**
52
+ * Resolve the project root from cwd.
53
+ * Walks up to find the .deepflow directory, or falls back to cwd itself.
54
+ */
55
+ function resolveProjectRoot(cwd) {
56
+ if (!cwd) return process.cwd();
57
+ // If inside a worktree, strip down to the project root
58
+ const worktreeMatch = cwd.match(/^(.*?)(?:\/\.deepflow\/worktrees\/[^/]+)/);
59
+ if (worktreeMatch) return worktreeMatch[1];
60
+ return cwd;
61
+ }
62
+
63
+ // Read all stdin, then process
64
+ let raw = '';
65
+ process.stdin.setEncoding('utf8');
66
+ process.stdin.on('data', chunk => { raw += chunk; });
67
+ process.stdin.on('end', () => {
68
+ try {
69
+ const data = JSON.parse(raw);
70
+
71
+ // Only fire for Agent tool calls
72
+ if (data.tool_name !== 'Agent') {
73
+ process.exit(0);
74
+ }
75
+
76
+ const prompt = (data.tool_input && data.tool_input.prompt) || '';
77
+ const taskId = extractTaskId(prompt);
78
+
79
+ // Only record if we have a task_id
80
+ if (!taskId) {
81
+ process.exit(0);
82
+ }
83
+
84
+ const cwd = data.cwd || process.cwd();
85
+ const projectRoot = resolveProjectRoot(cwd);
86
+ const historyFile = path.join(projectRoot, '.deepflow', 'execution-history.jsonl');
87
+
88
+ const timestamp = new Date().toISOString();
89
+ const sessionId = data.session_id || null;
90
+ const spec = extractSpec(prompt);
91
+ const status = extractStatus(data.tool_response);
92
+
93
+ const startRecord = {
94
+ type: 'task_start',
95
+ task_id: taskId,
96
+ spec,
97
+ session_id: sessionId,
98
+ timestamp,
99
+ };
100
+
101
+ const endRecord = {
102
+ type: 'task_end',
103
+ task_id: taskId,
104
+ session_id: sessionId,
105
+ status,
106
+ timestamp,
107
+ };
108
+
109
+ const logDir = path.dirname(historyFile);
110
+ if (!fs.existsSync(logDir)) {
111
+ fs.mkdirSync(logDir, { recursive: true });
112
+ }
113
+
114
+ fs.appendFileSync(historyFile, JSON.stringify(startRecord) + '\n');
115
+ fs.appendFileSync(historyFile, JSON.stringify(endRecord) + '\n');
116
+ } catch (_e) {
117
+ // Fail silently — never break tool execution (REQ-8)
118
+ }
119
+ process.exit(0);
120
+ });
@@ -1020,6 +1020,130 @@ function checkInvariants(diff, specContent, opts = {}) {
1020
1020
  return { hard, advisory };
1021
1021
  }
1022
1022
 
1023
+ // ── Hook entry point (REQ-5 AC-7) ────────────────────────────────────────────
1024
+ //
1025
+ // When called as a PostToolUse hook, Claude Code pipes a JSON payload on stdin:
1026
+ // { tool_name, tool_input, tool_response, cwd, ... }
1027
+ //
1028
+ // We fire after any Bash call that looks like a git commit, extract the diff
1029
+ // from HEAD~1, load the active spec from .deepflow/, and exit(1) on hard failures.
1030
+ //
1031
+ // Detection: if stdin is not a TTY we treat it as hook mode and attempt JSON parse.
1032
+ // If the payload is not a git-commit Bash call we exit(0) silently.
1033
+
1034
+ function loadActiveSpec(cwd) {
1035
+ const deepflowDir = path.join(cwd, '.deepflow');
1036
+ let specContent = null;
1037
+
1038
+ try {
1039
+ // Look for doing-*.md specs first (in-progress)
1040
+ const entries = fs.readdirSync(deepflowDir);
1041
+ const doingSpec = entries.find((e) => e.startsWith('doing-') && e.endsWith('.md'));
1042
+ if (doingSpec) {
1043
+ specContent = fs.readFileSync(path.join(deepflowDir, doingSpec), 'utf8');
1044
+ return specContent;
1045
+ }
1046
+
1047
+ // Fall back to specs/ subdirectory
1048
+ const specsDir = path.join(cwd, 'specs');
1049
+ if (fs.existsSync(specsDir)) {
1050
+ const specEntries = fs.readdirSync(specsDir);
1051
+ const doingInSpecs = specEntries.find((e) => e.startsWith('doing-') && e.endsWith('.md'));
1052
+ if (doingInSpecs) {
1053
+ specContent = fs.readFileSync(path.join(specsDir, doingInSpecs), 'utf8');
1054
+ return specContent;
1055
+ }
1056
+ }
1057
+ } catch (_) {
1058
+ // Cannot read .deepflow or specs dir — return null
1059
+ }
1060
+
1061
+ return null;
1062
+ }
1063
+
1064
+ function extractDiffFromLastCommit(cwd) {
1065
+ try {
1066
+ return execSync('git diff HEAD~1 HEAD', {
1067
+ encoding: 'utf8',
1068
+ cwd,
1069
+ stdio: ['ignore', 'pipe', 'ignore'],
1070
+ });
1071
+ } catch (_) {
1072
+ return null;
1073
+ }
1074
+ }
1075
+
1076
+ function isGitCommitBash(toolName, toolInput) {
1077
+ if (toolName !== 'Bash') return false;
1078
+ const cmd = (toolInput && (toolInput.command || toolInput.cmd || '')) || '';
1079
+ return /git\s+commit\b/.test(cmd);
1080
+ }
1081
+
1082
+ // Run hook mode when stdin is not a TTY (i.e., piped payload from Claude Code)
1083
+ if (!process.stdin.isTTY) {
1084
+ let raw = '';
1085
+ process.stdin.setEncoding('utf8');
1086
+ process.stdin.on('data', (chunk) => { raw += chunk; });
1087
+ process.stdin.on('end', () => {
1088
+ let data;
1089
+ try {
1090
+ data = JSON.parse(raw);
1091
+ } catch (_) {
1092
+ // Not valid JSON — not a hook payload, exit silently
1093
+ process.exit(0);
1094
+ }
1095
+
1096
+ try {
1097
+ const toolName = data.tool_name || '';
1098
+ const toolInput = data.tool_input || {};
1099
+
1100
+ // Only run after a git commit bash call
1101
+ if (!isGitCommitBash(toolName, toolInput)) {
1102
+ process.exit(0);
1103
+ }
1104
+
1105
+ const cwd = data.cwd || process.cwd();
1106
+
1107
+ const diff = extractDiffFromLastCommit(cwd);
1108
+ if (!diff) {
1109
+ // No diff available (e.g. initial commit) — pass through
1110
+ process.exit(0);
1111
+ }
1112
+
1113
+ const specContent = loadActiveSpec(cwd);
1114
+ if (!specContent) {
1115
+ // No active spec found — not a deepflow project or no spec in progress
1116
+ process.exit(0);
1117
+ }
1118
+
1119
+ const results = checkInvariants(diff, specContent, { mode: 'auto', taskType: 'implementation', projectRoot: cwd });
1120
+
1121
+ if (results.hard.length > 0) {
1122
+ console.error('[df-invariant-check] Hard invariant failures detected:');
1123
+ const outputLines = formatOutput(results);
1124
+ for (const line of outputLines) {
1125
+ if (results.hard.some((v) => formatViolation(v) === line)) {
1126
+ console.error(` ${line}`);
1127
+ }
1128
+ }
1129
+ process.exit(1);
1130
+ }
1131
+
1132
+ if (results.advisory.length > 0) {
1133
+ console.warn('[df-invariant-check] Advisory warnings:');
1134
+ for (const v of results.advisory) {
1135
+ console.warn(` ${formatViolation(v)}`);
1136
+ }
1137
+ }
1138
+
1139
+ process.exit(0);
1140
+ } catch (_err) {
1141
+ // Unexpected error — fail open so we never break non-deepflow projects
1142
+ process.exit(0);
1143
+ }
1144
+ });
1145
+ } else {
1146
+
1023
1147
  // ── CLI entry point (REQ-6) ───────────────────────────────────────────────────
1024
1148
  if (require.main === module) {
1025
1149
  const args = process.argv.slice(2);
@@ -1091,6 +1215,8 @@ if (require.main === module) {
1091
1215
  process.exit(results.hard.length > 0 ? 1 : 0);
1092
1216
  }
1093
1217
 
1218
+ } // end else (TTY / CLI mode)
1219
+
1094
1220
  module.exports = {
1095
1221
  checkInvariants,
1096
1222
  checkLspAvailability,
@@ -22,6 +22,62 @@ const REQUIRED_SECTIONS = [
22
22
  ['Technical Notes', 'architecture notes', 'architecture', 'tech notes', 'implementation notes'],
23
23
  ];
24
24
 
25
+ // ── Spec layers (onion model) ───────────────────────────────────────────
26
+ // Each layer defines sections that must ALL be present (cumulative with prior layers).
27
+ // The computed layer is the highest where all cumulative sections exist.
28
+ //
29
+ // L0: Problem defined → spikes only
30
+ // L1: Requirements known → targeted spikes
31
+ // L2: Verifiable → implementation tasks
32
+ // L3: Fully constrained → full impact analysis + optimize tasks
33
+ const LAYER_DEFINITIONS = [
34
+ { layer: 0, sections: ['Objective'] },
35
+ { layer: 1, sections: ['Requirements'] },
36
+ { layer: 2, sections: ['Acceptance Criteria'] },
37
+ { layer: 3, sections: ['Constraints', 'Out of Scope', 'Technical Notes'] },
38
+ ];
39
+
40
+ /**
41
+ * Compute the spec layer from its content.
42
+ * Returns the highest layer (0–3) where ALL cumulative required sections are present.
43
+ * Returns -1 if not even L0 (no Objective).
44
+ */
45
+ function computeLayer(content) {
46
+ const headersFound = [];
47
+ for (const line of content.split('\n')) {
48
+ const m = line.match(/^##\s+(.+)/i);
49
+ if (m) {
50
+ const raw = m[1].trim().replace(/^\d+\.\s*/, '');
51
+ headersFound.push(raw.toLowerCase());
52
+ }
53
+ }
54
+
55
+ // Inline *AC: lines satisfy the Acceptance Criteria requirement
56
+ const hasInlineAC = /\*AC[:.]/.test(content);
57
+
58
+ let currentLayer = -1;
59
+ for (const { layer, sections } of LAYER_DEFINITIONS) {
60
+ const allPresent = sections.every((section) => {
61
+ // Find the REQUIRED_SECTIONS entry for aliases
62
+ const entry = REQUIRED_SECTIONS.find(
63
+ ([canonical]) => canonical.toLowerCase() === section.toLowerCase()
64
+ );
65
+ const allNames = entry
66
+ ? [entry[0], ...entry.slice(1)].map((n) => n.toLowerCase())
67
+ : [section.toLowerCase()];
68
+
69
+ if (section === 'Acceptance Criteria' && hasInlineAC) return true;
70
+ return headersFound.some((h) => allNames.includes(h));
71
+ });
72
+ if (allPresent) {
73
+ currentLayer = layer;
74
+ } else {
75
+ break; // layers are cumulative — can't skip
76
+ }
77
+ }
78
+ return currentLayer;
79
+ }
80
+
25
81
  /**
26
82
  * Validate a spec's content against hard invariants and advisory checks.
27
83
  *
@@ -34,7 +90,11 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
34
90
  const hard = [];
35
91
  const advisory = [];
36
92
 
37
- // ── (a) Required sections ────────────────────────────────────────────
93
+ const layer = computeLayer(content);
94
+
95
+ // ── (a) Required sections (layer-aware) ──────────────────────────────
96
+ // Hard-fail only for sections required by the CURRENT layer.
97
+ // Missing sections beyond the current layer are advisory (hints to deepen).
38
98
  const headersFound = [];
39
99
  for (const line of content.split('\n')) {
40
100
  const m = line.match(/^##\s+(.+)/i);
@@ -45,13 +105,25 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
45
105
  }
46
106
  }
47
107
 
108
+ // Collect all sections required up to the current layer
109
+ const layerRequiredSections = new Set();
110
+ for (const { layer: l, sections } of LAYER_DEFINITIONS) {
111
+ if (l <= layer) {
112
+ for (const s of sections) layerRequiredSections.add(s.toLowerCase());
113
+ }
114
+ }
115
+
48
116
  for (const [canonical, ...aliases] of REQUIRED_SECTIONS) {
49
117
  const allNames = [canonical, ...aliases].map((n) => n.toLowerCase());
50
118
  const found = headersFound.some((h) => allNames.includes(h.toLowerCase()));
51
119
  if (!found) {
52
120
  // Inline *AC: lines satisfy the Acceptance Criteria requirement
53
121
  if (canonical === 'Acceptance Criteria' && /\*AC[:.]/.test(content)) continue;
54
- hard.push(`Missing required section: "## ${canonical}"`);
122
+ if (layerRequiredSections.has(canonical.toLowerCase())) {
123
+ hard.push(`Missing required section: "## ${canonical}"`);
124
+ } else {
125
+ advisory.push(`Missing section for deeper layer: "## ${canonical}"`);
126
+ }
55
127
  }
56
128
  }
57
129
 
@@ -161,7 +233,7 @@ function validateSpec(content, { mode = 'interactive', specsDir = null } = {}) {
161
233
  hard.push(...advisory.splice(0, advisory.length));
162
234
  }
163
235
 
164
- return { hard, advisory };
236
+ return { layer, hard, advisory };
165
237
  }
166
238
 
167
239
  /**
@@ -230,7 +302,9 @@ if (require.main === module) {
230
302
  console.log('All checks passed.');
231
303
  }
232
304
 
305
+ console.log(`Spec layer: L${result.layer} (${['problem defined', 'requirements known', 'verifiable', 'fully constrained'][result.layer] || 'incomplete'})`);
306
+
233
307
  process.exit(result.hard.length > 0 ? 1 : 0);
234
308
  }
235
309
 
236
- module.exports = { validateSpec, extractSection };
310
+ module.exports = { validateSpec, extractSection, computeLayer };