@cloverleaf/reference-impl 0.6.7 → 0.7.1

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]`.
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,176 @@ 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
+ const scope = sibling['scope'];
594
+ const files = Array.isArray(scope?.['files_touched'])
595
+ ? scope['files_touched'].filter((x) => typeof x === 'string')
596
+ : [];
597
+ if (files.length > 0) {
598
+ siblingScopes.push({ taskId: tid, files });
599
+ }
600
+ }
601
+ catch {
602
+ // Skip task files that can't be read
603
+ }
604
+ }
605
+ }
606
+ catch {
607
+ // main may not exist yet; treat as no siblings
608
+ }
609
+ // 3. Get modified files via git diff main..<branch>
610
+ let modifiedFiles = [];
611
+ try {
612
+ const diffOut = execSync(`git diff --name-only main..${branchName}`, {
613
+ cwd: repoRoot,
614
+ encoding: 'utf-8',
615
+ stdio: ['pipe', 'pipe', 'pipe'],
616
+ });
617
+ modifiedFiles = diffOut.split('\n').map((l) => l.trim()).filter(Boolean);
618
+ }
619
+ catch {
620
+ // No diff is fine (empty branch)
621
+ }
622
+ // 4. Classify and output
623
+ const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
624
+ process.stdout.write(JSON.stringify(result) + '\n');
625
+ process.exit(0);
626
+ }
627
+ case 'extend-scope': {
628
+ // extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
629
+ const positional = rest.filter((a) => !a.startsWith('--'));
630
+ const [repoRoot, taskId] = positional;
631
+ if (!repoRoot || !taskId)
632
+ usage('extend-scope requires <repoRoot> <taskId> --add <file>... --reason <text>');
633
+ // Parse --reason (takes everything after --reason up to another flag)
634
+ const reasonIdx = rest.indexOf('--reason');
635
+ if (reasonIdx < 0 || !rest[reasonIdx + 1]) {
636
+ process.stderr.write('extend-scope: missing --reason <text>\n');
637
+ process.exit(2);
638
+ }
639
+ // Collect reason: everything after --reason that doesn't start with --
640
+ const reasonParts = [];
641
+ for (let i = reasonIdx + 1; i < rest.length; i++) {
642
+ if (rest[i].startsWith('--'))
643
+ break;
644
+ reasonParts.push(rest[i]);
645
+ }
646
+ const reason = reasonParts.join(' ');
647
+ if (!reason) {
648
+ process.stderr.write('extend-scope: --reason requires a non-empty value\n');
649
+ process.exit(2);
650
+ }
651
+ // Parse --add <file>... (all values after --add that don't start with --)
652
+ const addIdx = rest.indexOf('--add');
653
+ if (addIdx < 0) {
654
+ process.stderr.write('extend-scope: missing --add <file>...\n');
655
+ process.exit(2);
656
+ }
657
+ const addFiles = [];
658
+ for (let i = addIdx + 1; i < rest.length; i++) {
659
+ if (rest[i].startsWith('--'))
660
+ break;
661
+ addFiles.push(rest[i]);
662
+ }
663
+ if (addFiles.length === 0) {
664
+ process.stderr.write('extend-scope: --add requires at least one file\n');
665
+ process.exit(2);
666
+ }
667
+ // Load task doc
668
+ const task = loadTask(repoRoot, taskId);
669
+ // Get current scope.files_touched
670
+ const scope = (task['scope'] ?? {});
671
+ const currentFiles = Array.isArray(scope['files_touched'])
672
+ ? scope['files_touched'].filter((f) => typeof f === 'string')
673
+ : [];
674
+ // Set-union and sort/dedup
675
+ const merged = Array.from(new Set([...currentFiles, ...addFiles])).sort();
676
+ // Check if idempotent (no change needed)
677
+ const newlyAdded = addFiles.filter((f) => !currentFiles.includes(f));
678
+ // Always update and save (even if idempotent, shape is canonical)
679
+ const updatedTask = {
680
+ ...task,
681
+ scope: { ...scope, files_touched: merged },
682
+ };
683
+ saveTask(repoRoot, updatedTask);
684
+ // Append audit entry
685
+ // Find plan ID from task.parent or task.context
686
+ const parent = task['parent'];
687
+ const planId = parent?.id ?? taskId; // fallback to taskId if no parent
688
+ const auditDir = join(repoRoot, '.cloverleaf', 'runs', 'plan', planId);
689
+ mkdirSync(auditDir, { recursive: true });
690
+ const auditPath = join(auditDir, 'audit.jsonl');
691
+ const auditEntry = {
692
+ ts: new Date().toISOString(),
693
+ kind: 'extend-scope',
694
+ task_id: taskId,
695
+ files: newlyAdded,
696
+ reason,
697
+ };
698
+ appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
699
+ process.exit(0);
700
+ }
526
701
  default:
527
702
  usage(`Unknown command: ${command}`);
528
703
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * dag-overlap.ts — reference-impl v0.7.0
3
+ *
4
+ * Infers TaskDag serialization edges from shared `scope.files_touched` paths.
5
+ * Two tasks that touch the same file must be serialized to avoid merge conflicts.
6
+ *
7
+ * Algorithm:
8
+ * 1. For each task, extract `scope?.files_touched` (absent / empty → zero contribution).
9
+ * 2. Normalize each path: strip leading `./`, collapse multiple leading `./`, strip
10
+ * trailing `/`, canonicalize separators to forward-slash.
11
+ * 3. Enumerate all (i, j) pairs where i < j (by task index). For each pair, compute
12
+ * the intersection of their normalized file sets.
13
+ * 4. For each intersecting pair, emit one edge: lower-id (lexicographic) → higher-id.
14
+ * 5. Deduplicate edges by (from.id, to.id) — a pair can share multiple files, but
15
+ * only one edge is emitted per unique (from, to) combination.
16
+ * 6. Sort output by (from.id, to.id) for deterministic ordering.
17
+ */
18
+ /**
19
+ * Normalize a raw file path from `scope.files_touched`.
20
+ *
21
+ * Rules (applied in order):
22
+ * - Trim whitespace
23
+ * - Replace backslashes with forward-slashes
24
+ * - Strip any number of leading `./` sequences
25
+ * - Strip trailing `/`
26
+ * - Return the resulting string (may be empty for degenerate inputs; caller filters)
27
+ */
28
+ function normalizePath(p) {
29
+ let s = p.trim().replace(/\\/g, '/');
30
+ // Strip leading `./` repeatedly
31
+ while (s.startsWith('./')) {
32
+ s = s.slice(2);
33
+ }
34
+ // Strip trailing `/`
35
+ while (s.endsWith('/') && s.length > 1) {
36
+ s = s.slice(0, -1);
37
+ }
38
+ return s;
39
+ }
40
+ /**
41
+ * Extract and normalize `scope.files_touched` from a task document.
42
+ * Returns an empty array if the field is absent, null, or empty.
43
+ */
44
+ function getFiles(task) {
45
+ const scope = task['scope'];
46
+ if (!scope)
47
+ return [];
48
+ const files = scope['files_touched'];
49
+ if (!Array.isArray(files))
50
+ return [];
51
+ const normalized = [];
52
+ for (const f of files) {
53
+ if (typeof f !== 'string')
54
+ continue;
55
+ const n = normalizePath(f);
56
+ if (n.length > 0)
57
+ normalized.push(n);
58
+ }
59
+ return normalized;
60
+ }
61
+ /**
62
+ * Compute the canonical overlap edge (from → to) for a pair of tasks.
63
+ * The edge always points from the lexicographically lower id to the higher id.
64
+ */
65
+ function makeEdgeRef(projectA, idA, projectB, idB) {
66
+ if (idA <= idB) {
67
+ return { from: { project: projectA, id: idA }, to: { project: projectB, id: idB } };
68
+ }
69
+ return { from: { project: projectB, id: idB }, to: { project: projectA, id: idA } };
70
+ }
71
+ /**
72
+ * Compute overlap-inferred DAG edges from a list of task documents.
73
+ *
74
+ * Returns a deterministically ordered, deduplicated list of `TaskDagEdge`
75
+ * values — one edge per (from.id, to.id) pair that shares at least one
76
+ * normalized file path.
77
+ *
78
+ * Input order is irrelevant; output order is sorted by (from.id, to.id).
79
+ */
80
+ export function computeOverlapEdges(tasks) {
81
+ if (tasks.length < 2)
82
+ return [];
83
+ // Build a map: taskIndex → normalized file set
84
+ const fileSets = tasks.map(t => new Set(getFiles(t)));
85
+ // Collect edges using a map keyed by "fromId|toId" for deduplication
86
+ const edgeMap = new Map();
87
+ for (let i = 0; i < tasks.length; i++) {
88
+ for (let j = i + 1; j < tasks.length; j++) {
89
+ const setA = fileSets[i];
90
+ const setB = fileSets[j];
91
+ if (setA.size === 0 || setB.size === 0)
92
+ continue;
93
+ // Check intersection
94
+ let hasOverlap = false;
95
+ for (const f of setA) {
96
+ if (setB.has(f)) {
97
+ hasOverlap = true;
98
+ break;
99
+ }
100
+ }
101
+ if (!hasOverlap)
102
+ continue;
103
+ const taskA = tasks[i];
104
+ const taskB = tasks[j];
105
+ const edge = makeEdgeRef(taskA.project, taskA.id, taskB.project, taskB.id);
106
+ const key = `${edge.from.id}|${edge.to.id}`;
107
+ if (!edgeMap.has(key)) {
108
+ edgeMap.set(key, edge);
109
+ }
110
+ }
111
+ }
112
+ // Sort for determinism: by from.id, then to.id
113
+ const edges = Array.from(edgeMap.values());
114
+ edges.sort((a, b) => {
115
+ const c = a.from.id.localeCompare(b.from.id);
116
+ return c !== 0 ? c : a.to.id.localeCompare(b.to.id);
117
+ });
118
+ return edges;
119
+ }
120
+ /**
121
+ * Return true if task A and task B share at least one normalized file path.
122
+ * Used by savePlan to report which file caused the cycle.
123
+ */
124
+ export function getFirstSharedFile(taskA, taskB) {
125
+ const setA = new Set(getFiles(taskA));
126
+ const filesB = getFiles(taskB);
127
+ for (const f of filesB) {
128
+ if (setA.has(f))
129
+ return f;
130
+ }
131
+ return null;
132
+ }
package/dist/plan.mjs CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { plansDir, tasksDir } from './paths.mjs';
4
4
  import { validateOrThrow } from './validate.mjs';
5
5
  import { advanceWorkItemStatus, loadStateMachine } from './work-item.mjs';
6
+ import { computeOverlapEdges, getFirstSharedFile } from './dag-overlap.mjs';
6
7
  export function loadPlan(repoRoot, id) {
7
8
  const path = join(plansDir(repoRoot), `${id}.json`);
8
9
  if (!existsSync(path))
@@ -10,7 +11,55 @@ export function loadPlan(repoRoot, id) {
10
11
  return JSON.parse(readFileSync(path, 'utf-8'));
11
12
  }
12
13
  export function savePlan(repoRoot, plan) {
14
+ // 1. Schema validation.
13
15
  validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
16
+ // 2. Compute overlap-inferred edges from task scope.files_touched and
17
+ // merge them into task_dag.edges via set-union (idempotent).
18
+ const tasks = plan.tasks;
19
+ const overlapEdges = computeOverlapEdges(tasks);
20
+ if (overlapEdges.length > 0) {
21
+ const existingKeys = new Set(plan.task_dag.edges.map(e => `${e.from.id}|${e.to.id}`));
22
+ const newEdges = overlapEdges.filter(e => !existingKeys.has(`${e.from.id}|${e.to.id}`));
23
+ if (newEdges.length > 0) {
24
+ plan = {
25
+ ...plan,
26
+ task_dag: {
27
+ ...plan.task_dag,
28
+ edges: [...plan.task_dag.edges, ...newEdges],
29
+ },
30
+ };
31
+ }
32
+ }
33
+ // 3. Cycle detection on the augmented DAG. Only triggered when overlap
34
+ // edges are present — if computeOverlapEdges emitted any edges, we
35
+ // must verify the merged graph is acyclic. Report which pair and which
36
+ // file caused the cycle.
37
+ if (overlapEdges.length > 0) {
38
+ const cycleNodeId = detectCycle(plan.task_dag);
39
+ if (cycleNodeId !== null) {
40
+ // Find the two tasks involved in the cycle that share a file.
41
+ const taskMap = new Map();
42
+ for (const t of tasks)
43
+ taskMap.set(t.id, t);
44
+ let errorMsg = `file overlap creates cycle: ${cycleNodeId} ↔ (unknown) via (unknown)`;
45
+ // Try to find a concrete overlap pair that touches the cycle node.
46
+ outer: for (const edge of overlapEdges) {
47
+ const tFrom = taskMap.get(edge.from.id);
48
+ const tTo = taskMap.get(edge.to.id);
49
+ if (!tFrom || !tTo)
50
+ continue;
51
+ if (edge.from.id !== cycleNodeId && edge.to.id !== cycleNodeId)
52
+ continue;
53
+ const sharedFile = getFirstSharedFile(tFrom, tTo);
54
+ if (sharedFile) {
55
+ errorMsg = `file overlap creates cycle: ${edge.from.id} ↔ ${edge.to.id} via ${sharedFile}`;
56
+ break outer;
57
+ }
58
+ }
59
+ throw new Error(errorMsg);
60
+ }
61
+ }
62
+ // 4. Write to disk.
14
63
  mkdirSync(plansDir(repoRoot), { recursive: true });
15
64
  const path = join(plansDir(repoRoot), `${plan.id}.json`);
16
65
  writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
@@ -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,184 @@ 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
+ const scope = sibling['scope'] as Record<string, unknown> | undefined;
608
+ const files = Array.isArray(scope?.['files_touched'])
609
+ ? (scope!['files_touched'] as unknown[]).filter((x): x is string => typeof x === 'string')
610
+ : [];
611
+ if (files.length > 0) {
612
+ siblingScopes.push({ taskId: tid, files });
613
+ }
614
+ } catch {
615
+ // Skip task files that can't be read
616
+ }
617
+ }
618
+ } catch {
619
+ // main may not exist yet; treat as no siblings
620
+ }
621
+
622
+ // 3. Get modified files via git diff main..<branch>
623
+ let modifiedFiles: string[] = [];
624
+ try {
625
+ const diffOut = execSync(`git diff --name-only main..${branchName}`, {
626
+ cwd: repoRoot,
627
+ encoding: 'utf-8',
628
+ stdio: ['pipe', 'pipe', 'pipe'],
629
+ });
630
+ modifiedFiles = diffOut.split('\n').map((l) => l.trim()).filter(Boolean);
631
+ } catch {
632
+ // No diff is fine (empty branch)
633
+ }
634
+
635
+ // 4. Classify and output
636
+ const result = classifyFiles(taskDoc, modifiedFiles, siblingScopes);
637
+ process.stdout.write(JSON.stringify(result) + '\n');
638
+ process.exit(0);
639
+ }
640
+
641
+ case 'extend-scope': {
642
+ // extend-scope <repoRoot> <taskId> --add <file>... --reason <text>
643
+ const positional = rest.filter((a) => !a.startsWith('--'));
644
+ const [repoRoot, taskId] = positional;
645
+ if (!repoRoot || !taskId) usage('extend-scope requires <repoRoot> <taskId> --add <file>... --reason <text>');
646
+
647
+ // Parse --reason (takes everything after --reason up to another flag)
648
+ const reasonIdx = rest.indexOf('--reason');
649
+ if (reasonIdx < 0 || !rest[reasonIdx + 1]) {
650
+ process.stderr.write('extend-scope: missing --reason <text>\n');
651
+ process.exit(2);
652
+ }
653
+ // Collect reason: everything after --reason that doesn't start with --
654
+ const reasonParts: string[] = [];
655
+ for (let i = reasonIdx + 1; i < rest.length; i++) {
656
+ if (rest[i].startsWith('--')) break;
657
+ reasonParts.push(rest[i]);
658
+ }
659
+ const reason = reasonParts.join(' ');
660
+ if (!reason) {
661
+ process.stderr.write('extend-scope: --reason requires a non-empty value\n');
662
+ process.exit(2);
663
+ }
664
+
665
+ // Parse --add <file>... (all values after --add that don't start with --)
666
+ const addIdx = rest.indexOf('--add');
667
+ if (addIdx < 0) {
668
+ process.stderr.write('extend-scope: missing --add <file>...\n');
669
+ process.exit(2);
670
+ }
671
+ const addFiles: string[] = [];
672
+ for (let i = addIdx + 1; i < rest.length; i++) {
673
+ if (rest[i].startsWith('--')) break;
674
+ addFiles.push(rest[i]);
675
+ }
676
+ if (addFiles.length === 0) {
677
+ process.stderr.write('extend-scope: --add requires at least one file\n');
678
+ process.exit(2);
679
+ }
680
+
681
+ // Load task doc
682
+ const task = loadTask(repoRoot, taskId);
683
+
684
+ // Get current scope.files_touched
685
+ const scope = (task['scope'] ?? {}) as Record<string, unknown>;
686
+ const currentFiles = Array.isArray(scope['files_touched'])
687
+ ? (scope['files_touched'] as unknown[]).filter((f): f is string => typeof f === 'string')
688
+ : [];
689
+
690
+ // Set-union and sort/dedup
691
+ const merged = Array.from(new Set([...currentFiles, ...addFiles])).sort();
692
+
693
+ // Check if idempotent (no change needed)
694
+ const newlyAdded = addFiles.filter((f) => !currentFiles.includes(f));
695
+
696
+ // Always update and save (even if idempotent, shape is canonical)
697
+ const updatedTask = {
698
+ ...task,
699
+ scope: { ...scope, files_touched: merged },
700
+ };
701
+ saveTask(repoRoot, updatedTask);
702
+
703
+ // Append audit entry
704
+ // Find plan ID from task.parent or task.context
705
+ const parent = task['parent'] as { project?: string; id?: string } | undefined;
706
+ const planId = parent?.id ?? taskId; // fallback to taskId if no parent
707
+
708
+ const auditDir = join(repoRoot, '.cloverleaf', 'runs', 'plan', planId);
709
+ mkdirSync(auditDir, { recursive: true });
710
+ const auditPath = join(auditDir, 'audit.jsonl');
711
+
712
+ const auditEntry = {
713
+ ts: new Date().toISOString(),
714
+ kind: 'extend-scope',
715
+ task_id: taskId,
716
+ files: newlyAdded,
717
+ reason,
718
+ };
719
+ appendFileSync(auditPath, JSON.stringify(auditEntry) + '\n');
720
+
721
+ process.exit(0);
722
+ }
723
+
540
724
  default:
541
725
  usage(`Unknown command: ${command}`);
542
726
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * dag-overlap.ts — reference-impl v0.7.0
3
+ *
4
+ * Infers TaskDag serialization edges from shared `scope.files_touched` paths.
5
+ * Two tasks that touch the same file must be serialized to avoid merge conflicts.
6
+ *
7
+ * Algorithm:
8
+ * 1. For each task, extract `scope?.files_touched` (absent / empty → zero contribution).
9
+ * 2. Normalize each path: strip leading `./`, collapse multiple leading `./`, strip
10
+ * trailing `/`, canonicalize separators to forward-slash.
11
+ * 3. Enumerate all (i, j) pairs where i < j (by task index). For each pair, compute
12
+ * the intersection of their normalized file sets.
13
+ * 4. For each intersecting pair, emit one edge: lower-id (lexicographic) → higher-id.
14
+ * 5. Deduplicate edges by (from.id, to.id) — a pair can share multiple files, but
15
+ * only one edge is emitted per unique (from, to) combination.
16
+ * 6. Sort output by (from.id, to.id) for deterministic ordering.
17
+ */
18
+
19
+ import type { TaskDoc } from './task.js';
20
+
21
+ // Re-export the shape used in plan.ts so callers can use one import.
22
+ export interface TaskDagEdgeRef {
23
+ project: string;
24
+ id: string;
25
+ }
26
+
27
+ export interface TaskDagEdge {
28
+ from: TaskDagEdgeRef;
29
+ to: TaskDagEdgeRef;
30
+ }
31
+
32
+ /**
33
+ * Normalize a raw file path from `scope.files_touched`.
34
+ *
35
+ * Rules (applied in order):
36
+ * - Trim whitespace
37
+ * - Replace backslashes with forward-slashes
38
+ * - Strip any number of leading `./` sequences
39
+ * - Strip trailing `/`
40
+ * - Return the resulting string (may be empty for degenerate inputs; caller filters)
41
+ */
42
+ function normalizePath(p: string): string {
43
+ let s = p.trim().replace(/\\/g, '/');
44
+ // Strip leading `./` repeatedly
45
+ while (s.startsWith('./')) {
46
+ s = s.slice(2);
47
+ }
48
+ // Strip trailing `/`
49
+ while (s.endsWith('/') && s.length > 1) {
50
+ s = s.slice(0, -1);
51
+ }
52
+ return s;
53
+ }
54
+
55
+ /**
56
+ * Extract and normalize `scope.files_touched` from a task document.
57
+ * Returns an empty array if the field is absent, null, or empty.
58
+ */
59
+ function getFiles(task: TaskDoc): string[] {
60
+ const scope = task['scope'] as Record<string, unknown> | undefined;
61
+ if (!scope) return [];
62
+ const files = scope['files_touched'];
63
+ if (!Array.isArray(files)) return [];
64
+ const normalized: string[] = [];
65
+ for (const f of files) {
66
+ if (typeof f !== 'string') continue;
67
+ const n = normalizePath(f);
68
+ if (n.length > 0) normalized.push(n);
69
+ }
70
+ return normalized;
71
+ }
72
+
73
+ /**
74
+ * Compute the canonical overlap edge (from → to) for a pair of tasks.
75
+ * The edge always points from the lexicographically lower id to the higher id.
76
+ */
77
+ function makeEdgeRef(
78
+ projectA: string, idA: string,
79
+ projectB: string, idB: string
80
+ ): { from: TaskDagEdgeRef; to: TaskDagEdgeRef } {
81
+ if (idA <= idB) {
82
+ return { from: { project: projectA, id: idA }, to: { project: projectB, id: idB } };
83
+ }
84
+ return { from: { project: projectB, id: idB }, to: { project: projectA, id: idA } };
85
+ }
86
+
87
+ /**
88
+ * Compute overlap-inferred DAG edges from a list of task documents.
89
+ *
90
+ * Returns a deterministically ordered, deduplicated list of `TaskDagEdge`
91
+ * values — one edge per (from.id, to.id) pair that shares at least one
92
+ * normalized file path.
93
+ *
94
+ * Input order is irrelevant; output order is sorted by (from.id, to.id).
95
+ */
96
+ export function computeOverlapEdges(tasks: TaskDoc[]): TaskDagEdge[] {
97
+ if (tasks.length < 2) return [];
98
+
99
+ // Build a map: taskIndex → normalized file set
100
+ const fileSets: Array<Set<string>> = tasks.map(t => new Set(getFiles(t)));
101
+
102
+ // Collect edges using a map keyed by "fromId|toId" for deduplication
103
+ const edgeMap = new Map<string, TaskDagEdge>();
104
+
105
+ for (let i = 0; i < tasks.length; i++) {
106
+ for (let j = i + 1; j < tasks.length; j++) {
107
+ const setA = fileSets[i];
108
+ const setB = fileSets[j];
109
+ if (setA.size === 0 || setB.size === 0) continue;
110
+
111
+ // Check intersection
112
+ let hasOverlap = false;
113
+ for (const f of setA) {
114
+ if (setB.has(f)) {
115
+ hasOverlap = true;
116
+ break;
117
+ }
118
+ }
119
+ if (!hasOverlap) continue;
120
+
121
+ const taskA = tasks[i];
122
+ const taskB = tasks[j];
123
+ const edge = makeEdgeRef(
124
+ taskA.project, taskA.id,
125
+ taskB.project, taskB.id
126
+ );
127
+ const key = `${edge.from.id}|${edge.to.id}`;
128
+ if (!edgeMap.has(key)) {
129
+ edgeMap.set(key, edge);
130
+ }
131
+ }
132
+ }
133
+
134
+ // Sort for determinism: by from.id, then to.id
135
+ const edges = Array.from(edgeMap.values());
136
+ edges.sort((a, b) => {
137
+ const c = a.from.id.localeCompare(b.from.id);
138
+ return c !== 0 ? c : a.to.id.localeCompare(b.to.id);
139
+ });
140
+
141
+ return edges;
142
+ }
143
+
144
+ /**
145
+ * Return true if task A and task B share at least one normalized file path.
146
+ * Used by savePlan to report which file caused the cycle.
147
+ */
148
+ export function getFirstSharedFile(taskA: TaskDoc, taskB: TaskDoc): string | null {
149
+ const setA = new Set(getFiles(taskA));
150
+ const filesB = getFiles(taskB);
151
+ for (const f of filesB) {
152
+ if (setA.has(f)) return f;
153
+ }
154
+ return null;
155
+ }
package/lib/plan.ts CHANGED
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { plansDir, tasksDir } from './paths.js';
4
4
  import { validateOrThrow } from './validate.js';
5
5
  import { advanceWorkItemStatus, loadStateMachine } from './work-item.js';
6
+ import { computeOverlapEdges, getFirstSharedFile } from './dag-overlap.js';
7
+ import type { TaskDoc } from './task.js';
6
8
 
7
9
  export interface WorkItemRef {
8
10
  project: string;
@@ -34,7 +36,59 @@ export function loadPlan(repoRoot: string, id: string): PlanDoc {
34
36
  }
35
37
 
36
38
  export function savePlan(repoRoot: string, plan: PlanDoc): void {
39
+ // 1. Schema validation.
37
40
  validateOrThrow('https://cloverleaf.example/schemas/plan.schema.json', plan);
41
+
42
+ // 2. Compute overlap-inferred edges from task scope.files_touched and
43
+ // merge them into task_dag.edges via set-union (idempotent).
44
+ const tasks = plan.tasks as unknown as TaskDoc[];
45
+ const overlapEdges = computeOverlapEdges(tasks);
46
+ if (overlapEdges.length > 0) {
47
+ const existingKeys = new Set(
48
+ plan.task_dag.edges.map(e => `${e.from.id}|${e.to.id}`)
49
+ );
50
+ const newEdges = overlapEdges.filter(
51
+ e => !existingKeys.has(`${e.from.id}|${e.to.id}`)
52
+ );
53
+ if (newEdges.length > 0) {
54
+ plan = {
55
+ ...plan,
56
+ task_dag: {
57
+ ...plan.task_dag,
58
+ edges: [...plan.task_dag.edges, ...newEdges],
59
+ },
60
+ };
61
+ }
62
+ }
63
+
64
+ // 3. Cycle detection on the augmented DAG. Only triggered when overlap
65
+ // edges are present — if computeOverlapEdges emitted any edges, we
66
+ // must verify the merged graph is acyclic. Report which pair and which
67
+ // file caused the cycle.
68
+ if (overlapEdges.length > 0) {
69
+ const cycleNodeId = detectCycle(plan.task_dag);
70
+ if (cycleNodeId !== null) {
71
+ // Find the two tasks involved in the cycle that share a file.
72
+ const taskMap = new Map<string, TaskDoc>();
73
+ for (const t of tasks) taskMap.set(t.id, t);
74
+ let errorMsg = `file overlap creates cycle: ${cycleNodeId} ↔ (unknown) via (unknown)`;
75
+ // Try to find a concrete overlap pair that touches the cycle node.
76
+ outer: for (const edge of overlapEdges) {
77
+ const tFrom = taskMap.get(edge.from.id);
78
+ const tTo = taskMap.get(edge.to.id);
79
+ if (!tFrom || !tTo) continue;
80
+ if (edge.from.id !== cycleNodeId && edge.to.id !== cycleNodeId) continue;
81
+ const sharedFile = getFirstSharedFile(tFrom, tTo);
82
+ if (sharedFile) {
83
+ errorMsg = `file overlap creates cycle: ${edge.from.id} ↔ ${edge.to.id} via ${sharedFile}`;
84
+ break outer;
85
+ }
86
+ }
87
+ throw new Error(errorMsg);
88
+ }
89
+ }
90
+
91
+ // 4. Write to disk.
38
92
  mkdirSync(plansDir(repoRoot), { recursive: true });
39
93
  const path = join(plansDir(repoRoot), `${plan.id}.json`);
40
94
  writeFileSync(path, JSON.stringify(plan, null, 2) + '\n');
@@ -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.6.7",
3
+ "version": "0.7.1",
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",
@@ -48,7 +48,7 @@
48
48
  "prepublishOnly": "node scripts/check-standard-prepped.mjs && npm test && npm run build"
49
49
  },
50
50
  "dependencies": {
51
- "@cloverleaf/standard": "^0.4.0",
51
+ "@cloverleaf/standard": "^0.5.0",
52
52
  "ajv": "^8.17.1",
53
53
  "ajv-formats": "^3.0.1",
54
54
  "axe-core": "^4.10.0",
@@ -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
@@ -27,7 +27,9 @@ You are the Plan Agent. Your role is to take an approved RFC and its completed S
27
27
  4. Build a `task_dag` using the edge-based shape from `dependency-dag.schema.json`:
28
28
  - `nodes: Array<{project, id}>` — one workItemRef per task in `tasks[]`.
29
29
  - `edges: Array<{from: {project, id}, to: {project, id}}>` — directed edges from prerequisite to dependent. `to` cannot start until `from` completes. Both endpoints must appear in `nodes`. DAG roots are nodes that appear in no edge's `to` field.
30
- 5. If `path_rules` is non-empty, emit `path_reviewer_map: Array<{pattern, role}>` by mapping the rules' path globs to reviewer roles.
30
+ - Only add edges for **logical** sequencing dependencies (e.g. task B cannot start until task A's output exists). **Do NOT manually add edges for file overlap. The system computes them automatically when the Plan is saved.**
31
+ 5. For each task in `tasks[]`, populate `scope.files_touched` with the repo-root-relative POSIX paths of every file the task is expected to create or modify. Transcribe these paths directly from the brief's Parallel-DAG conflict guidance section; if the brief does not list specific paths for a task, include a best-effort list based on the task's definition of done. Example: `"scope": { "files_touched": ["reference-impl/lib/cli.ts", "reference-impl/lib/auth.ts"] }`.
32
+ 6. If `path_rules` is non-empty, emit `path_reviewer_map: Array<{pattern, role}>` by mapping the rules' path globs to reviewer roles.
31
33
 
32
34
  ## Emit a Plan JSON conforming to `plan.schema.json`
33
35
 
@@ -61,3 +63,29 @@ Write the Plan JSON to stdout, nothing else. No prose, no markdown fences. The o
61
63
  - **Schema compliance is mandatory.** Each task in `tasks[]` must independently pass `task.schema.json` validation.
62
64
  - **No file writes.** Orchestrator persists your output.
63
65
  - Gate for Plan approval: `task_batch_gate` (approved by human after `gate-pending`).
66
+
67
+ ## Gate-pending summary template
68
+
69
+ When the orchestrator transitions the Plan to `gate-pending`, include a human-readable summary of the proposed task graph. Use the following template, computing the edge groupings inline before emitting:
70
+
71
+ ```
72
+ Plan <PLAN-ID> — gate-pending
73
+
74
+ Tasks (<N> total):
75
+ <TASK-ID> <task title> [<risk_class>]
76
+ ...
77
+
78
+ Dependencies:
79
+ Logical:
80
+ <FROM-ID> → <TO-ID> (<reason, e.g. "B needs A's migration output">)
81
+ ...
82
+ Inferred from file overlap:
83
+ <FROM-ID> → <TO-ID> (shared: <comma-separated overlapping file paths>)
84
+ ...
85
+
86
+ (None) — use "(None)" under a subsection when it is empty.
87
+ ```
88
+
89
+ **How to compute the diff inline:**
90
+ - **Logical** edges: edges you explicitly declared in `task_dag.edges` for sequencing reasons.
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.
@@ -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.