@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 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.0",
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",
@@ -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.