deepflow 0.1.110 → 0.1.112
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 +68 -84
- package/bin/install.test.js +1 -0
- package/bin/ratchet.js +185 -8
- package/bin/ratchet.test.js +4 -4
- package/bin/wave-runner.js +11 -3
- package/bin/worktree-deps.js +28 -22
- package/hooks/ac-coverage.js +66 -60
- package/hooks/df-check-update.js +1 -0
- package/hooks/df-command-usage.js +1 -0
- package/hooks/df-dashboard-push.js +1 -0
- package/hooks/df-execution-history.js +1 -0
- package/hooks/df-explore-protocol.js +1 -21
- package/hooks/df-harness-score.js +389 -0
- package/hooks/df-invariant-check.js +154 -1
- package/hooks/df-quota-logger.js +1 -0
- package/hooks/df-snapshot-guard.js +1 -0
- package/hooks/df-spec-lint.js +6 -0
- package/hooks/df-spec-lint.test.js +57 -1
- package/hooks/df-statusline.js +1 -0
- package/hooks/df-subagent-registry.js +1 -0
- package/hooks/df-tool-usage-spike.js +2 -0
- package/hooks/df-tool-usage.js +1 -0
- package/hooks/df-worktree-guard.js +157 -0
- package/hooks/lib/installer-utils.js +114 -0
- package/package.json +1 -1
- package/src/commands/df/debate.md +20 -4
- package/src/commands/df/discover.md +1 -1
- package/src/commands/df/execute.md +125 -33
- package/src/commands/df/plan.md +12 -7
- package/src/commands/df/spec.md +1 -0
- package/src/commands/df/verify.md +2 -0
- package/src/skills/repo-inspect/SKILL.md +205 -0
- package/templates/config-template.yaml +3 -0
- package/templates/spec-template.md +17 -0
package/bin/install.js
CHANGED
|
@@ -9,6 +9,18 @@ const path = require('path');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const readline = require('readline');
|
|
11
11
|
const { execFileSync } = require('child_process');
|
|
12
|
+
const { scanHookEvents, removeDeepflowHooks } = require('../hooks/lib/installer-utils');
|
|
13
|
+
|
|
14
|
+
function atomicWriteFileSync(targetPath, data) {
|
|
15
|
+
const tmpPath = targetPath + '.tmp';
|
|
16
|
+
try {
|
|
17
|
+
fs.writeFileSync(tmpPath, data);
|
|
18
|
+
fs.renameSync(tmpPath, targetPath);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
// Legacy subcommand: `deepflow auto` is now `/df:auto` inside Claude Code
|
|
14
26
|
if (process.argv[2] === 'auto') {
|
|
@@ -35,23 +47,6 @@ const GLOBAL_DIR = path.join(os.homedir(), '.claude');
|
|
|
35
47
|
const PROJECT_DIR = path.join(process.cwd(), '.claude');
|
|
36
48
|
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
37
49
|
|
|
38
|
-
/**
|
|
39
|
-
* Atomically write data to targetPath using a write-to-temp + rename pattern.
|
|
40
|
-
* If the write fails, the original file is left untouched and the temp file is
|
|
41
|
-
* cleaned up. Temp file is created in the same directory as the target so the
|
|
42
|
-
* rename is within the same filesystem (atomic on POSIX).
|
|
43
|
-
*/
|
|
44
|
-
function atomicWriteFileSync(targetPath, data) {
|
|
45
|
-
const tmpPath = targetPath + '.tmp';
|
|
46
|
-
try {
|
|
47
|
-
fs.writeFileSync(tmpPath, data);
|
|
48
|
-
fs.renameSync(tmpPath, targetPath);
|
|
49
|
-
} catch (err) {
|
|
50
|
-
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
51
|
-
throw err;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
50
|
function updateGlobalPackage() {
|
|
56
51
|
const currentVersion = require(path.join(PACKAGE_DIR, 'package.json')).version;
|
|
57
52
|
try {
|
|
@@ -297,77 +292,46 @@ function copyDir(src, dest) {
|
|
|
297
292
|
}
|
|
298
293
|
}
|
|
299
294
|
|
|
300
|
-
// Valid hook events (settings.hooks keys + special "statusLine")
|
|
301
|
-
const VALID_HOOK_EVENTS = new Set([
|
|
302
|
-
'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'SubagentStop', 'statusLine'
|
|
303
|
-
]);
|
|
304
|
-
|
|
305
295
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
296
|
+
* Returns true if settings.json contains any hook commands that reference a
|
|
297
|
+
* dashboard-owned hook file (identified by @hook-owner: dashboard in its source).
|
|
298
|
+
* Checks both settings.hooks.* entries and settings.statusLine.
|
|
308
299
|
*/
|
|
309
|
-
function
|
|
310
|
-
const
|
|
311
|
-
|
|
300
|
+
function detectDashboardHooks(settings, claudeDir) {
|
|
301
|
+
const hooksInstallDir = path.join(claudeDir, 'hooks');
|
|
302
|
+
if (!fs.existsSync(hooksInstallDir)) return false;
|
|
312
303
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const match = firstLines.match(/\/\/\s*@hook-event:\s*(.+)/);
|
|
321
|
-
|
|
322
|
-
if (!match) {
|
|
323
|
-
untagged.push(file);
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const events = match[1].split(',').map(e => e.trim()).filter(Boolean);
|
|
328
|
-
let hasValidEvent = false;
|
|
329
|
-
|
|
330
|
-
for (const event of events) {
|
|
331
|
-
if (!VALID_HOOK_EVENTS.has(event)) {
|
|
332
|
-
console.log(` ${c.yellow}!${c.reset} Warning: unknown event "${event}" in ${file} — skipped`);
|
|
333
|
-
continue;
|
|
304
|
+
// Collect all command strings currently wired in settings
|
|
305
|
+
const wiredCommands = [];
|
|
306
|
+
if (settings.hooks) {
|
|
307
|
+
for (const entries of Object.values(settings.hooks)) {
|
|
308
|
+
for (const hook of entries) {
|
|
309
|
+
const cmd = hook.hooks?.[0]?.command;
|
|
310
|
+
if (cmd) wiredCommands.push(cmd);
|
|
334
311
|
}
|
|
335
|
-
hasValidEvent = true;
|
|
336
|
-
if (!eventMap.has(event)) eventMap.set(event, []);
|
|
337
|
-
eventMap.get(event).push(file);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (!hasValidEvent) {
|
|
341
|
-
untagged.push(file);
|
|
342
312
|
}
|
|
343
313
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Remove all deepflow hook entries (commands containing /hooks/df-) from settings.
|
|
350
|
-
* Preserves non-deepflow hooks.
|
|
351
|
-
*/
|
|
352
|
-
function removeDeepflowHooks(settings) {
|
|
353
|
-
const isDeepflow = (hook) => {
|
|
354
|
-
const cmd = hook.hooks?.[0]?.command || '';
|
|
355
|
-
return cmd.includes('/hooks/df-');
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
// Clean settings.hooks.*
|
|
359
|
-
if (settings.hooks) {
|
|
360
|
-
for (const event of Object.keys(settings.hooks)) {
|
|
361
|
-
settings.hooks[event] = settings.hooks[event].filter(h => !isDeepflow(h));
|
|
362
|
-
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
363
|
-
}
|
|
364
|
-
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
314
|
+
if (settings.statusLine?.command) {
|
|
315
|
+
wiredCommands.push(settings.statusLine.command);
|
|
365
316
|
}
|
|
366
317
|
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
318
|
+
// For each wired command, resolve the hook filename and check its @hook-owner
|
|
319
|
+
for (const cmd of wiredCommands) {
|
|
320
|
+
// Commands look like: node "/path/to/.claude/hooks/df-foo.js"
|
|
321
|
+
const match = cmd.match(/["']?([^"'\s]+\.js)["']?\s*$/);
|
|
322
|
+
if (!match) continue;
|
|
323
|
+
const hookPath = match[1];
|
|
324
|
+
if (!fs.existsSync(hookPath)) continue;
|
|
325
|
+
try {
|
|
326
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
327
|
+
const firstLines = content.split('\n').slice(0, 10).join('\n');
|
|
328
|
+
const ownerMatch = firstLines.match(/\/\/\s*@hook-owner:\s*(.+)/);
|
|
329
|
+
if (ownerMatch && ownerMatch[1].trim() === 'dashboard') return true;
|
|
330
|
+
} catch (_) {
|
|
331
|
+
// Skip unreadable files
|
|
332
|
+
}
|
|
370
333
|
}
|
|
334
|
+
return false;
|
|
371
335
|
}
|
|
372
336
|
|
|
373
337
|
async function configureHooks(claudeDir) {
|
|
@@ -393,8 +357,8 @@ async function configureHooks(claudeDir) {
|
|
|
393
357
|
configurePermissions(settings);
|
|
394
358
|
log('Agent permissions configured');
|
|
395
359
|
|
|
396
|
-
// Scan hook files for @hook-event tags
|
|
397
|
-
const { eventMap, untagged } = scanHookEvents(hooksSourceDir);
|
|
360
|
+
// Scan hook files for @hook-event tags — only deepflow-owned hooks
|
|
361
|
+
const { eventMap, untagged } = scanHookEvents(hooksSourceDir, 'deepflow');
|
|
398
362
|
|
|
399
363
|
// Remember if there was a pre-existing non-deepflow statusLine
|
|
400
364
|
const hadExternalStatusLine = settings.statusLine &&
|
|
@@ -403,6 +367,15 @@ async function configureHooks(claudeDir) {
|
|
|
403
367
|
// Remove all existing deepflow hooks (orphan cleanup + idempotency)
|
|
404
368
|
removeDeepflowHooks(settings);
|
|
405
369
|
|
|
370
|
+
// Migration warning: detect dashboard-owned hooks already wired in settings.json
|
|
371
|
+
// (they were installed by an older deepflow version that didn't distinguish owners)
|
|
372
|
+
const hasDashboardHooks = detectDashboardHooks(settings, claudeDir);
|
|
373
|
+
if (hasDashboardHooks) {
|
|
374
|
+
console.log('');
|
|
375
|
+
console.log(` ${c.yellow}!${c.reset} Dashboard hooks detected — run \`npx deepflow-dashboard install\` to manage them separately.`);
|
|
376
|
+
console.log('');
|
|
377
|
+
}
|
|
378
|
+
|
|
406
379
|
// Wire hooks by event
|
|
407
380
|
if (!settings.hooks) settings.hooks = {};
|
|
408
381
|
|
|
@@ -636,12 +609,23 @@ async function uninstall() {
|
|
|
636
609
|
];
|
|
637
610
|
|
|
638
611
|
if (level === 'global') {
|
|
639
|
-
// Dynamically find
|
|
612
|
+
// Dynamically find deepflow-owned hook files to remove.
|
|
613
|
+
// Check @hook-owner tag from the installed file; skip dashboard-owned hooks.
|
|
640
614
|
const hooksDir = path.join(CLAUDE_DIR, 'hooks');
|
|
641
615
|
if (fs.existsSync(hooksDir)) {
|
|
642
616
|
for (const file of fs.readdirSync(hooksDir)) {
|
|
643
|
-
if (file.startsWith('df-')
|
|
644
|
-
|
|
617
|
+
if (!file.startsWith('df-') || !file.endsWith('.js') || file.endsWith('.test.js')) continue;
|
|
618
|
+
const filePath = path.join(hooksDir, file);
|
|
619
|
+
try {
|
|
620
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
621
|
+
const firstLines = content.split('\n').slice(0, 10).join('\n');
|
|
622
|
+
const ownerMatch = firstLines.match(/\/\/\s*@hook-owner:\s*(.+)/);
|
|
623
|
+
if (ownerMatch && ownerMatch[1].trim() === 'deepflow') {
|
|
624
|
+
toRemove.push(`hooks/${file}`);
|
|
625
|
+
}
|
|
626
|
+
// dashboard-owned hooks are intentionally left in place
|
|
627
|
+
} catch (_) {
|
|
628
|
+
// Skip unreadable files
|
|
645
629
|
}
|
|
646
630
|
}
|
|
647
631
|
}
|
package/bin/install.test.js
CHANGED
|
@@ -333,6 +333,7 @@ describe('Uninstaller — file removal and settings cleanup', () => {
|
|
|
333
333
|
'df-dashboard-push.js',
|
|
334
334
|
'df-execution-history.js',
|
|
335
335
|
'df-worktree-guard.js',
|
|
336
|
+
'df-harness-score.js',
|
|
336
337
|
]) {
|
|
337
338
|
fs.writeFileSync(path.join(hookDir, hook), '// hook');
|
|
338
339
|
}
|
package/bin/ratchet.js
CHANGED
|
@@ -273,10 +273,157 @@ function autoRevert(cwd) {
|
|
|
273
273
|
// Health-check stages in order
|
|
274
274
|
// ---------------------------------------------------------------------------
|
|
275
275
|
|
|
276
|
-
const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint'];
|
|
276
|
+
const STAGE_ORDER = ['build', 'test', 'typecheck', 'lint', 'contract'];
|
|
277
277
|
|
|
278
278
|
// Stages where failure is SALVAGEABLE (not FAIL)
|
|
279
|
-
const SALVAGEABLE_STAGES = new Set(['lint']);
|
|
279
|
+
const SALVAGEABLE_STAGES = new Set(['lint', 'contract']);
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// REQ-1 + REQ-8: Contract stage
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse `Produces: path::Symbol` entries from PLAN.md.
|
|
287
|
+
* Returns array of { file, symbol } entries.
|
|
288
|
+
*/
|
|
289
|
+
function parseProducesFromPlan(planPath) {
|
|
290
|
+
if (!fs.existsSync(planPath)) return [];
|
|
291
|
+
const text = fs.readFileSync(planPath, 'utf8');
|
|
292
|
+
const entries = [];
|
|
293
|
+
// Match: Produces: <path>::<Symbol> (tolerant to leading whitespace & markdown bullets)
|
|
294
|
+
const re = /Produces:\s*([^\s:`]+)::([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
295
|
+
let m;
|
|
296
|
+
while ((m = re.exec(text)) !== null) {
|
|
297
|
+
entries.push({ file: m[1].trim(), symbol: m[2].trim() });
|
|
298
|
+
}
|
|
299
|
+
return entries;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Verify that a symbol exists in a file.
|
|
304
|
+
* Tries LSP documentSymbols first (if available); falls back to regex grep.
|
|
305
|
+
* Returns true if found, false otherwise.
|
|
306
|
+
*/
|
|
307
|
+
async function verifySymbolExists(absFilePath, symbol, projectRoot) {
|
|
308
|
+
// LSP-first — try to use queryLsp from df-invariant-check if present
|
|
309
|
+
try {
|
|
310
|
+
const invariantPath = path.join(projectRoot, 'hooks', 'df-invariant-check.js');
|
|
311
|
+
if (fs.existsSync(invariantPath)) {
|
|
312
|
+
// eslint-disable-next-line global-require
|
|
313
|
+
const inv = require(invariantPath);
|
|
314
|
+
if (typeof inv.queryLsp === 'function' && typeof inv.detectLanguageServer === 'function') {
|
|
315
|
+
const detected = inv.detectLanguageServer(projectRoot, [absFilePath]);
|
|
316
|
+
if (detected && detected.binary) {
|
|
317
|
+
const fileUri = 'file://' + absFilePath;
|
|
318
|
+
const res = await inv.queryLsp(
|
|
319
|
+
detected.binary,
|
|
320
|
+
projectRoot,
|
|
321
|
+
fileUri,
|
|
322
|
+
'textDocument/documentSymbol',
|
|
323
|
+
{ textDocument: { uri: fileUri } }
|
|
324
|
+
);
|
|
325
|
+
if (res && res.ok && Array.isArray(res.result)) {
|
|
326
|
+
const names = flattenSymbolNames(res.result);
|
|
327
|
+
if (names.includes(symbol)) return true;
|
|
328
|
+
// LSP gave a definitive (empty/non-matching) result — do NOT fall
|
|
329
|
+
// back to regex when we have an authoritative answer. But if
|
|
330
|
+
// the list is empty, treat as inconclusive → fallback.
|
|
331
|
+
if (names.length > 0) return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} catch (_) {
|
|
337
|
+
// LSP unavailable — fall back
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Regex fallback: scan file for \bsymbol\b
|
|
341
|
+
try {
|
|
342
|
+
if (!fs.existsSync(absFilePath)) return false;
|
|
343
|
+
const src = fs.readFileSync(absFilePath, 'utf8');
|
|
344
|
+
const safe = escapeRegExp(symbol);
|
|
345
|
+
return new RegExp(`\\b${safe}\\b`).test(src);
|
|
346
|
+
} catch (_) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function flattenSymbolNames(symbols) {
|
|
352
|
+
const names = [];
|
|
353
|
+
const walk = (arr) => {
|
|
354
|
+
for (const s of arr || []) {
|
|
355
|
+
if (s && typeof s.name === 'string') names.push(s.name);
|
|
356
|
+
if (s && Array.isArray(s.children)) walk(s.children);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
walk(symbols);
|
|
360
|
+
return names;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Count AC-N references across snapshot test files.
|
|
365
|
+
*/
|
|
366
|
+
function countAcRefsInSnapshot(snapshotFiles) {
|
|
367
|
+
let count = 0;
|
|
368
|
+
const pattern = /\bAC-\d+\b/g;
|
|
369
|
+
for (const f of snapshotFiles) {
|
|
370
|
+
try {
|
|
371
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
372
|
+
const matches = src.match(pattern);
|
|
373
|
+
if (matches) count += matches.length;
|
|
374
|
+
} catch (_) {
|
|
375
|
+
// skip unreadable
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return count;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Run the contract stage.
|
|
383
|
+
* @returns {Promise<{ ok: boolean, salvageable?: boolean, log: string }>}
|
|
384
|
+
* - ok:true → PASS (continue / exit success)
|
|
385
|
+
* - ok:false, salvageable:true → SALVAGEABLE (exit 2, no revert)
|
|
386
|
+
* - ok:false → FAIL
|
|
387
|
+
* - If no Produces: entries at all, returns ok:true (no-op).
|
|
388
|
+
*/
|
|
389
|
+
async function runContractStage(repoRoot, cwd, snapshotFiles) {
|
|
390
|
+
// Locate PLAN.md — prefer worktree cwd, fall back to repo root
|
|
391
|
+
let planPath = path.join(cwd, 'PLAN.md');
|
|
392
|
+
if (!fs.existsSync(planPath)) planPath = path.join(repoRoot, 'PLAN.md');
|
|
393
|
+
|
|
394
|
+
const entries = parseProducesFromPlan(planPath);
|
|
395
|
+
if (entries.length === 0) {
|
|
396
|
+
return { ok: true, log: 'contract: no Produces: entries — skipped' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Verify each declared symbol exists
|
|
400
|
+
const missing = [];
|
|
401
|
+
for (const { file, symbol } of entries) {
|
|
402
|
+
const absPath = path.isAbsolute(file) ? file : path.join(cwd, file);
|
|
403
|
+
const found = await verifySymbolExists(absPath, symbol, repoRoot);
|
|
404
|
+
if (!found) missing.push(`${file}::${symbol}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (missing.length > 0) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
salvageable: true,
|
|
411
|
+
log: `contract: declared symbols not found: ${missing.join(', ')}`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Ratchet PASS + zero AC test references in snapshot → SALVAGEABLE
|
|
416
|
+
const acRefs = countAcRefsInSnapshot(snapshotFiles);
|
|
417
|
+
if (acRefs === 0) {
|
|
418
|
+
return {
|
|
419
|
+
ok: false,
|
|
420
|
+
salvageable: true,
|
|
421
|
+
log: 'contract: zero AC-N references found in ratchet snapshot test files',
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { ok: true, log: `contract: ${entries.length} symbols verified, ${acRefs} AC refs` };
|
|
426
|
+
}
|
|
280
427
|
|
|
281
428
|
// ---------------------------------------------------------------------------
|
|
282
429
|
// CLI argument parser
|
|
@@ -287,7 +434,7 @@ function escapeRegExp(value) {
|
|
|
287
434
|
}
|
|
288
435
|
|
|
289
436
|
function parseArgs(argv) {
|
|
290
|
-
const args = { task: null, worktree: null, snapshot: null };
|
|
437
|
+
const args = { task: null, worktree: null, snapshot: null, stage: null };
|
|
291
438
|
for (let i = 0; i < argv.length; i++) {
|
|
292
439
|
if (argv[i] === '--task' && argv[i + 1]) {
|
|
293
440
|
args.task = argv[++i];
|
|
@@ -295,6 +442,8 @@ function parseArgs(argv) {
|
|
|
295
442
|
args.worktree = argv[++i];
|
|
296
443
|
} else if (argv[i] === '--snapshot' && argv[i + 1]) {
|
|
297
444
|
args.snapshot = argv[++i];
|
|
445
|
+
} else if (argv[i] === '--stage' && argv[i + 1]) {
|
|
446
|
+
args.stage = argv[++i];
|
|
298
447
|
}
|
|
299
448
|
}
|
|
300
449
|
return args;
|
|
@@ -338,26 +487,51 @@ function updatePlanMd(repoRoot, taskId, cwd) {
|
|
|
338
487
|
// Main
|
|
339
488
|
// ---------------------------------------------------------------------------
|
|
340
489
|
|
|
341
|
-
function main() {
|
|
490
|
+
async function main() {
|
|
342
491
|
const cliArgs = parseArgs(process.argv.slice(2));
|
|
343
492
|
const cwd = cliArgs.worktree || process.cwd();
|
|
344
493
|
const repoRoot = mainRepoRoot(cwd);
|
|
345
494
|
|
|
346
495
|
const cfg = loadConfig(repoRoot);
|
|
347
496
|
const projectType = detectProjectType(repoRoot);
|
|
348
|
-
|
|
497
|
+
let snapshotFiles = loadSnapshotFiles(repoRoot, cwd);
|
|
349
498
|
const cmds = buildCommands(repoRoot, projectType, snapshotFiles, cfg);
|
|
350
499
|
// --snapshot flag overrides the snapshot-derived test command
|
|
351
500
|
if (cliArgs.snapshot && fs.existsSync(cliArgs.snapshot)) {
|
|
352
501
|
const snapFiles = fs.readFileSync(cliArgs.snapshot, 'utf8')
|
|
353
502
|
.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
|
354
503
|
.map(rel => path.isAbsolute(rel) ? rel : path.join(cwd, rel));
|
|
355
|
-
if (snapFiles.length > 0
|
|
356
|
-
|
|
504
|
+
if (snapFiles.length > 0) {
|
|
505
|
+
snapshotFiles = snapFiles;
|
|
506
|
+
if (projectType === 'node' && !cfg.test_command) {
|
|
507
|
+
cmds.test = ['node', '--test', ...snapFiles];
|
|
508
|
+
}
|
|
357
509
|
}
|
|
358
510
|
}
|
|
359
511
|
|
|
512
|
+
// --stage filter: run only the specified stage
|
|
513
|
+
const stageFilter = cliArgs.stage;
|
|
514
|
+
if (stageFilter && !STAGE_ORDER.includes(stageFilter)) {
|
|
515
|
+
process.stdout.write(JSON.stringify({ result: 'FAIL', stage: stageFilter, log: `unknown stage: ${stageFilter}` }) + '\n');
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
|
|
360
519
|
for (const stage of STAGE_ORDER) {
|
|
520
|
+
if (stageFilter && stage !== stageFilter) continue;
|
|
521
|
+
|
|
522
|
+
// Contract stage is implemented in-process (no external command)
|
|
523
|
+
if (stage === 'contract') {
|
|
524
|
+
const res = await runContractStage(repoRoot, cwd, snapshotFiles);
|
|
525
|
+
if (res.ok) continue;
|
|
526
|
+
if (res.salvageable) {
|
|
527
|
+
process.stdout.write(JSON.stringify({ result: 'SALVAGEABLE', stage, log: res.log }) + '\n');
|
|
528
|
+
process.exit(2);
|
|
529
|
+
}
|
|
530
|
+
autoRevert(cwd);
|
|
531
|
+
process.stdout.write(JSON.stringify({ result: 'FAIL', stage, log: res.log }) + '\n');
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
534
|
+
|
|
361
535
|
const cmd = cmds[stage];
|
|
362
536
|
if (!cmd) continue; // stage not applicable
|
|
363
537
|
|
|
@@ -394,4 +568,7 @@ function main() {
|
|
|
394
568
|
process.exit(0);
|
|
395
569
|
}
|
|
396
570
|
|
|
397
|
-
main()
|
|
571
|
+
main().catch((err) => {
|
|
572
|
+
process.stdout.write(JSON.stringify({ result: 'FAIL', stage: 'internal', log: String(err && err.stack || err) }) + '\n');
|
|
573
|
+
process.exit(1);
|
|
574
|
+
});
|
package/bin/ratchet.test.js
CHANGED
|
@@ -570,19 +570,19 @@ describe('buildCommands — unknown project type', () => {
|
|
|
570
570
|
// 7. Health check stage ordering — source assertions
|
|
571
571
|
// ---------------------------------------------------------------------------
|
|
572
572
|
|
|
573
|
-
describe('STAGE_ORDER — build, test, typecheck, lint', () => {
|
|
573
|
+
describe('STAGE_ORDER — build, test, typecheck, lint, contract', () => {
|
|
574
574
|
test('source defines stages in correct order', () => {
|
|
575
575
|
const match = RATCHET_SRC.match(/STAGE_ORDER\s*=\s*\[([^\]]+)\]/);
|
|
576
576
|
assert.ok(match, 'STAGE_ORDER constant should exist in source');
|
|
577
577
|
const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
|
|
578
|
-
assert.deepEqual(stages, ['build', 'test', 'typecheck', 'lint']);
|
|
578
|
+
assert.deepEqual(stages, ['build', 'test', 'typecheck', 'lint', 'contract']);
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
-
test('
|
|
581
|
+
test('lint and contract are SALVAGEABLE', () => {
|
|
582
582
|
const match = RATCHET_SRC.match(/SALVAGEABLE_STAGES\s*=\s*new Set\(\[([^\]]+)\]\)/);
|
|
583
583
|
assert.ok(match, 'SALVAGEABLE_STAGES constant should exist in source');
|
|
584
584
|
const stages = match[1].replace(/['"]/g, '').split(',').map(s => s.trim());
|
|
585
|
-
assert.deepEqual(stages, ['lint']);
|
|
585
|
+
assert.deepEqual(stages, ['lint', 'contract']);
|
|
586
586
|
});
|
|
587
587
|
});
|
|
588
588
|
|
package/bin/wave-runner.js
CHANGED
|
@@ -83,9 +83,11 @@ function parsePlan(text) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Match pending task header: - [ ] **T{N}**...
|
|
86
|
-
|
|
86
|
+
// Captures optional [TAG] as group 2 (e.g. INTEGRATION, SPIKE, OPTIMIZE)
|
|
87
|
+
const taskMatch = line.match(/^\s*-\s+\[\s+\]\s+\*\*T(\d+)\*\*(?:\s+\[([^\]]*)\])?[:\s]*(.*)/);
|
|
87
88
|
if (taskMatch) {
|
|
88
|
-
const
|
|
89
|
+
const rawTag = taskMatch[2] ? taskMatch[2].trim().toUpperCase() : null;
|
|
90
|
+
const rest = taskMatch[3].trim();
|
|
89
91
|
|
|
90
92
|
// Extract inline blocked-by (from " | Blocked by: T1, T2")
|
|
91
93
|
let inlineBlockedBy = [];
|
|
@@ -118,6 +120,7 @@ function parsePlan(text) {
|
|
|
118
120
|
files: null,
|
|
119
121
|
effort: inlineEffort,
|
|
120
122
|
spec: currentSpec,
|
|
123
|
+
tag: rawTag,
|
|
121
124
|
};
|
|
122
125
|
tasks.push(current);
|
|
123
126
|
continue;
|
|
@@ -275,13 +278,14 @@ function buildWaves(tasks, stuckIds) {
|
|
|
275
278
|
|
|
276
279
|
/**
|
|
277
280
|
* Format waves as a JSON array of task objects, each with a `wave` field.
|
|
278
|
-
* Fields: id, description, model, files, effort, blockedBy, spec, wave
|
|
281
|
+
* Fields: id, description, model, files, effort, blockedBy, spec, tag, isIntegration, isSpike, isOptimize, wave
|
|
279
282
|
*/
|
|
280
283
|
function formatWavesJson(waves) {
|
|
281
284
|
const result = [];
|
|
282
285
|
for (let i = 0; i < waves.length; i++) {
|
|
283
286
|
const waveNum = i + 1;
|
|
284
287
|
for (const t of waves[i]) {
|
|
288
|
+
const tag = t.tag || null;
|
|
285
289
|
result.push({
|
|
286
290
|
id: t.id,
|
|
287
291
|
description: t.description || null,
|
|
@@ -290,6 +294,10 @@ function formatWavesJson(waves) {
|
|
|
290
294
|
effort: t.effort || null,
|
|
291
295
|
blockedBy: t.blockedBy,
|
|
292
296
|
spec: t.spec || null,
|
|
297
|
+
tag: tag,
|
|
298
|
+
isIntegration: tag === 'INTEGRATION',
|
|
299
|
+
isSpike: tag === 'SPIKE',
|
|
300
|
+
isOptimize: tag === 'OPTIMIZE',
|
|
293
301
|
wave: waveNum,
|
|
294
302
|
});
|
|
295
303
|
}
|
package/bin/worktree-deps.js
CHANGED
|
@@ -41,38 +41,44 @@ function parseArgs() {
|
|
|
41
41
|
|
|
42
42
|
function findNodeModules(root) {
|
|
43
43
|
const results = [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
|
|
46
|
+
function addIfNM(relDir) {
|
|
47
|
+
if (seen.has(relDir)) return;
|
|
48
|
+
const abs = path.join(root, relDir, 'node_modules');
|
|
49
|
+
if (fs.existsSync(abs)) {
|
|
50
|
+
seen.add(relDir);
|
|
51
|
+
results.push(relDir === '.' ? 'node_modules' : path.join(relDir, 'node_modules'));
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// Root node_modules
|
|
56
|
+
addIfNM('.');
|
|
57
|
+
|
|
58
|
+
// Walk every top-level directory up to 2 levels deep looking for node_modules.
|
|
59
|
+
// This covers both flat layouts (go/frontend, web/admin) and monorepo layouts
|
|
60
|
+
// (packages/foo, apps/bar) without hardcoding directory names.
|
|
61
|
+
function walk(relDir, depth) {
|
|
62
|
+
if (depth > 2) return;
|
|
63
|
+
const abs = path.join(root, relDir);
|
|
58
64
|
let entries;
|
|
59
65
|
try {
|
|
60
|
-
entries = fs.readdirSync(
|
|
66
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
61
67
|
} catch (_) {
|
|
62
|
-
|
|
68
|
+
return;
|
|
63
69
|
}
|
|
64
|
-
|
|
65
70
|
for (const entry of entries) {
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
71
|
+
if (!entry.isDirectory()) continue;
|
|
72
|
+
if (entry.name.startsWith('.')) continue;
|
|
73
|
+
if (entry.name === 'node_modules') continue;
|
|
74
|
+
const childRel = relDir === '.' ? entry.name : path.join(relDir, entry.name);
|
|
75
|
+
addIfNM(childRel);
|
|
76
|
+
walk(childRel, depth + 1);
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
walk('.', 1);
|
|
81
|
+
|
|
76
82
|
return results;
|
|
77
83
|
}
|
|
78
84
|
|