cclaw-cli 7.6.0 → 7.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.
@@ -71,8 +71,11 @@ export function extractMembersListFromLine(trimmedLine) {
71
71
  *
72
72
  * Rules:
73
73
  * - The line must start with `|` (after trimming).
74
- * - Column 1 (after stripping markdown noise) must match `^S-(\d+)$`
75
- * header rows (`| sliceId | …`) and separator rows (`|---|---|…`) are
74
+ * - Column 1 (after stripping markdown noise) may be either a slice id
75
+ * (`S-N`) or an implementation-unit id (`U-N`). Unit ids derive their
76
+ * execution slice as `S-N`, which lets 7.7+ plans schedule feature-atomic
77
+ * units without inventing a tiny `T-NNN` row per dispatch lane. Header rows
78
+ * (`| sliceId | …`, `| unit | …`) and separator rows (`|---|---|…`) are
76
79
  * silently skipped.
77
80
  * - Column 2, when present and non-empty, becomes the `unitId`
78
81
  * verbatim (after stripping whitespace + backticks/quotes/brackets).
@@ -94,13 +97,16 @@ export function parseTableRowMember(trimmedLine) {
94
97
  const stripDecorations = (raw) => raw.replace(/^[`"'[\]()]+|[`"'[\]()]+$/gu, "").trim();
95
98
  const col1 = stripDecorations(cells[0]);
96
99
  const parsedSlice = parseSliceId(col1);
97
- if (!parsedSlice)
100
+ const parsedUnit = tokenToSliceAndUnit(col1);
101
+ if (!parsedSlice && !parsedUnit)
98
102
  return null;
99
- const sliceTail = parsedSlice.suffix.length > 0
100
- ? `${parsedSlice.numeric}${parsedSlice.suffix}`
101
- : `${parsedSlice.numeric}`;
102
- const sliceId = parsedSlice.id;
103
- let unitId = `U-${sliceTail}`;
103
+ const sliceTail = parsedSlice
104
+ ? parsedSlice.suffix.length > 0
105
+ ? `${parsedSlice.numeric}${parsedSlice.suffix}`
106
+ : `${parsedSlice.numeric}`
107
+ : "";
108
+ const sliceId = parsedSlice ? parsedSlice.id : parsedUnit.sliceId;
109
+ let unitId = parsedSlice ? `U-${sliceTail}` : parsedUnit.unitId;
104
110
  if (cells.length >= 2) {
105
111
  const col2 = stripDecorations(cells[1]);
106
112
  if (col2.length > 0) {
@@ -1,4 +1,5 @@
1
1
  import type { Writable } from "node:stream";
2
+ import type { ExecutionTopology } from "../types.js";
2
3
  interface InternalIo {
3
4
  stdout: Writable;
4
5
  stderr: Writable;
@@ -12,11 +13,25 @@ export interface WaveStatusWaveSummary {
12
13
  blockedMembers: string[];
13
14
  status: "closed" | "open" | "partial" | "empty";
14
15
  }
16
+ /**
17
+ * 7.7.1 — `controller-inline` is added so the controller knows it must
18
+ * fulfil the ready slices in this turn (no slice-builder dispatch). The
19
+ * remaining values (`single-slice`, `wave-fanout`, `blocked`, `none`) keep
20
+ * their pre-7.7.1 contract.
21
+ */
15
22
  export interface WaveStatusNextDispatch {
16
23
  waveId: string | null;
17
24
  readyToDispatch: string[];
18
25
  pathConflicts: string[];
19
- mode: "single-slice" | "wave-fanout" | "blocked" | "none";
26
+ mode: "single-slice" | "wave-fanout" | "blocked" | "none" | "controller-inline";
27
+ topology: Exclude<ExecutionTopology, "auto"> | "none";
28
+ topologyReason: string;
29
+ maxBuilders: number;
30
+ /**
31
+ * Optional natural-language hint for the controller, populated when the
32
+ * router selects a non-default mode (currently only `inline`).
33
+ */
34
+ controllerHint?: string;
20
35
  }
21
36
  export interface WaveStatusReport {
22
37
  activeRunId: string;
@@ -4,6 +4,8 @@ import { RUNTIME_ROOT } from "../constants.js";
4
4
  import { readDelegationEvents, readDelegationLedger } from "../delegation.js";
5
5
  import { readFlowState } from "../runs.js";
6
6
  import { DEFAULT_SLICE_STREAM_REL_PATH, readEventStreamFile } from "../streaming/event-stream.js";
7
+ import { readConfig, resolveExecutionStrictness, resolveExecutionTopology, resolveMaxBuilders } from "../config.js";
8
+ import { routeExecutionTopology } from "../execution-topology.js";
7
9
  import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./plan-split-waves.js";
8
10
  import { compareSliceIds, parseSliceId } from "../util/slice-id.js";
9
11
  const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
@@ -55,7 +57,7 @@ function parsePipeRow(trimmedLine) {
55
57
  function normalizePathToken(raw) {
56
58
  return raw.trim().replace(/^`|`$/gu, "").replace(/^\.\/+/u, "");
57
59
  }
58
- function parseManagedWaveClaimedPaths(planMarkdown) {
60
+ function parseManagedWaveSliceMeta(planMarkdown) {
59
61
  const out = new Map();
60
62
  const startIdx = planMarkdown.indexOf(PARALLEL_EXEC_MANAGED_START);
61
63
  const endIdx = planMarkdown.indexOf(PARALLEL_EXEC_MANAGED_END);
@@ -82,7 +84,11 @@ function parseManagedWaveClaimedPaths(planMarkdown) {
82
84
  if (cells.length === 0)
83
85
  continue;
84
86
  const first = cells[0].toLowerCase();
85
- if (first === "sliceid" || first === "slice id") {
87
+ if (first === "sliceid" ||
88
+ first === "slice id" ||
89
+ first === "unitid" ||
90
+ first === "unit id" ||
91
+ first === "unit") {
86
92
  headerIdx = new Map();
87
93
  for (let i = 0; i < cells.length; i += 1) {
88
94
  const key = cells[i].toLowerCase().replace(/[^a-z0-9]/gu, "");
@@ -95,10 +101,12 @@ function parseManagedWaveClaimedPaths(planMarkdown) {
95
101
  if (cells.every((cell) => /^:?-{3,}:?$/u.test(cell))) {
96
102
  continue;
97
103
  }
98
- const parsedSlice = parseSliceId(cells[0]);
99
- if (!parsedSlice)
104
+ const firstCell = (cells[0] ?? "").replace(/^`|`$/gu, "").trim();
105
+ const parsedSlice = parseSliceId(firstCell);
106
+ const parsedUnit = /^U-(\d+(?:[a-z][a-z0-9]*)?)$/iu.exec(firstCell);
107
+ if (!parsedSlice && !parsedUnit)
100
108
  continue;
101
- const sliceId = parsedSlice.id;
109
+ const sliceId = parsedSlice?.id ?? `S-${parsedUnit[1].toLowerCase()}`;
102
110
  const pathsIdx = headerIdx.get("claimedpaths");
103
111
  const rawPaths = pathsIdx !== undefined ? (cells[pathsIdx] ?? "") : "";
104
112
  const claimedPaths = rawPaths.length === 0
@@ -107,10 +115,52 @@ function parseManagedWaveClaimedPaths(planMarkdown) {
107
115
  .split(",")
108
116
  .map((p) => normalizePathToken(p))
109
117
  .filter((p) => p.length > 0);
110
- out.get(currentWaveId).set(sliceId, claimedPaths);
118
+ const laneIdx = headerIdx.get("lane");
119
+ const rawLane = laneIdx !== undefined ? (cells[laneIdx] ?? "") : "";
120
+ const lane = rawLane.replace(/^`|`$/gu, "").trim().toLowerCase();
121
+ const riskIdx = headerIdx.get("risktier");
122
+ const rawRisk = riskIdx !== undefined ? (cells[riskIdx] ?? "") : "";
123
+ const riskTier = rawRisk.replace(/^`|`$/gu, "").trim().toLowerCase();
124
+ out.get(currentWaveId).set(sliceId, {
125
+ claimedPaths,
126
+ lane: lane.length > 0 ? lane : undefined,
127
+ riskTier: riskTier.length > 0 ? riskTier : undefined
128
+ });
111
129
  }
112
130
  return out;
113
131
  }
132
+ /**
133
+ * Backward-compatible projection: callers that only need claimed paths
134
+ * keep getting `Map<sliceId, paths[]>` shapes.
135
+ */
136
+ function projectClaimedPaths(meta) {
137
+ const out = new Map();
138
+ for (const [sliceId, info] of meta) {
139
+ out.set(sliceId, info.claimedPaths);
140
+ }
141
+ return out;
142
+ }
143
+ const DISCOVERY_LANES = new Set(["scaffold", "docs"]);
144
+ /**
145
+ * 7.7.1 — count ready members whose lane is `scaffold` or `docs` and whose
146
+ * `riskTier` is not `high`. Members with no recorded lane fall back to
147
+ * non-discovery (so the controller never collapses substantive work into
148
+ * inline by accident).
149
+ */
150
+ function countDiscoveryOnlyUnits(readySlices, meta) {
151
+ let count = 0;
152
+ for (const sliceId of readySlices) {
153
+ const info = meta.get(sliceId);
154
+ if (!info)
155
+ continue;
156
+ if (!info.lane || !DISCOVERY_LANES.has(info.lane))
157
+ continue;
158
+ if (info.riskTier === "high")
159
+ continue;
160
+ count += 1;
161
+ }
162
+ return count;
163
+ }
114
164
  function detectPathConflicts(readySlices, bySlice) {
115
165
  const conflicts = new Set();
116
166
  const ordered = [...readySlices].sort(compareSliceIds);
@@ -175,7 +225,10 @@ export async function runWaveStatus(projectRoot, options = {}) {
175
225
  waveId: null,
176
226
  readyToDispatch: [],
177
227
  pathConflicts: [],
178
- mode: "none"
228
+ mode: "none",
229
+ topology: "none",
230
+ topologyReason: "wave plan could not be parsed",
231
+ maxBuilders: 0
179
232
  },
180
233
  warnings: [
181
234
  `wave_plan_parse_error: ${err instanceof Error ? err.message : String(err)}`
@@ -203,7 +256,10 @@ export async function runWaveStatus(projectRoot, options = {}) {
203
256
  waveId: null,
204
257
  readyToDispatch: [],
205
258
  pathConflicts: [],
206
- mode: "none"
259
+ mode: "none",
260
+ topology: "none",
261
+ topologyReason: "wave plan sources conflict",
262
+ maxBuilders: 0
207
263
  },
208
264
  warnings: [
209
265
  `wave_plan_merge_conflict: ${err instanceof Error ? err.message : String(err)}`
@@ -331,30 +387,68 @@ export async function runWaveStatus(projectRoot, options = {}) {
331
387
  warnings.push("wave_plan_managed_block_missing: <!-- parallel-exec-managed-start --> block not found in 05-plan.md and wave-plans/ has no parseable wave files.");
332
388
  }
333
389
  let nextDispatch;
390
+ const config = await readConfig(projectRoot).catch(() => null);
391
+ const configuredTopology = resolveExecutionTopology(config);
392
+ const strictness = resolveExecutionStrictness(config);
393
+ const maxBuilders = resolveMaxBuilders(config);
334
394
  if (firstOpenWave === null) {
335
395
  nextDispatch = {
336
396
  waveId: null,
337
397
  readyToDispatch: [],
338
398
  pathConflicts: [],
339
- mode: "none"
399
+ mode: "none",
400
+ topology: "none",
401
+ topologyReason: "no open wave has ready units",
402
+ maxBuilders
340
403
  };
341
404
  }
342
405
  else {
343
406
  const readyToDispatch = [...firstOpenWave.readyMembers].sort(compareSliceIds);
344
- const claimedPathsByWave = parseManagedWaveClaimedPaths(planRaw);
345
- const conflicts = detectPathConflicts(readyToDispatch, claimedPathsByWave.get(firstOpenWave.waveId) ?? new Map());
346
- const mode = conflicts.length > 0
407
+ const sliceMetaByWave = parseManagedWaveSliceMeta(planRaw);
408
+ const sliceMeta = sliceMetaByWave.get(firstOpenWave.waveId) ?? new Map();
409
+ const conflicts = detectPathConflicts(readyToDispatch, projectClaimedPaths(sliceMeta));
410
+ const discoveryOnlyUnits = countDiscoveryOnlyUnits(readyToDispatch, sliceMeta);
411
+ const baseMode = conflicts.length > 0
347
412
  ? "blocked"
348
413
  : readyToDispatch.length > 1
349
414
  ? "wave-fanout"
350
415
  : readyToDispatch.length === 1
351
416
  ? "single-slice"
352
417
  : "none";
418
+ const topologyDecision = baseMode === "none"
419
+ ? null
420
+ : routeExecutionTopology({
421
+ configuredTopology,
422
+ strictness,
423
+ maxBuilders,
424
+ shape: {
425
+ unitCount: readyToDispatch.length,
426
+ independentUnitCount: conflicts.length > 0 ? 0 : readyToDispatch.length,
427
+ substantialUnitCount: readyToDispatch.length,
428
+ hasPathConflicts: conflicts.length > 0,
429
+ inlineSafe: false,
430
+ discoveryOnlyUnits
431
+ }
432
+ });
433
+ // 7.7.1 — when the router collapses a discovery-only ready set into
434
+ // `inline`, surface `controller-inline` mode + a controllerHint so the
435
+ // controller knows it must fulfil the slices this turn instead of
436
+ // dispatching slice-builder per file.
437
+ const mode = topologyDecision?.topology === "inline"
438
+ ? "controller-inline"
439
+ : baseMode;
440
+ const controllerHint = topologyDecision?.topology === "inline"
441
+ ? "Fulfill ready slices in this turn without dispatching slice-builder. Record delegation rows with role=controller (scheduled→completed) per slice."
442
+ : undefined;
353
443
  nextDispatch = {
354
444
  waveId: firstOpenWave.waveId,
355
445
  readyToDispatch,
356
446
  pathConflicts: conflicts,
357
- mode
447
+ mode,
448
+ topology: topologyDecision?.topology ?? "none",
449
+ topologyReason: topologyDecision?.reason ?? "no ready units",
450
+ maxBuilders: topologyDecision?.maxBuilders ?? maxBuilders,
451
+ ...(controllerHint ? { controllerHint } : {})
358
452
  };
359
453
  }
360
454
  return {
@@ -382,6 +476,7 @@ function formatHumanReport(report) {
382
476
  }
383
477
  lines.push(`nextDispatch: wave=${report.nextDispatch.waveId ?? "(none)"} ` +
384
478
  `mode=${report.nextDispatch.mode} ` +
479
+ `topology=${report.nextDispatch.topology} ` +
385
480
  `ready=[${report.nextDispatch.readyToDispatch.join(",")}]`);
386
481
  if (report.warnings.length > 0) {
387
482
  lines.push("warnings:");
package/dist/types.d.ts CHANGED
@@ -163,6 +163,10 @@ export interface ReviewLoopConfig {
163
163
  export type VcsMode = "git-with-remote" | "git-local-only" | "none";
164
164
  export type TddCommitMode = "managed-per-slice" | "agent-required" | "checkpoint-only" | "off";
165
165
  export type TddIsolationMode = "worktree" | "in-place" | "auto";
166
+ export type ExecutionTopology = "auto" | "inline" | "single-builder" | "parallel-builders" | "strict-micro";
167
+ export type ExecutionStrictnessProfile = "fast" | "balanced" | "strict";
168
+ export type PlanSliceGranularity = "feature-atomic" | "strict-micro";
169
+ export type PlanMicroTaskPolicy = "advisory" | "strict";
166
170
  /**
167
171
  * 7.6.0 — what slice-commit does when a manifest in the slice's
168
172
  * claim drifts its corresponding lockfile twin (e.g. Cargo.toml +
@@ -206,11 +210,52 @@ export interface TddConfig {
206
210
  */
207
211
  lockfileTwinPolicy?: LockfileTwinPolicy;
208
212
  }
213
+ export interface ExecutionConfig {
214
+ /**
215
+ * 7.7.0 — adaptive execution topology for implementation units.
216
+ *
217
+ * - `auto` (default): choose the cheapest safe route from plan shape.
218
+ * - `inline`: controller may execute a low-risk unit inline while still
219
+ * recording RED/GREEN/REFACTOR evidence.
220
+ * - `single-builder`: one slice-builder owns a feature-atomic unit.
221
+ * - `parallel-builders`: fan out independent substantial units only.
222
+ * - `strict-micro`: preserve the pre-7.7 posture where each tiny task is
223
+ * its own schedulable slice.
224
+ */
225
+ topology?: ExecutionTopology;
226
+ /**
227
+ * 7.7.0 — default calibration for topology and plan-shape advisories.
228
+ * `balanced` is the default: prefer feature-atomic units with internal
229
+ * 2-5 minute TDD steps, warning on microtask-only plans without blocking.
230
+ */
231
+ strictness?: ExecutionStrictnessProfile;
232
+ /**
233
+ * 7.7.0 — upper bound for simultaneous slice-builder workers when the
234
+ * router selects `parallel-builders`.
235
+ */
236
+ maxBuilders?: number;
237
+ }
238
+ export interface PlanConfig {
239
+ /**
240
+ * 7.7.0 — default schedulable surface for plan authoring.
241
+ * - feature-atomic: U-* slices contain internal 2-5 minute TDD steps.
242
+ * - strict-micro: one tiny task can remain one schedulable slice.
243
+ */
244
+ sliceGranularity?: PlanSliceGranularity;
245
+ /**
246
+ * 7.7.0 — how strongly the plan linter reacts to microtask-only plans.
247
+ * `advisory` warns in fast/balanced execution; `strict` treats strict
248
+ * microtask planning as intentional.
249
+ */
250
+ microTaskPolicy?: PlanMicroTaskPolicy;
251
+ }
209
252
  export interface CclawConfig {
210
253
  version: string;
211
254
  flowVersion: string;
212
255
  harnesses: HarnessId[];
213
256
  tdd?: TddConfig;
257
+ execution?: ExecutionConfig;
258
+ plan?: PlanConfig;
214
259
  }
215
260
  export interface TransitionRule {
216
261
  from: FlowStage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "7.6.0",
3
+ "version": "7.7.1",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {