worclaude 2.5.1 → 2.6.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.
@@ -0,0 +1,110 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { readFile } from '../../../utils/file.js';
4
+
5
+ const EXCLUDES = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ '.claude',
9
+ 'dist',
10
+ 'build',
11
+ '.next',
12
+ 'target',
13
+ 'vendor',
14
+ '.venv',
15
+ '__pycache__',
16
+ '.cache',
17
+ ]);
18
+
19
+ const MAX_FILES = 200;
20
+ const MAX_DEPTH = 3;
21
+ const HEADING_CHAR_CAP = 80;
22
+ const ROOT_PATTERNS = [/^PRD/i, /^SPEC/i, /^REQUIREMENTS/i];
23
+
24
+ async function rootMatches(projectRoot) {
25
+ const matches = [];
26
+ try {
27
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (!entry.isFile()) continue;
30
+ if (ROOT_PATTERNS.some((p) => p.test(entry.name))) {
31
+ matches.push(entry.name);
32
+ }
33
+ }
34
+ } catch {
35
+ /* missing or unreadable — non-fatal */
36
+ }
37
+ return matches;
38
+ }
39
+
40
+ async function walkDocs(dir, projectRoot, depth, results) {
41
+ if (depth > MAX_DEPTH || results.length >= MAX_FILES) return;
42
+ let entries;
43
+ try {
44
+ entries = await fs.readdir(dir, { withFileTypes: true });
45
+ } catch {
46
+ return;
47
+ }
48
+ for (const entry of entries) {
49
+ if (results.length >= MAX_FILES) return;
50
+ if (EXCLUDES.has(entry.name)) continue;
51
+ const fullPath = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ await walkDocs(fullPath, projectRoot, depth + 1, results);
54
+ } else if (entry.isFile() && /\.(md|txt)$/i.test(entry.name)) {
55
+ results.push(path.relative(projectRoot, fullPath).split(path.sep).join('/'));
56
+ }
57
+ }
58
+ }
59
+
60
+ async function firstHeading(projectRoot, relativePath) {
61
+ try {
62
+ const raw = await readFile(path.join(projectRoot, relativePath));
63
+ for (const rawLine of raw.split(/\r?\n/)) {
64
+ const line = rawLine.trim();
65
+ if (line === '') continue;
66
+ const match = line.match(/^#\s+(.+)$/);
67
+ if (match) return match[1].slice(0, HEADING_CHAR_CAP);
68
+ return line.slice(0, HEADING_CHAR_CAP);
69
+ }
70
+ } catch {
71
+ /* missing or unreadable — non-fatal */
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export default async function detectSpecDocs(projectRoot) {
77
+ const docs = [];
78
+ const rootNames = await rootMatches(projectRoot);
79
+ for (const name of rootNames) docs.push(name);
80
+
81
+ const docsDir = path.join(projectRoot, 'docs');
82
+ try {
83
+ const stat = await fs.stat(docsDir);
84
+ if (stat.isDirectory()) {
85
+ await walkDocs(docsDir, projectRoot, 1, docs);
86
+ }
87
+ } catch {
88
+ /* missing or unreadable — non-fatal */
89
+ }
90
+
91
+ if (docs.length === 0) return [];
92
+
93
+ const unique = Array.from(new Set(docs));
94
+ const value = await Promise.all(
95
+ unique.map(async (relative) => ({
96
+ path: relative,
97
+ firstHeading: await firstHeading(projectRoot, relative),
98
+ }))
99
+ );
100
+
101
+ return [
102
+ {
103
+ field: 'specDocs',
104
+ value,
105
+ confidence: 'high',
106
+ source: 'project tree',
107
+ candidates: null,
108
+ },
109
+ ];
110
+ }
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import { fileExists } from '../../../utils/file.js';
3
+ import { getAllDeps, readPyprojectToml } from '../manifests.js';
4
+
5
+ const CONFIG_VARIANTS = {
6
+ vitest: ['vitest.config.js', 'vitest.config.ts', 'vitest.config.mjs', 'vitest.config.mts'],
7
+ jest: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs', 'jest.config.cjs'],
8
+ playwright: ['playwright.config.js', 'playwright.config.ts', 'playwright.config.mjs'],
9
+ cypress: ['cypress.config.js', 'cypress.config.ts', 'cypress.config.mjs'],
10
+ mocha: ['.mocharc.js', '.mocharc.json', '.mocharc.yml', '.mocharc.cjs'],
11
+ };
12
+
13
+ async function findConfig(projectRoot, variants) {
14
+ for (const name of variants) {
15
+ if (await fileExists(path.join(projectRoot, name))) return name;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ async function hasPytestConfig(projectRoot) {
21
+ if (await fileExists(path.join(projectRoot, 'pytest.ini'))) return 'pytest.ini';
22
+ const pyproject = await readPyprojectToml(projectRoot);
23
+ if (pyproject && pyproject.tool && pyproject.tool.pytest) {
24
+ return 'pyproject.toml';
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export default async function detectTesting(projectRoot) {
30
+ const { js, py } = await getAllDeps(projectRoot);
31
+ const results = [];
32
+
33
+ const jsFrameworkOrder = ['vitest', 'jest', 'mocha'];
34
+ for (const name of jsFrameworkOrder) {
35
+ if (js[name] !== undefined) {
36
+ const configFile = await findConfig(projectRoot, CONFIG_VARIANTS[name] || []);
37
+ results.push({
38
+ field: 'testing',
39
+ value: {
40
+ framework: name,
41
+ configFile,
42
+ browserTesting: false,
43
+ },
44
+ confidence: 'high',
45
+ source: configFile || 'package.json',
46
+ candidates: null,
47
+ });
48
+ break;
49
+ }
50
+ }
51
+
52
+ if (js['playwright'] !== undefined || js['@playwright/test'] !== undefined) {
53
+ const configFile = await findConfig(projectRoot, CONFIG_VARIANTS.playwright);
54
+ results.push({
55
+ field: 'testing',
56
+ value: { framework: 'playwright', configFile, browserTesting: true },
57
+ confidence: 'high',
58
+ source: configFile || 'package.json',
59
+ candidates: null,
60
+ });
61
+ }
62
+
63
+ if (js['cypress'] !== undefined) {
64
+ const configFile = await findConfig(projectRoot, CONFIG_VARIANTS.cypress);
65
+ results.push({
66
+ field: 'testing',
67
+ value: { framework: 'cypress', configFile, browserTesting: true },
68
+ confidence: 'high',
69
+ source: configFile || 'package.json',
70
+ candidates: null,
71
+ });
72
+ }
73
+
74
+ const pytestPresent =
75
+ py['pytest'] !== undefined || Object.keys(py).some((k) => k.startsWith('pytest-'));
76
+ if (pytestPresent) {
77
+ const configFile = await hasPytestConfig(projectRoot);
78
+ results.push({
79
+ field: 'testing',
80
+ value: { framework: 'pytest', configFile, browserTesting: false },
81
+ confidence: 'high',
82
+ source: configFile || 'pyproject.toml',
83
+ candidates: null,
84
+ });
85
+ }
86
+
87
+ return results;
88
+ }
@@ -0,0 +1,126 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
+ import fs from 'fs-extra';
4
+ import { dirExists } from '../../utils/file.js';
5
+ import { resetManifestCache } from './manifests.js';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const DETECTOR_TIMEOUT_MS = 5000;
9
+ const SCHEMA_VERSION = 1;
10
+
11
+ async function loadDetectorsFromDisk() {
12
+ const detectorsDir = path.join(__dirname, 'detectors');
13
+ const entries = await fs.readdir(detectorsDir, { withFileTypes: true });
14
+ const detectorFiles = entries.filter(
15
+ (e) =>
16
+ e.isFile() && !e.name.startsWith('.') && !e.name.startsWith('_') && e.name.endsWith('.js')
17
+ );
18
+ return Promise.all(
19
+ detectorFiles.map(async (entry) => {
20
+ const fileUrl = pathToFileURL(path.join(detectorsDir, entry.name)).href;
21
+ const mod = await import(fileUrl);
22
+ if (typeof mod.default !== 'function') {
23
+ throw new Error(`Detector ${entry.name} must export a default function`);
24
+ }
25
+ return { name: path.basename(entry.name, '.js'), fn: mod.default };
26
+ })
27
+ );
28
+ }
29
+
30
+ function normalizeOverride(overrideDetectors) {
31
+ return overrideDetectors.map((entry, index) => {
32
+ if (typeof entry === 'function') {
33
+ return { name: entry.name || `override-${index}`, fn: entry };
34
+ }
35
+ if (entry && typeof entry.fn === 'function') {
36
+ return { name: entry.name || `override-${index}`, fn: entry.fn };
37
+ }
38
+ throw new Error(`overrideDetectors[${index}] must be a function or { name, fn } object`);
39
+ });
40
+ }
41
+
42
+ function withTimeout(promise, ms) {
43
+ let timeoutId;
44
+ const timeout = new Promise((_, reject) => {
45
+ timeoutId = setTimeout(() => reject(new Error('timeout')), ms);
46
+ });
47
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId));
48
+ }
49
+
50
+ async function runDetector(detector, projectRoot) {
51
+ try {
52
+ const results = await withTimeout(
53
+ Promise.resolve().then(() => detector.fn(projectRoot)),
54
+ DETECTOR_TIMEOUT_MS
55
+ );
56
+ if (!Array.isArray(results)) {
57
+ return {
58
+ results: [],
59
+ error: {
60
+ detector: detector.name,
61
+ kind: 'exception',
62
+ message: `Detector did not return an array (got ${typeof results})`,
63
+ },
64
+ };
65
+ }
66
+ const annotated = results.map((r) => ({ ...r, detector: detector.name }));
67
+ return { results: annotated, error: null };
68
+ } catch (err) {
69
+ const kind = err && err.message === 'timeout' ? 'timeout' : 'exception';
70
+ const message =
71
+ kind === 'timeout'
72
+ ? `Detector exceeded ${DETECTOR_TIMEOUT_MS}ms timeout`
73
+ : String(err && err.message ? err.message : err);
74
+ return { results: [], error: { detector: detector.name, kind, message } };
75
+ }
76
+ }
77
+
78
+ export async function scanProject(projectRoot, options = {}) {
79
+ if (!projectRoot || typeof projectRoot !== 'string') {
80
+ throw new Error('scanProject requires a projectRoot string');
81
+ }
82
+ const absoluteRoot = path.resolve(projectRoot);
83
+ if (!(await dirExists(absoluteRoot))) {
84
+ throw new Error(`projectRoot not found: ${absoluteRoot}`);
85
+ }
86
+
87
+ resetManifestCache();
88
+
89
+ const detectors = options.overrideDetectors
90
+ ? normalizeOverride(options.overrideDetectors)
91
+ : await loadDetectorsFromDisk();
92
+
93
+ const settled = await Promise.all(detectors.map((d) => runDetector(d, absoluteRoot)));
94
+
95
+ const results = [];
96
+ const errors = [];
97
+ for (const { results: r, error } of settled) {
98
+ for (const item of r) results.push(item);
99
+ if (error) errors.push(error);
100
+ }
101
+
102
+ return {
103
+ schemaVersion: SCHEMA_VERSION,
104
+ generatedAt: new Date().toISOString(),
105
+ projectRoot: absoluteRoot,
106
+ results,
107
+ errors,
108
+ };
109
+ }
110
+
111
+ export async function writeDetectionReport(report, projectRoot) {
112
+ if (!report || typeof report !== 'object') {
113
+ throw new Error('writeDetectionReport requires a report object');
114
+ }
115
+ if (report.schemaVersion !== SCHEMA_VERSION) {
116
+ throw new Error(`Unsupported schemaVersion: ${report.schemaVersion}`);
117
+ }
118
+ if (!Array.isArray(report.results) || !Array.isArray(report.errors)) {
119
+ throw new Error('Report must have results and errors arrays');
120
+ }
121
+ const cacheDir = path.join(projectRoot, '.claude', 'cache');
122
+ await fs.ensureDir(cacheDir);
123
+ const filePath = path.join(cacheDir, 'detection-report.json');
124
+ await fs.writeFile(filePath, JSON.stringify(report, null, 2) + '\n', 'utf-8');
125
+ return filePath;
126
+ }
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import { parse as parseToml } from 'smol-toml';
3
+ import { readFile, fileExists } from '../../utils/file.js';
4
+
5
+ // Per-scan caches — cleared by resetManifestCache() at the start of each scanProject call.
6
+ // Without this, detectors that independently call readPackageJson / readPyprojectToml would
7
+ // each re-read and re-parse the same files (up to 7× package.json, 5× pyproject.toml per scan).
8
+ const pkgCache = new Map();
9
+ const pyprojectCache = new Map();
10
+
11
+ export function resetManifestCache() {
12
+ pkgCache.clear();
13
+ pyprojectCache.clear();
14
+ }
15
+
16
+ export async function readPackageJson(projectRoot) {
17
+ if (pkgCache.has(projectRoot)) return pkgCache.get(projectRoot);
18
+ const filePath = path.join(projectRoot, 'package.json');
19
+ let result = null;
20
+ if (await fileExists(filePath)) {
21
+ try {
22
+ const raw = await readFile(filePath);
23
+ result = JSON.parse(raw.replace(/^/, ''));
24
+ } catch {
25
+ result = null;
26
+ }
27
+ }
28
+ pkgCache.set(projectRoot, result);
29
+ return result;
30
+ }
31
+
32
+ export async function readPyprojectToml(projectRoot) {
33
+ if (pyprojectCache.has(projectRoot)) return pyprojectCache.get(projectRoot);
34
+ const filePath = path.join(projectRoot, 'pyproject.toml');
35
+ let result = null;
36
+ if (await fileExists(filePath)) {
37
+ try {
38
+ const raw = await readFile(filePath);
39
+ result = parseToml(raw);
40
+ } catch {
41
+ result = null;
42
+ }
43
+ }
44
+ pyprojectCache.set(projectRoot, result);
45
+ return result;
46
+ }
47
+
48
+ function normalizeDepMap(input) {
49
+ if (!input || typeof input !== 'object') return {};
50
+ if (Array.isArray(input)) {
51
+ const out = {};
52
+ for (const entry of input) {
53
+ if (typeof entry === 'string') {
54
+ const match = entry.match(/^\s*([A-Za-z0-9_.\-[\]]+)/);
55
+ if (match) out[match[1]] = entry;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ return input;
61
+ }
62
+
63
+ export function flattenPyprojectDeps(pyproject) {
64
+ if (!pyproject) return {};
65
+ const deps = {};
66
+ const assign = (map) => {
67
+ const normalized = normalizeDepMap(map);
68
+ for (const [k, v] of Object.entries(normalized)) {
69
+ // Skip Poetry's interpreter pin — `python = "^3.11"` is the runtime, not a dep.
70
+ if (k === 'python') continue;
71
+ if (!(k in deps)) deps[k] = typeof v === 'string' ? v : '';
72
+ }
73
+ };
74
+
75
+ if (pyproject.project) {
76
+ if (pyproject.project.dependencies) assign(pyproject.project.dependencies);
77
+ if (pyproject.project['optional-dependencies']) {
78
+ for (const group of Object.values(pyproject.project['optional-dependencies'])) {
79
+ assign(group);
80
+ }
81
+ }
82
+ }
83
+
84
+ if (pyproject.tool && pyproject.tool.poetry) {
85
+ if (pyproject.tool.poetry.dependencies) assign(pyproject.tool.poetry.dependencies);
86
+ if (pyproject.tool.poetry.group) {
87
+ for (const group of Object.values(pyproject.tool.poetry.group)) {
88
+ if (group && group.dependencies) assign(group.dependencies);
89
+ }
90
+ }
91
+ }
92
+
93
+ if (pyproject['dependency-groups']) {
94
+ for (const group of Object.values(pyproject['dependency-groups'])) {
95
+ assign(group);
96
+ }
97
+ }
98
+
99
+ return deps;
100
+ }
101
+
102
+ export async function getAllDeps(projectRoot) {
103
+ const pkg = await readPackageJson(projectRoot);
104
+ const pyproject = await readPyprojectToml(projectRoot);
105
+ const js = {};
106
+ if (pkg) {
107
+ for (const k of Object.keys(pkg.dependencies || {})) js[k] = pkg.dependencies[k];
108
+ for (const k of Object.keys(pkg.devDependencies || {})) js[k] = pkg.devDependencies[k];
109
+ }
110
+ const py = flattenPyprojectDeps(pyproject);
111
+ return { js, py, hasPackageJson: pkg !== null, hasPyproject: pyproject !== null };
112
+ }
113
+
114
+ export function depMatches(deps, pattern) {
115
+ if (pattern.endsWith('/*')) {
116
+ const prefix = pattern.slice(0, -1);
117
+ return Object.keys(deps).some((name) => name.startsWith(prefix));
118
+ }
119
+ return Object.prototype.hasOwnProperty.call(deps, pattern);
120
+ }
@@ -191,6 +191,7 @@ export async function cleanGitignore(projectRoot) {
191
191
  '.claude/workflow-meta.json',
192
192
  '.claude/worktrees/',
193
193
  '.claude/.stop-hook-active',
194
+ '.claude/cache/',
194
195
  ]);
195
196
 
196
197
  const filtered = lines.filter((line) => !REMOVE_LINES.has(line.trim()));
@@ -55,6 +55,7 @@ export async function updateGitignore(projectDir) {
55
55
  '.claude-backup-*/',
56
56
  '.claude/learnings/',
57
57
  '.claude/.stop-hook-active',
58
+ '.claude/cache/',
58
59
  ];
59
60
  const header = '# Worclaude (generated workflow files)';
60
61
 
@@ -0,0 +1,213 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+
4
+ export const SCHEMA_VERSION = 1;
5
+
6
+ export const STATE_NAMES = [
7
+ 'INIT',
8
+ 'SCAN',
9
+ 'CONFIRM_HIGH',
10
+ 'CONFIRM_MEDIUM',
11
+ 'INTERVIEW_STORY',
12
+ 'INTERVIEW_ARCH',
13
+ 'INTERVIEW_FEATURES',
14
+ 'INTERVIEW_WORKFLOW',
15
+ 'INTERVIEW_CONVENTIONS',
16
+ 'INTERVIEW_VERIFICATION',
17
+ 'WRITE',
18
+ 'DONE',
19
+ ];
20
+
21
+ export const QUESTION_IDS = [
22
+ 'story.audience',
23
+ 'story.problem',
24
+ 'story.analogs',
25
+ 'arch.classification',
26
+ 'arch.modules',
27
+ 'arch.entities',
28
+ 'arch.external_apis',
29
+ 'arch.stack_rationale',
30
+ 'features.core',
31
+ 'features.nice_to_have',
32
+ 'features.non_goals',
33
+ 'workflow.new_dev_steps',
34
+ 'workflow.env_values',
35
+ 'conventions.patterns',
36
+ 'conventions.errors',
37
+ 'conventions.logging',
38
+ 'conventions.api_format',
39
+ 'conventions.naming',
40
+ 'conventions.rules',
41
+ 'verification.manual',
42
+ 'verification.staging',
43
+ 'verification.required_checks',
44
+ ];
45
+
46
+ // Unchecked-namespace routing: rejected high-confidence fields re-asked in the
47
+ // state matching the field's natural section. Source of truth for
48
+ // interviewAnswers key validation.
49
+ export const UNCHECKED_ROUTING = {
50
+ readme: 'story',
51
+ specDocs: 'story',
52
+ packageManager: 'arch',
53
+ language: 'arch',
54
+ frameworks: 'arch',
55
+ orm: 'arch',
56
+ monorepo: 'arch',
57
+ deployment: 'arch',
58
+ externalApis: 'arch',
59
+ scripts: 'workflow',
60
+ envVariables: 'workflow',
61
+ linting: 'workflow',
62
+ ci: 'workflow',
63
+ testing: 'verification',
64
+ };
65
+
66
+ export function getStateFilePath(projectRoot) {
67
+ return path.join(projectRoot, '.claude', 'cache', 'setup-state.json');
68
+ }
69
+
70
+ function isUncheckedKey(key) {
71
+ const match = /^(story|arch|workflow|verification)\.unchecked\.([A-Za-z][A-Za-z0-9]*)$/.exec(key);
72
+ if (!match) return false;
73
+ const [, prefix, field] = match;
74
+ return UNCHECKED_ROUTING[field] === prefix;
75
+ }
76
+
77
+ function isKnownQuestionId(key) {
78
+ return QUESTION_IDS.includes(key);
79
+ }
80
+
81
+ function isKnownStateName(name) {
82
+ return STATE_NAMES.includes(name);
83
+ }
84
+
85
+ function validateState(state) {
86
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
87
+ throw new Error('state must be an object');
88
+ }
89
+ if (state.schemaVersion !== SCHEMA_VERSION) {
90
+ throw new Error(
91
+ `Unsupported schemaVersion: ${state.schemaVersion} (expected ${SCHEMA_VERSION})`
92
+ );
93
+ }
94
+ if (typeof state.startedAt !== 'string' || !state.startedAt) {
95
+ throw new Error('state.startedAt must be a non-empty ISO string');
96
+ }
97
+ if (typeof state.updatedAt !== 'string' || !state.updatedAt) {
98
+ throw new Error('state.updatedAt must be a non-empty ISO string');
99
+ }
100
+ if (!isKnownStateName(state.currentState)) {
101
+ throw new Error(`Unknown currentState: ${state.currentState}`);
102
+ }
103
+ if (typeof state.detectionReportPath !== 'string') {
104
+ throw new Error('state.detectionReportPath must be a string');
105
+ }
106
+ if (!Array.isArray(state.highConfirmedAccepted)) {
107
+ throw new Error('state.highConfirmedAccepted must be an array');
108
+ }
109
+ if (!Array.isArray(state.highConfirmedRejected)) {
110
+ throw new Error('state.highConfirmedRejected must be an array');
111
+ }
112
+ if (!state.mediumResolved || typeof state.mediumResolved !== 'object') {
113
+ throw new Error('state.mediumResolved must be an object');
114
+ }
115
+ for (const [k, v] of Object.entries(state.mediumResolved)) {
116
+ if (typeof v !== 'string') {
117
+ throw new Error(`state.mediumResolved.${k} must be a string`);
118
+ }
119
+ }
120
+ if (!state.interviewAnswers || typeof state.interviewAnswers !== 'object') {
121
+ throw new Error('state.interviewAnswers must be an object');
122
+ }
123
+ for (const [k, v] of Object.entries(state.interviewAnswers)) {
124
+ if (typeof v !== 'string') {
125
+ throw new Error(`state.interviewAnswers.${k} must be a string`);
126
+ }
127
+ if (!isKnownQuestionId(k) && !isUncheckedKey(k)) {
128
+ throw new Error(
129
+ `state.interviewAnswers has unknown key: ${k} (must be in QuestionId enumeration or a routed <state>.unchecked.<field> key)`
130
+ );
131
+ }
132
+ }
133
+ if (state.writeResults !== undefined) {
134
+ if (!state.writeResults || typeof state.writeResults !== 'object') {
135
+ throw new Error('state.writeResults must be an object when present');
136
+ }
137
+ for (const [k, v] of Object.entries(state.writeResults)) {
138
+ if (typeof v !== 'string') {
139
+ throw new Error(`state.writeResults.${k} must be a string`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ export async function loadSetupState(projectRoot) {
146
+ const filePath = getStateFilePath(projectRoot);
147
+ if (!(await fs.pathExists(filePath))) {
148
+ return null;
149
+ }
150
+ const raw = await fs.readFile(filePath, 'utf-8');
151
+ let parsed;
152
+ try {
153
+ parsed = JSON.parse(raw);
154
+ } catch (err) {
155
+ throw new Error(`Corrupt setup-state.json: ${err.message}`);
156
+ }
157
+ validateState(parsed);
158
+ return parsed;
159
+ }
160
+
161
+ export async function saveSetupState(projectRoot, state) {
162
+ const filePath = getStateFilePath(projectRoot);
163
+ await fs.ensureDir(path.dirname(filePath));
164
+
165
+ const merged = {
166
+ ...state,
167
+ startedAt: (await readExistingStartedAt(filePath)) || state.startedAt,
168
+ updatedAt: new Date().toISOString(),
169
+ };
170
+
171
+ validateState(merged);
172
+ await fs.writeFile(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
173
+ return filePath;
174
+ }
175
+
176
+ // Preserves startedAt across re-saves so the timeline reflects when setup
177
+ // actually began, not when the most recent mutation happened. Returns null
178
+ // for fresh saves. Corrupt JSON is treated as "no preserved value"; other
179
+ // I/O errors propagate so we never silently lose timeline data.
180
+ async function readExistingStartedAt(filePath) {
181
+ let raw;
182
+ try {
183
+ raw = await fs.readFile(filePath, 'utf-8');
184
+ } catch (err) {
185
+ if (err.code === 'ENOENT') return null;
186
+ throw err;
187
+ }
188
+ try {
189
+ const existing = JSON.parse(raw);
190
+ return typeof existing.startedAt === 'string' && existing.startedAt ? existing.startedAt : null;
191
+ } catch (err) {
192
+ if (err instanceof SyntaxError) return null;
193
+ throw err;
194
+ }
195
+ }
196
+
197
+ export async function clearSetupState(projectRoot) {
198
+ const filePath = getStateFilePath(projectRoot);
199
+ await fs.remove(filePath);
200
+ }
201
+
202
+ export function isSetupStateStale(state, staleHours = 24) {
203
+ if (!state || typeof state.updatedAt !== 'string') {
204
+ throw new Error('isSetupStateStale requires a state with updatedAt');
205
+ }
206
+ const updated = Date.parse(state.updatedAt);
207
+ if (Number.isNaN(updated)) {
208
+ throw new Error(`Invalid updatedAt: ${state.updatedAt}`);
209
+ }
210
+ const ageMs = Date.now() - updated;
211
+ const staleMs = staleHours * 60 * 60 * 1000;
212
+ return ageMs >= staleMs;
213
+ }