@cloverleaf/reference-impl 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.mjs +182 -4
- package/dist/scope-check.mjs +133 -0
- package/lib/cli.ts +190 -4
- package/lib/scope-check.ts +155 -0
- package/package.json +1 -1
- package/prompts/implementer.md +3 -0
- package/prompts/plan.md +8 -0
- package/skills/cloverleaf-run-plan/SKILL.md +32 -0
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
|
|
|
153
153
|
|
|
154
154
|
## Package layout
|
|
155
155
|
|
|
156
|
-
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`. The CLI exposes `write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>` as the safe write path for baselines; it enforces the `baselines_pending` guard and uses `buildBaselinePath` internally. `lib/walker-config.ts` exports `loadWalkerConfig()` — reads `~/.config/cloverleaf/walker.json` (XDG-aware) for the user-level `max_concurrent` override; exposed via `cloverleaf-cli walker-default-concurrency [--explain]`. `lib/release-preflight.ts` exports `runPreflightChecks(repoRoot)` — runs six blocking checks and two warnings, returning `{ checks, version, tag, notes }`; exposed via `cloverleaf-cli release-preflight <repoRoot> [--json]`. `lib/dag-overlap.ts` exports `computeOverlapEdges(tasks)` and `getFirstSharedFile(taskA, taskB)` — infers serialization edges from `scope.files_touched` and is used internally by `savePlan` to augment `task_dag.edges` before writing.
|
|
156
|
+
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping). `lib/ui-review-state.ts` exports `readUiReviewState`, `writeUiReviewState`, and `uiReviewStatePath` — the baseline-approval sidecar API for `.cloverleaf/runs/{taskId}/ui-review/state.json`. The CLI exposes `write-baseline <repoRoot> <taskId> <browser> <slug> <viewport> <sourceFile>` as the safe write path for baselines; it enforces the `baselines_pending` guard and uses `buildBaselinePath` internally. `lib/walker-config.ts` exports `loadWalkerConfig()` — reads `~/.config/cloverleaf/walker.json` (XDG-aware) for the user-level `max_concurrent` override; exposed via `cloverleaf-cli walker-default-concurrency [--explain]`. `lib/release-preflight.ts` exports `runPreflightChecks(repoRoot)` — runs six blocking checks and two warnings, returning `{ checks, version, tag, notes }`; exposed via `cloverleaf-cli release-preflight <repoRoot> [--json]`. `lib/dag-overlap.ts` exports `computeOverlapEdges(tasks)` and `getFirstSharedFile(taskA, taskB)` — infers serialization edges from `scope.files_touched` and is used internally by `savePlan` to augment `task_dag.edges` before writing. `lib/scope-check.ts` exports `classifyFiles(taskDoc, modifiedFiles, siblingScopes)` — classifies branch-modified files into `own`, `contested`, and `extension` buckets; exposed via `cloverleaf-cli check-scope <repoRoot> <taskId> --branch <branchName>` (prints JSON, exits 1 on missing branch/task doc) and `cloverleaf-cli extend-scope <repoRoot> <taskId> --add <file>... --reason <text>` (idempotently set-unions files into `scope.files_touched` and appends an audit entry to `.cloverleaf/runs/plan/<PLAN-ID>/audit.jsonl`).
|
|
157
157
|
- `skills/` — Claude Code skill markdown files.
|
|
158
158
|
- `prompts/` — Implementer/Reviewer subagent system prompts.
|
|
159
159
|
- `examples/toy-repo/` — standalone demo repo.
|
package/dist/cli.mjs
CHANGED
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
* walk-state-read <repoRoot> <planId>
|
|
36
36
|
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
37
37
|
* walker-default-concurrency [--explain]
|
|
38
|
+
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
39
|
+
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
38
40
|
*/
|
|
39
|
-
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
40
|
-
import { dirname } from 'node:path';
|
|
41
|
+
import { readFileSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
41
43
|
import { execSync } from 'node:child_process';
|
|
42
|
-
import { loadTask } from './task.mjs';
|
|
44
|
+
import { loadTask, saveTask } from './task.mjs';
|
|
43
45
|
import { advanceStatus } from './task.mjs';
|
|
44
46
|
import { emitGateDecision } from './events.mjs';
|
|
45
47
|
import { writeFeedback, latestFeedback } from './feedback.mjs';
|
|
@@ -60,6 +62,7 @@ import { buildBaselinePath } from './visual-diff.mjs';
|
|
|
60
62
|
import { computeReadyTasks, detectCycle } from './dag-walker.mjs';
|
|
61
63
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.mjs';
|
|
62
64
|
import { loadWalkerConfig } from './walker-config.mjs';
|
|
65
|
+
import { classifyFiles } from './scope-check.mjs';
|
|
63
66
|
function die(msg, code = 1) {
|
|
64
67
|
process.stderr.write(msg + '\n');
|
|
65
68
|
process.exit(code);
|
|
@@ -98,7 +101,9 @@ function usage(msg) {
|
|
|
98
101
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
99
102
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
100
103
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
101
|
-
' walker-default-concurrency [--explain]\n'
|
|
104
|
+
' walker-default-concurrency [--explain]\n' +
|
|
105
|
+
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
106
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n');
|
|
102
107
|
process.exit(2);
|
|
103
108
|
}
|
|
104
109
|
const [, , command, ...rest] = process.argv;
|
|
@@ -523,6 +528,179 @@ try {
|
|
|
523
528
|
}
|
|
524
529
|
process.exit(0);
|
|
525
530
|
}
|
|
531
|
+
case 'check-scope': {
|
|
532
|
+
// check-scope <repoRoot> <taskId> --branch <branchName>
|
|
533
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
534
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
535
|
+
const [repoRoot, taskId] = positional;
|
|
536
|
+
if (!repoRoot || !taskId)
|
|
537
|
+
usage('check-scope requires <repoRoot> <taskId> --branch <branchName>');
|
|
538
|
+
const branchFlag = flags.find((f) => f === '--branch');
|
|
539
|
+
const branchIdx = rest.indexOf('--branch');
|
|
540
|
+
const branchName = branchFlag !== undefined && branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
541
|
+
if (!branchName) {
|
|
542
|
+
process.stderr.write('check-scope: missing --branch <branchName>\n');
|
|
543
|
+
process.exit(2);
|
|
544
|
+
}
|
|
545
|
+
// 1. Read task doc from feature branch via git show
|
|
546
|
+
let taskDoc;
|
|
547
|
+
try {
|
|
548
|
+
const taskPath = `.cloverleaf/tasks/${taskId}.json`;
|
|
549
|
+
const raw = execSync(`git show ${branchName}:${taskPath}`, {
|
|
550
|
+
cwd: repoRoot,
|
|
551
|
+
encoding: 'utf-8',
|
|
552
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
553
|
+
});
|
|
554
|
+
taskDoc = JSON.parse(raw);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
try {
|
|
558
|
+
// Fall back to checking if the branch exists at all
|
|
559
|
+
execSync(`git rev-parse --verify ${branchName}`, {
|
|
560
|
+
cwd: repoRoot,
|
|
561
|
+
encoding: 'utf-8',
|
|
562
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
563
|
+
});
|
|
564
|
+
// Branch exists but no task doc
|
|
565
|
+
process.stderr.write(`check-scope: task doc not found for ${taskId} on branch ${branchName}\n`);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
process.stderr.write(`check-scope: branch ${branchName} not found\n`);
|
|
569
|
+
}
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
// 2. Read sibling scopes from main
|
|
573
|
+
const siblingScopes = [];
|
|
574
|
+
try {
|
|
575
|
+
// List all task files visible on main
|
|
576
|
+
const lsOut = execSync(`git ls-tree -r --name-only main -- .cloverleaf/tasks/`, {
|
|
577
|
+
cwd: repoRoot,
|
|
578
|
+
encoding: 'utf-8',
|
|
579
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
580
|
+
});
|
|
581
|
+
const taskFiles = lsOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
582
|
+
for (const f of taskFiles) {
|
|
583
|
+
const tid = f.replace(/^\.cloverleaf\/tasks\//, '').replace(/\.json$/, '');
|
|
584
|
+
if (tid === taskId)
|
|
585
|
+
continue; // skip self
|
|
586
|
+
try {
|
|
587
|
+
const raw = execSync(`git show main:${f}`, {
|
|
588
|
+
cwd: repoRoot,
|
|
589
|
+
encoding: 'utf-8',
|
|
590
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
591
|
+
});
|
|
592
|
+
const sibling = JSON.parse(raw);
|
|
593
|
+
// Skip siblings that are already merged — they no longer contest scope
|
|
594
|
+
if (sibling['status'] === 'merged')
|
|
595
|
+
continue;
|
|
596
|
+
const scope = sibling['scope'];
|
|
597
|
+
const files = Array.isArray(scope?.['files_touched'])
|
|
598
|
+
? scope['files_touched'].filter((x) => typeof x === 'string')
|
|
599
|
+
: [];
|
|
600
|
+
if (files.length > 0) {
|
|
601
|
+
siblingScopes.push({ taskId: tid, files });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Skip task files that can't be read
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// main may not exist yet; treat as no siblings
|
|
611
|
+
}
|
|
612
|
+
// 3. Get modified files via git diff main..<branch>
|
|
613
|
+
let modifiedFiles = [];
|
|
614
|
+
try {
|
|
615
|
+
const diffOut = execSync(`git diff --name-only main..${branchName}`, {
|
|
616
|
+
cwd: repoRoot,
|
|
617
|
+
encoding: 'utf-8',
|
|
618
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
619
|
+
});
|
|
620
|
+
modifiedFiles = diffOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// No diff is fine (empty branch)
|
|
624
|
+
}
|
|
625
|
+
// 4. Classify and output
|
|
626
|
+
const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
|
|
627
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
628
|
+
process.exit(0);
|
|
629
|
+
}
|
|
630
|
+
case 'extend-scope': {
|
|
631
|
+
// extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
632
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
633
|
+
const [repoRoot, taskId] = positional;
|
|
634
|
+
if (!repoRoot || !taskId)
|
|
635
|
+
usage('extend-scope requires <repoRoot> <taskId> --add <file>... --reason <text>');
|
|
636
|
+
// Parse --reason (takes everything after --reason up to another flag)
|
|
637
|
+
const reasonIdx = rest.indexOf('--reason');
|
|
638
|
+
if (reasonIdx < 0 || !rest[reasonIdx + 1]) {
|
|
639
|
+
process.stderr.write('extend-scope: missing --reason <text>\n');
|
|
640
|
+
process.exit(2);
|
|
641
|
+
}
|
|
642
|
+
// Collect reason: everything after --reason that doesn't start with --
|
|
643
|
+
const reasonParts = [];
|
|
644
|
+
for (let i = reasonIdx + 1; i < rest.length; i++) {
|
|
645
|
+
if (rest[i].startsWith('--'))
|
|
646
|
+
break;
|
|
647
|
+
reasonParts.push(rest[i]);
|
|
648
|
+
}
|
|
649
|
+
const reason = reasonParts.join(' ');
|
|
650
|
+
if (!reason) {
|
|
651
|
+
process.stderr.write('extend-scope: --reason requires a non-empty value\n');
|
|
652
|
+
process.exit(2);
|
|
653
|
+
}
|
|
654
|
+
// Parse --add <file>... (all values after --add that don't start with --)
|
|
655
|
+
const addIdx = rest.indexOf('--add');
|
|
656
|
+
if (addIdx < 0) {
|
|
657
|
+
process.stderr.write('extend-scope: missing --add <file>...\n');
|
|
658
|
+
process.exit(2);
|
|
659
|
+
}
|
|
660
|
+
const addFiles = [];
|
|
661
|
+
for (let i = addIdx + 1; i < rest.length; i++) {
|
|
662
|
+
if (rest[i].startsWith('--'))
|
|
663
|
+
break;
|
|
664
|
+
addFiles.push(rest[i]);
|
|
665
|
+
}
|
|
666
|
+
if (addFiles.length === 0) {
|
|
667
|
+
process.stderr.write('extend-scope: --add requires at least one file\n');
|
|
668
|
+
process.exit(2);
|
|
669
|
+
}
|
|
670
|
+
// Load task doc
|
|
671
|
+
const task = loadTask(repoRoot, taskId);
|
|
672
|
+
// Get current scope.files_touched
|
|
673
|
+
const scope = (task['scope'] ?? {});
|
|
674
|
+
const currentFiles = Array.isArray(scope['files_touched'])
|
|
675
|
+
? scope['files_touched'].filter((f) => typeof f === 'string')
|
|
676
|
+
: [];
|
|
677
|
+
// Set-union and sort/dedup
|
|
678
|
+
const merged = Array.from(new Set([...currentFiles, ...addFiles])).sort();
|
|
679
|
+
// Check if idempotent (no change needed)
|
|
680
|
+
const newlyAdded = addFiles.filter((f) => !currentFiles.includes(f));
|
|
681
|
+
// Always update and save (even if idempotent, shape is canonical)
|
|
682
|
+
const updatedTask = {
|
|
683
|
+
...task,
|
|
684
|
+
scope: { ...scope, files_touched: merged },
|
|
685
|
+
};
|
|
686
|
+
saveTask(repoRoot, updatedTask);
|
|
687
|
+
// Append audit entry
|
|
688
|
+
// Find plan ID from task.parent or task.context
|
|
689
|
+
const parent = task['parent'];
|
|
690
|
+
const planId = parent?.id ?? taskId; // fallback to taskId if no parent
|
|
691
|
+
const auditDir = join(repoRoot, '.cloverleaf', 'runs', 'plan', planId);
|
|
692
|
+
mkdirSync(auditDir, { recursive: true });
|
|
693
|
+
const auditPath = join(auditDir, 'audit.jsonl');
|
|
694
|
+
const auditEntry = {
|
|
695
|
+
ts: new Date().toISOString(),
|
|
696
|
+
kind: 'extend-scope',
|
|
697
|
+
task_id: taskId,
|
|
698
|
+
files: newlyAdded,
|
|
699
|
+
reason,
|
|
700
|
+
};
|
|
701
|
+
appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
|
|
702
|
+
process.exit(0);
|
|
703
|
+
}
|
|
526
704
|
default:
|
|
527
705
|
usage(`Unknown command: ${command}`);
|
|
528
706
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-check.ts — reference-impl v0.7.0
|
|
3
|
+
*
|
|
4
|
+
* Classifies a task's modified files into three buckets:
|
|
5
|
+
* - own: file is declared in the task's own scope.files_touched
|
|
6
|
+
* - contested: file is declared by a sibling task in the same Plan
|
|
7
|
+
* - extension: file was touched but not declared by anyone
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. Normalize all input paths (taskDoc.scope.files_touched, modifiedFiles,
|
|
11
|
+
* every siblingScopes[*].files) using the same rules as dag-overlap.ts:
|
|
12
|
+
* trim, replace backslashes, strip leading `./`, strip trailing `/`.
|
|
13
|
+
* Drop empties after normalization.
|
|
14
|
+
* 2. Build:
|
|
15
|
+
* - ownSet = normalized set of taskDoc.scope?.files_touched ?? []
|
|
16
|
+
* - siblingMap = Map<normalizedFile, sortedListOfTaskIds> from all siblings
|
|
17
|
+
* (a single file may be claimed by multiple siblings — kept sorted lex ascending)
|
|
18
|
+
* 3. Filter out any modified file whose normalized path starts with `.cloverleaf/`
|
|
19
|
+
* (these are state-machine transitions, not work).
|
|
20
|
+
* 4. For each remaining normalized modified file f (in input order, deduped):
|
|
21
|
+
* - If f ∈ ownSet → push to own
|
|
22
|
+
* - Else if siblingMap.has(f) → push { file: f, owner: siblingMap.get(f)![0] } to contested
|
|
23
|
+
* (lex-smallest sibling id wins)
|
|
24
|
+
* - Else → push to extension
|
|
25
|
+
* 5. Sort all three output bucket arrays lexicographically by file path.
|
|
26
|
+
* 6. Return { contested, own, extension }.
|
|
27
|
+
*
|
|
28
|
+
* Exact-path comparison only — no glob expansion (mirrors dag-overlap.ts v0.7.0 non-goals).
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a raw file path from `scope.files_touched` or modifiedFiles.
|
|
32
|
+
*
|
|
33
|
+
* Rules (applied in order):
|
|
34
|
+
* - Trim whitespace
|
|
35
|
+
* - Replace backslashes with forward-slashes
|
|
36
|
+
* - Strip any number of leading `./` sequences
|
|
37
|
+
* - Strip trailing `/` (preserving a non-empty result)
|
|
38
|
+
* - Return the resulting string (may be empty for degenerate inputs; caller filters)
|
|
39
|
+
*/
|
|
40
|
+
function normalizePath(p) {
|
|
41
|
+
let s = p.trim().replace(/\\/g, '/');
|
|
42
|
+
// Strip leading `./` repeatedly
|
|
43
|
+
while (s.startsWith('./')) {
|
|
44
|
+
s = s.slice(2);
|
|
45
|
+
}
|
|
46
|
+
// Strip trailing `/`
|
|
47
|
+
while (s.endsWith('/') && s.length > 1) {
|
|
48
|
+
s = s.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Normalize an array of paths and drop empty strings after normalization.
|
|
54
|
+
*/
|
|
55
|
+
function normalizePaths(paths) {
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const p of paths) {
|
|
58
|
+
if (typeof p !== 'string')
|
|
59
|
+
continue;
|
|
60
|
+
const n = normalizePath(p);
|
|
61
|
+
if (n.length > 0)
|
|
62
|
+
result.push(n);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Classify a task's modified files into own, contested, and extension buckets.
|
|
68
|
+
*
|
|
69
|
+
* @param taskDoc - The task document whose scope declares "own" files.
|
|
70
|
+
* @param modifiedFiles - The list of files actually modified (e.g., from git diff).
|
|
71
|
+
* @param siblingScopes - Other tasks in the same Plan with their declared files.
|
|
72
|
+
* @returns - { contested, own, extension } with arrays sorted by file path.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyFiles(taskDoc, modifiedFiles, siblingScopes) {
|
|
75
|
+
// 1. Normalize own files from taskDoc.scope.files_touched
|
|
76
|
+
const scope = taskDoc['scope'];
|
|
77
|
+
const rawOwn = Array.isArray(scope?.['files_touched'])
|
|
78
|
+
? scope['files_touched'].filter((f) => typeof f === 'string')
|
|
79
|
+
: [];
|
|
80
|
+
const ownSet = new Set(normalizePaths(rawOwn));
|
|
81
|
+
// 2. Build siblingMap: normalizedFile → sorted list of taskIds
|
|
82
|
+
const siblingMap = new Map();
|
|
83
|
+
for (const sibling of siblingScopes) {
|
|
84
|
+
const normalized = normalizePaths(sibling.files);
|
|
85
|
+
for (const f of normalized) {
|
|
86
|
+
const existing = siblingMap.get(f);
|
|
87
|
+
if (existing) {
|
|
88
|
+
existing.push(sibling.taskId);
|
|
89
|
+
existing.sort();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
siblingMap.set(f, [sibling.taskId]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 3. Normalize modifiedFiles, filter .cloverleaf/ prefix, deduplicate
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const filteredModified = [];
|
|
99
|
+
for (const raw of modifiedFiles) {
|
|
100
|
+
if (typeof raw !== 'string')
|
|
101
|
+
continue;
|
|
102
|
+
const n = normalizePath(raw);
|
|
103
|
+
if (n.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
if (n.startsWith('.cloverleaf/'))
|
|
106
|
+
continue;
|
|
107
|
+
if (seen.has(n))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(n);
|
|
110
|
+
filteredModified.push(n);
|
|
111
|
+
}
|
|
112
|
+
// 4. Classify each file into the appropriate bucket
|
|
113
|
+
const own = [];
|
|
114
|
+
const contested = [];
|
|
115
|
+
const extension = [];
|
|
116
|
+
for (const f of filteredModified) {
|
|
117
|
+
if (ownSet.has(f)) {
|
|
118
|
+
own.push(f);
|
|
119
|
+
}
|
|
120
|
+
else if (siblingMap.has(f)) {
|
|
121
|
+
const owners = siblingMap.get(f);
|
|
122
|
+
contested.push({ file: f, owner: owners[0] }); // lex-smallest wins
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
extension.push(f);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 5. Sort all buckets lexicographically by file path
|
|
129
|
+
own.sort();
|
|
130
|
+
contested.sort((a, b) => a.file.localeCompare(b.file));
|
|
131
|
+
extension.sort();
|
|
132
|
+
return { contested, own, extension };
|
|
133
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -35,12 +35,14 @@
|
|
|
35
35
|
* walk-state-read <repoRoot> <planId>
|
|
36
36
|
* walk-state-write <repoRoot> <walkStateJsonPath>
|
|
37
37
|
* walker-default-concurrency [--explain]
|
|
38
|
+
* check-scope <repoRoot> <taskId> --branch <branchName>
|
|
39
|
+
* extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
38
40
|
*/
|
|
39
41
|
|
|
40
|
-
import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
41
|
-
import { dirname } from 'node:path';
|
|
42
|
+
import { readFileSync, mkdirSync, copyFileSync, appendFileSync, existsSync } from 'node:fs';
|
|
43
|
+
import { dirname, join } from 'node:path';
|
|
42
44
|
import { execSync } from 'node:child_process';
|
|
43
|
-
import { loadTask } from './task.js';
|
|
45
|
+
import { loadTask, saveTask } from './task.js';
|
|
44
46
|
import { advanceStatus } from './task.js';
|
|
45
47
|
import { emitGateDecision } from './events.js';
|
|
46
48
|
import { writeFeedback, latestFeedback } from './feedback.js';
|
|
@@ -62,6 +64,8 @@ import { buildBaselinePath } from './visual-diff.js';
|
|
|
62
64
|
import { computeReadyTasks, detectCycle } from './dag-walker.js';
|
|
63
65
|
import { readWalkState, writeWalkState, walkStatePath } from './walk-state.js';
|
|
64
66
|
import { loadWalkerConfig } from './walker-config.js';
|
|
67
|
+
import { classifyFiles } from './scope-check.js';
|
|
68
|
+
import type { SiblingScope } from './scope-check.js';
|
|
65
69
|
|
|
66
70
|
function die(msg: string, code = 1): never {
|
|
67
71
|
process.stderr.write(msg + '\n');
|
|
@@ -102,7 +106,9 @@ function usage(msg?: string): never {
|
|
|
102
106
|
' dag-detect-cycle <repoRoot> <planId>\n' +
|
|
103
107
|
' walk-state-read <repoRoot> <planId>\n' +
|
|
104
108
|
' walk-state-write <repoRoot> <walkStateJsonPath>\n' +
|
|
105
|
-
' walker-default-concurrency [--explain]\n'
|
|
109
|
+
' walker-default-concurrency [--explain]\n' +
|
|
110
|
+
' check-scope <repoRoot> <taskId> --branch <branchName>\n' +
|
|
111
|
+
' extend-scope <repoRoot> <taskId> --add <file>... --reason <text>\n'
|
|
106
112
|
);
|
|
107
113
|
process.exit(2);
|
|
108
114
|
}
|
|
@@ -537,6 +543,186 @@ try {
|
|
|
537
543
|
process.exit(0);
|
|
538
544
|
}
|
|
539
545
|
|
|
546
|
+
case 'check-scope': {
|
|
547
|
+
// check-scope <repoRoot> <taskId> --branch <branchName>
|
|
548
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
549
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
550
|
+
const [repoRoot, taskId] = positional;
|
|
551
|
+
if (!repoRoot || !taskId) usage('check-scope requires <repoRoot> <taskId> --branch <branchName>');
|
|
552
|
+
|
|
553
|
+
const branchFlag = flags.find((f) => f === '--branch');
|
|
554
|
+
const branchIdx = rest.indexOf('--branch');
|
|
555
|
+
const branchName = branchFlag !== undefined && branchIdx >= 0 ? rest[branchIdx + 1] : undefined;
|
|
556
|
+
if (!branchName) {
|
|
557
|
+
process.stderr.write('check-scope: missing --branch <branchName>\n');
|
|
558
|
+
process.exit(2);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 1. Read task doc from feature branch via git show
|
|
562
|
+
let taskDoc: ReturnType<typeof loadTask>;
|
|
563
|
+
try {
|
|
564
|
+
const taskPath = `.cloverleaf/tasks/${taskId}.json`;
|
|
565
|
+
const raw = execSync(`git show ${branchName}:${taskPath}`, {
|
|
566
|
+
cwd: repoRoot,
|
|
567
|
+
encoding: 'utf-8',
|
|
568
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
569
|
+
});
|
|
570
|
+
taskDoc = JSON.parse(raw) as ReturnType<typeof loadTask>;
|
|
571
|
+
} catch {
|
|
572
|
+
try {
|
|
573
|
+
// Fall back to checking if the branch exists at all
|
|
574
|
+
execSync(`git rev-parse --verify ${branchName}`, {
|
|
575
|
+
cwd: repoRoot,
|
|
576
|
+
encoding: 'utf-8',
|
|
577
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
578
|
+
});
|
|
579
|
+
// Branch exists but no task doc
|
|
580
|
+
process.stderr.write(`check-scope: task doc not found for ${taskId} on branch ${branchName}\n`);
|
|
581
|
+
} catch {
|
|
582
|
+
process.stderr.write(`check-scope: branch ${branchName} not found\n`);
|
|
583
|
+
}
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 2. Read sibling scopes from main
|
|
588
|
+
const siblingScopes: SiblingScope[] = [];
|
|
589
|
+
try {
|
|
590
|
+
// List all task files visible on main
|
|
591
|
+
const lsOut = execSync(`git ls-tree -r --name-only main -- .cloverleaf/tasks/`, {
|
|
592
|
+
cwd: repoRoot,
|
|
593
|
+
encoding: 'utf-8',
|
|
594
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
595
|
+
});
|
|
596
|
+
const taskFiles = lsOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
597
|
+
for (const f of taskFiles) {
|
|
598
|
+
const tid = f.replace(/^\.cloverleaf\/tasks\//, '').replace(/\.json$/, '');
|
|
599
|
+
if (tid === taskId) continue; // skip self
|
|
600
|
+
try {
|
|
601
|
+
const raw = execSync(`git show main:${f}`, {
|
|
602
|
+
cwd: repoRoot,
|
|
603
|
+
encoding: 'utf-8',
|
|
604
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
605
|
+
});
|
|
606
|
+
const sibling = JSON.parse(raw) as Record<string, unknown>;
|
|
607
|
+
// Skip siblings that are already merged — they no longer contest scope
|
|
608
|
+
if (sibling['status'] === 'merged') continue;
|
|
609
|
+
const scope = sibling['scope'] as Record<string, unknown> | undefined;
|
|
610
|
+
const files = Array.isArray(scope?.['files_touched'])
|
|
611
|
+
? (scope!['files_touched'] as unknown[]).filter((x): x is string => typeof x === 'string')
|
|
612
|
+
: [];
|
|
613
|
+
if (files.length > 0) {
|
|
614
|
+
siblingScopes.push({ taskId: tid, files });
|
|
615
|
+
}
|
|
616
|
+
} catch {
|
|
617
|
+
// Skip task files that can't be read
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
} catch {
|
|
621
|
+
// main may not exist yet; treat as no siblings
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// 3. Get modified files via git diff main..<branch>
|
|
625
|
+
let modifiedFiles: string[] = [];
|
|
626
|
+
try {
|
|
627
|
+
const diffOut = execSync(`git diff --name-only main..${branchName}`, {
|
|
628
|
+
cwd: repoRoot,
|
|
629
|
+
encoding: 'utf-8',
|
|
630
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
631
|
+
});
|
|
632
|
+
modifiedFiles = diffOut.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
633
|
+
} catch {
|
|
634
|
+
// No diff is fine (empty branch)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// 4. Classify and output
|
|
638
|
+
const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
|
|
639
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
640
|
+
process.exit(0);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
case 'extend-scope': {
|
|
644
|
+
// extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
|
|
645
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
646
|
+
const [repoRoot, taskId] = positional;
|
|
647
|
+
if (!repoRoot || !taskId) usage('extend-scope requires <repoRoot> <taskId> --add <file>... --reason <text>');
|
|
648
|
+
|
|
649
|
+
// Parse --reason (takes everything after --reason up to another flag)
|
|
650
|
+
const reasonIdx = rest.indexOf('--reason');
|
|
651
|
+
if (reasonIdx < 0 || !rest[reasonIdx + 1]) {
|
|
652
|
+
process.stderr.write('extend-scope: missing --reason <text>\n');
|
|
653
|
+
process.exit(2);
|
|
654
|
+
}
|
|
655
|
+
// Collect reason: everything after --reason that doesn't start with --
|
|
656
|
+
const reasonParts: string[] = [];
|
|
657
|
+
for (let i = reasonIdx + 1; i < rest.length; i++) {
|
|
658
|
+
if (rest[i].startsWith('--')) break;
|
|
659
|
+
reasonParts.push(rest[i]);
|
|
660
|
+
}
|
|
661
|
+
const reason = reasonParts.join(' ');
|
|
662
|
+
if (!reason) {
|
|
663
|
+
process.stderr.write('extend-scope: --reason requires a non-empty value\n');
|
|
664
|
+
process.exit(2);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Parse --add <file>... (all values after --add that don't start with --)
|
|
668
|
+
const addIdx = rest.indexOf('--add');
|
|
669
|
+
if (addIdx < 0) {
|
|
670
|
+
process.stderr.write('extend-scope: missing --add <file>...\n');
|
|
671
|
+
process.exit(2);
|
|
672
|
+
}
|
|
673
|
+
const addFiles: string[] = [];
|
|
674
|
+
for (let i = addIdx + 1; i < rest.length; i++) {
|
|
675
|
+
if (rest[i].startsWith('--')) break;
|
|
676
|
+
addFiles.push(rest[i]);
|
|
677
|
+
}
|
|
678
|
+
if (addFiles.length === 0) {
|
|
679
|
+
process.stderr.write('extend-scope: --add requires at least one file\n');
|
|
680
|
+
process.exit(2);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Load task doc
|
|
684
|
+
const task = loadTask(repoRoot, taskId);
|
|
685
|
+
|
|
686
|
+
// Get current scope.files_touched
|
|
687
|
+
const scope = (task['scope'] ?? {}) as Record<string, unknown>;
|
|
688
|
+
const currentFiles = Array.isArray(scope['files_touched'])
|
|
689
|
+
? (scope['files_touched'] as unknown[]).filter((f): f is string => typeof f === 'string')
|
|
690
|
+
: [];
|
|
691
|
+
|
|
692
|
+
// Set-union and sort/dedup
|
|
693
|
+
const merged = Array.from(new Set([...currentFiles, ...addFiles])).sort();
|
|
694
|
+
|
|
695
|
+
// Check if idempotent (no change needed)
|
|
696
|
+
const newlyAdded = addFiles.filter((f) => !currentFiles.includes(f));
|
|
697
|
+
|
|
698
|
+
// Always update and save (even if idempotent, shape is canonical)
|
|
699
|
+
const updatedTask = {
|
|
700
|
+
...task,
|
|
701
|
+
scope: { ...scope, files_touched: merged },
|
|
702
|
+
};
|
|
703
|
+
saveTask(repoRoot, updatedTask);
|
|
704
|
+
|
|
705
|
+
// Append audit entry
|
|
706
|
+
// Find plan ID from task.parent or task.context
|
|
707
|
+
const parent = task['parent'] as { project?: string; id?: string } | undefined;
|
|
708
|
+
const planId = parent?.id ?? taskId; // fallback to taskId if no parent
|
|
709
|
+
|
|
710
|
+
const auditDir = join(repoRoot, '.cloverleaf', 'runs', 'plan', planId);
|
|
711
|
+
mkdirSync(auditDir, { recursive: true });
|
|
712
|
+
const auditPath = join(auditDir, 'audit.jsonl');
|
|
713
|
+
|
|
714
|
+
const auditEntry = {
|
|
715
|
+
ts: new Date().toISOString(),
|
|
716
|
+
kind: 'extend-scope',
|
|
717
|
+
task_id: taskId,
|
|
718
|
+
files: newlyAdded,
|
|
719
|
+
reason,
|
|
720
|
+
};
|
|
721
|
+
appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
|
|
722
|
+
|
|
723
|
+
process.exit(0);
|
|
724
|
+
}
|
|
725
|
+
|
|
540
726
|
default:
|
|
541
727
|
usage(`Unknown command: ${command}`);
|
|
542
728
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-check.ts — reference-impl v0.7.0
|
|
3
|
+
*
|
|
4
|
+
* Classifies a task's modified files into three buckets:
|
|
5
|
+
* - own: file is declared in the task's own scope.files_touched
|
|
6
|
+
* - contested: file is declared by a sibling task in the same Plan
|
|
7
|
+
* - extension: file was touched but not declared by anyone
|
|
8
|
+
*
|
|
9
|
+
* Algorithm:
|
|
10
|
+
* 1. Normalize all input paths (taskDoc.scope.files_touched, modifiedFiles,
|
|
11
|
+
* every siblingScopes[*].files) using the same rules as dag-overlap.ts:
|
|
12
|
+
* trim, replace backslashes, strip leading `./`, strip trailing `/`.
|
|
13
|
+
* Drop empties after normalization.
|
|
14
|
+
* 2. Build:
|
|
15
|
+
* - ownSet = normalized set of taskDoc.scope?.files_touched ?? []
|
|
16
|
+
* - siblingMap = Map<normalizedFile, sortedListOfTaskIds> from all siblings
|
|
17
|
+
* (a single file may be claimed by multiple siblings — kept sorted lex ascending)
|
|
18
|
+
* 3. Filter out any modified file whose normalized path starts with `.cloverleaf/`
|
|
19
|
+
* (these are state-machine transitions, not work).
|
|
20
|
+
* 4. For each remaining normalized modified file f (in input order, deduped):
|
|
21
|
+
* - If f ∈ ownSet → push to own
|
|
22
|
+
* - Else if siblingMap.has(f) → push { file: f, owner: siblingMap.get(f)![0] } to contested
|
|
23
|
+
* (lex-smallest sibling id wins)
|
|
24
|
+
* - Else → push to extension
|
|
25
|
+
* 5. Sort all three output bucket arrays lexicographically by file path.
|
|
26
|
+
* 6. Return { contested, own, extension }.
|
|
27
|
+
*
|
|
28
|
+
* Exact-path comparison only — no glob expansion (mirrors dag-overlap.ts v0.7.0 non-goals).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { TaskDoc } from './task.js';
|
|
32
|
+
|
|
33
|
+
export interface ContestedEntry {
|
|
34
|
+
file: string;
|
|
35
|
+
owner: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClassifyResult {
|
|
39
|
+
contested: ContestedEntry[];
|
|
40
|
+
own: string[];
|
|
41
|
+
extension: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SiblingScope {
|
|
45
|
+
taskId: string;
|
|
46
|
+
files: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a raw file path from `scope.files_touched` or modifiedFiles.
|
|
51
|
+
*
|
|
52
|
+
* Rules (applied in order):
|
|
53
|
+
* - Trim whitespace
|
|
54
|
+
* - Replace backslashes with forward-slashes
|
|
55
|
+
* - Strip any number of leading `./` sequences
|
|
56
|
+
* - Strip trailing `/` (preserving a non-empty result)
|
|
57
|
+
* - Return the resulting string (may be empty for degenerate inputs; caller filters)
|
|
58
|
+
*/
|
|
59
|
+
function normalizePath(p: string): string {
|
|
60
|
+
let s = p.trim().replace(/\\/g, '/');
|
|
61
|
+
// Strip leading `./` repeatedly
|
|
62
|
+
while (s.startsWith('./')) {
|
|
63
|
+
s = s.slice(2);
|
|
64
|
+
}
|
|
65
|
+
// Strip trailing `/`
|
|
66
|
+
while (s.endsWith('/') && s.length > 1) {
|
|
67
|
+
s = s.slice(0, -1);
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize an array of paths and drop empty strings after normalization.
|
|
74
|
+
*/
|
|
75
|
+
function normalizePaths(paths: string[]): string[] {
|
|
76
|
+
const result: string[] = [];
|
|
77
|
+
for (const p of paths) {
|
|
78
|
+
if (typeof p !== 'string') continue;
|
|
79
|
+
const n = normalizePath(p);
|
|
80
|
+
if (n.length > 0) result.push(n);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Classify a task's modified files into own, contested, and extension buckets.
|
|
87
|
+
*
|
|
88
|
+
* @param taskDoc - The task document whose scope declares "own" files.
|
|
89
|
+
* @param modifiedFiles - The list of files actually modified (e.g., from git diff).
|
|
90
|
+
* @param siblingScopes - Other tasks in the same Plan with their declared files.
|
|
91
|
+
* @returns - { contested, own, extension } with arrays sorted by file path.
|
|
92
|
+
*/
|
|
93
|
+
export function classifyFiles(
|
|
94
|
+
taskDoc: TaskDoc,
|
|
95
|
+
modifiedFiles: string[],
|
|
96
|
+
siblingScopes: SiblingScope[]
|
|
97
|
+
): ClassifyResult {
|
|
98
|
+
// 1. Normalize own files from taskDoc.scope.files_touched
|
|
99
|
+
const scope = taskDoc['scope'] as Record<string, unknown> | undefined;
|
|
100
|
+
const rawOwn = Array.isArray(scope?.['files_touched'])
|
|
101
|
+
? (scope!['files_touched'] as unknown[]).filter((f): f is string => typeof f === 'string')
|
|
102
|
+
: [];
|
|
103
|
+
const ownSet = new Set(normalizePaths(rawOwn));
|
|
104
|
+
|
|
105
|
+
// 2. Build siblingMap: normalizedFile → sorted list of taskIds
|
|
106
|
+
const siblingMap = new Map<string, string[]>();
|
|
107
|
+
for (const sibling of siblingScopes) {
|
|
108
|
+
const normalized = normalizePaths(sibling.files);
|
|
109
|
+
for (const f of normalized) {
|
|
110
|
+
const existing = siblingMap.get(f);
|
|
111
|
+
if (existing) {
|
|
112
|
+
existing.push(sibling.taskId);
|
|
113
|
+
existing.sort();
|
|
114
|
+
} else {
|
|
115
|
+
siblingMap.set(f, [sibling.taskId]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Normalize modifiedFiles, filter .cloverleaf/ prefix, deduplicate
|
|
121
|
+
const seen = new Set<string>();
|
|
122
|
+
const filteredModified: string[] = [];
|
|
123
|
+
for (const raw of modifiedFiles) {
|
|
124
|
+
if (typeof raw !== 'string') continue;
|
|
125
|
+
const n = normalizePath(raw);
|
|
126
|
+
if (n.length === 0) continue;
|
|
127
|
+
if (n.startsWith('.cloverleaf/')) continue;
|
|
128
|
+
if (seen.has(n)) continue;
|
|
129
|
+
seen.add(n);
|
|
130
|
+
filteredModified.push(n);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4. Classify each file into the appropriate bucket
|
|
134
|
+
const own: string[] = [];
|
|
135
|
+
const contested: ContestedEntry[] = [];
|
|
136
|
+
const extension: string[] = [];
|
|
137
|
+
|
|
138
|
+
for (const f of filteredModified) {
|
|
139
|
+
if (ownSet.has(f)) {
|
|
140
|
+
own.push(f);
|
|
141
|
+
} else if (siblingMap.has(f)) {
|
|
142
|
+
const owners = siblingMap.get(f)!;
|
|
143
|
+
contested.push({ file: f, owner: owners[0] }); // lex-smallest wins
|
|
144
|
+
} else {
|
|
145
|
+
extension.push(f);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 5. Sort all buckets lexicographically by file path
|
|
150
|
+
own.sort();
|
|
151
|
+
contested.sort((a, b) => a.file.localeCompare(b.file));
|
|
152
|
+
extension.sort();
|
|
153
|
+
|
|
154
|
+
return { contested, own, extension };
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/implementer.md
CHANGED
|
@@ -20,6 +20,9 @@ You are the Cloverleaf Implementer agent. Your job: take a Task and produce work
|
|
|
20
20
|
Run this as the first executable step before anything else. Session B sessions may inherit an arbitrary `cwd` from the walker harness; this anchors you at the repo root.
|
|
21
21
|
|
|
22
22
|
1. Read the task's `title`, `acceptance_criteria`, `definition_of_done`, and `context`. Read any referenced files.
|
|
23
|
+
|
|
24
|
+
**Scope nudge.** Your declared scope is `task.scope.files_touched`. You may freely modify any file listed there. If you discover during implementation that you need to touch a file outside that list, you may do so only if no sibling task in the same Plan declares that file — the walker auto-extends your scope on merge. If a file you need is already declared by a sibling task, that is a contested modification: stop, surface the conflict to the human, and do not merge. The walker enforces this at merge time and will refuse contested merges; auto-resolution is never attempted.
|
|
25
|
+
|
|
23
26
|
2. If `feedback` is present, re-read each finding; plan how to address them.
|
|
24
27
|
3. Create a new branch named `cloverleaf/<task.id>` from `base_branch` using `git checkout -b cloverleaf/<task.id>`.
|
|
25
28
|
4. Implement the code + tests needed to satisfy every acceptance criterion.
|
package/prompts/plan.md
CHANGED
|
@@ -89,3 +89,11 @@ Dependencies:
|
|
|
89
89
|
**How to compute the diff inline:**
|
|
90
90
|
- **Logical** edges: edges you explicitly declared in `task_dag.edges` for sequencing reasons.
|
|
91
91
|
- **Inferred from file overlap**: edges the system will auto-compute by comparing `scope.files_touched` across tasks. For the summary, compute these yourself: for each pair of tasks (A, B) where A has lower task-number, if `scope.files_touched` sets overlap, list an inferred edge A → B with the overlapping paths. Do NOT add these to `task_dag.edges` — list them only in this summary so the human can sanity-check before approval.
|
|
92
|
+
|
|
93
|
+
**Partial-scope warning:** Count the tasks where `scope.files_touched` is absent or an empty array. If that count is greater than zero, append the following warning line at the bottom of the gate-pending summary (substituting the actual task IDs), then add an advisory note that the walker will silent-skip scope enforcement on those tasks:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
⚠ Tasks without scope.files_touched: <CLV-XX, CLV-YY>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Omit this warning line entirely when every task has a non-empty `scope.files_touched`.
|
|
@@ -169,6 +169,27 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
169
169
|
|
|
170
170
|
e. **Drain the final-gate queue serially and merge on main.** Session B does NOT invoke `/cloverleaf-merge` — it stops at automated-gates (fast lane) or final-gate (full pipeline) and reports. The walker performs the merge on main in the primary repo. For each queued task:
|
|
171
171
|
|
|
172
|
+
**Scope check (BEFORE the y/N prompt).** Run `cloverleaf-cli check-scope` and capture its output and exit code:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
SCOPE_JSON=$(cloverleaf-cli check-scope <repo_root> <TASK-ID> --branch cloverleaf/<TASK-ID> 2>/dev/null)
|
|
176
|
+
SCOPE_EXIT=$?
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- **If exit 0**: parse the JSON. If `contested[]` is non-empty:
|
|
180
|
+
- **Skip the y/N prompt entirely.**
|
|
181
|
+
- Mark the task `state: "escalated"` in walk-state via `walk-state-write`.
|
|
182
|
+
- Surface the following message to the driver (the literal contested-escalation message template):
|
|
183
|
+
```
|
|
184
|
+
⚠️ <TASK-ID> escalated: scope-contested merge. Files contested with sibling task(s): <list of file:owned_by pairs>. Walker will not auto-resolve. Inspect the Plan decomposition (CLV-86 vs CLV-87 etc.) and either (a) re-decompose so each contested file is owned by exactly one task, or (b) merge the colliding tasks into one. Re-run /cloverleaf-run-plan <PLAN-ID> after fixing.
|
|
185
|
+
```
|
|
186
|
+
- Continue to the next queued task. Do NOT proceed to the merge prompt.
|
|
187
|
+
- **If exit non-zero (tooling failure)**: print a warning and fall through to the existing merge flow without scope enforcement (**warn-and-proceed**):
|
|
188
|
+
```
|
|
189
|
+
⚠️ check-scope failed (exit N) — falling through to existing merge flow without scope enforcement.
|
|
190
|
+
```
|
|
191
|
+
- **If exit 0 and `contested[]` is empty**: proceed normally to the summary + y/N prompt below. Retain the parsed `extension[]` array from the JSON for use in the post-merge auto-extend block.
|
|
192
|
+
|
|
172
193
|
1. Print a full summary to the driver:
|
|
173
194
|
```
|
|
174
195
|
⏵ <TASK-ID> ready to merge (<fast lane | full pipeline>)
|
|
@@ -221,6 +242,16 @@ description: Autonomous DAG walker for Cloverleaf Plans. Given a PLAN-ID in stat
|
|
|
221
242
|
# The updated walk-state sets tasks["<TASK-ID>"].state = "merged"
|
|
222
243
|
# and tasks["<TASK-ID>"].merge_commit = "$MERGE_COMMIT"
|
|
223
244
|
```
|
|
245
|
+
**Post-merge auto-extend.** If the `extension[]` array captured from the earlier `cloverleaf-cli check-scope` call was non-empty, invoke `extend-scope` and commit the amended task doc:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
cloverleaf-cli extend-scope <repo_root> <TASK-ID> --add <file1> --add <file2> ... --reason "auto-extended post-merge: files touched but undeclared"
|
|
249
|
+
git -C <repo_root> add .cloverleaf/tasks/<TASK-ID>.json .cloverleaf/runs/plan/<PLAN-ID>/audit.jsonl
|
|
250
|
+
git -C <repo_root> commit -m "cloverleaf: <TASK-ID> scope auto-extended (+N files)"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
where N is the count of files in `extension[]`.
|
|
254
|
+
|
|
224
255
|
Send `y` (informational) back to Session B so it can record the outcome and exit, but the walker is the authoritative merge-performer.
|
|
225
256
|
**Tear down the worktree**: `git -C <repo_root> worktree remove --force <worktree_path>`. Delete the branch is optional (keep if useful for post-hoc inspection).
|
|
226
257
|
4. If it matches `^n(o)?$|^N(O)?$` → mark task `state: "awaiting_final_gate"`. Send `n` to Session B. **Keep the worktree** so the user can re-run `/cloverleaf-merge <TASK-ID>` manually pointing at it, or fix and retry. Continue with the next queued task.
|
|
@@ -310,3 +341,4 @@ The concrete policy JSON is the same one used during the CLV-16..CLV-20 dogfood
|
|
|
310
341
|
- Escalations surface immediately; they do NOT queue behind the final-gate drain.
|
|
311
342
|
- Final-gate drain is serial across tasks — one prompt, one decision.
|
|
312
343
|
- The walker exits after the loop reports the final status; it does not auto-retry escalated tasks.
|
|
344
|
+
- Scope-contested merges are escalated, never auto-resolved.
|