sortie-dogs 0.7.1 → 0.8.0

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.
Files changed (38) hide show
  1. package/README.md +41 -26
  2. package/dist/asset-version.d.ts +1 -1
  3. package/dist/asset-version.js +1 -1
  4. package/dist/core/acceptance-continuity.js +2 -2
  5. package/dist/core/initialize.js +45 -0
  6. package/dist/core/luna-fabric-contract.d.ts +54 -0
  7. package/dist/core/luna-fabric-contract.js +286 -0
  8. package/dist/core/luna-fabric-scheduler.d.ts +31 -0
  9. package/dist/core/luna-fabric-scheduler.js +138 -0
  10. package/dist/core/types.d.ts +37 -1
  11. package/dist/core/validate-schema.js +1 -1
  12. package/dist/core/validate-worktree-parallel.js +1 -1
  13. package/dist/core/worktree-commit-artifact.js +8 -3
  14. package/dist/core/worktree-integration-queue.js +6 -5
  15. package/dist/core/worktree-lifecycle.d.ts +9 -0
  16. package/dist/core/worktree-lifecycle.js +45 -14
  17. package/dist/core/worktree-parallel-dispatch.d.ts +42 -1
  18. package/dist/core/worktree-parallel-dispatch.js +1216 -50
  19. package/dist/index.d.ts +7 -3
  20. package/dist/index.js +3 -1
  21. package/dist/plugin/config.d.ts +4 -2
  22. package/dist/plugin/config.js +21 -8
  23. package/dist/plugin/continuation.d.ts +28 -2
  24. package/dist/plugin/continuation.js +107 -0
  25. package/dist/plugin/fast-lane.d.ts +1 -0
  26. package/dist/plugin/fast-lane.js +14 -3
  27. package/dist/plugin/gate.d.ts +1 -1
  28. package/dist/plugin/gate.js +1 -0
  29. package/dist/plugin/host-conformance.d.ts +132 -0
  30. package/dist/plugin/host-conformance.js +56 -0
  31. package/dist/plugin/index.d.ts +19 -0
  32. package/dist/plugin/index.js +818 -190
  33. package/dist/plugin/model-routing.d.ts +18 -21
  34. package/dist/plugin/model-routing.js +42 -29
  35. package/dist/plugin/task-result-repair.js +9 -3
  36. package/dist/runtime-assets.d.ts +13 -8
  37. package/dist/runtime-assets.js +313 -149
  38. package/package.json +1 -1
package/README.md CHANGED
@@ -24,7 +24,7 @@ Requirements: Node.js 22.6 or newer, npm, and OpenCode.
24
24
 
25
25
  Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
26
26
 
27
- Release: [v0.7.1](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.7.1)
27
+ Release: [v0.8.0](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.8.0)
28
28
 
29
29
  ## Quick start
30
30
 
@@ -184,10 +184,14 @@ global file for durable global settings.
184
184
  Remove the directory only when no Sortie run is active.
185
185
  - `readOnlyTools` adds host-specific tool names that never change files, such as
186
186
  MCP tools. Unknown tools are denied for a bound session by default.
187
- - `dedicatedWorkerModel` selects the single model every worker role resolves to.
188
- It defaults to `openai/gpt-5.6-luna` with variant `max`; declare your own when
189
- that model is unavailable or when you want a different worker effort. Worker
190
- roles always resolve to this one target and cannot be routed per role.
187
+ - `dedicatedWorkerModel` selects the serial implementation target used by
188
+ `implementation`, `remediation`, `blocker-resolution`, `sol-worker-mk2a2`, and
189
+ `dog-worker`. It defaults to `openai/gpt-5.6-sol` with variant `medium`.
190
+ The installed `dog-luna-worker` fabric route remains fixed to
191
+ `openai/gpt-5.6-luna` with variant `max`; pointing the serial target at that
192
+ Luna model is invalid because it would collapse the two route identities. The
193
+ coordinator dispatches this role only for a ready descriptor of a prepared
194
+ `luna-fabric` run; a `sol-serial` run keeps `dog-worker`.
191
195
  - `continuation` bounds the batch loop. After a terminal unit and its checkpoint,
192
196
  `dog-coordinator` calls `sortie_compact_and_continue`, which compacts the run
193
197
  and resumes the same root session on the next independent unit. Only a root
@@ -223,9 +227,9 @@ global file for durable global settings.
223
227
  requests are not repeated.
224
228
  - **Writes stay inside the assignment.** Exact source or operation manifests
225
229
  gate edits and handoffs.
226
- - **Autonomous sequential implementation.** A normal request may use as many sequential
227
- workers as its accepted scope requires. Concurrent fan-out still needs the explicit
228
- parallel contract.
230
+ - **Autonomous implementation routing.** An accepted scope with at least two safe independent
231
+ units defaults to Luna fabric without opt-in. An explicit serial/no-parallel request wins;
232
+ other work uses sequential workers. The separate parallel contract remains explicit.
229
233
  - **Runtime overlap protection.** Active equal or ancestor write scopes are rejected
230
234
  before mutation, and full validation waits until every parallel unit joins.
231
235
  - **Evidence before completion.** Canonical validation, risk-based review, and
@@ -321,29 +325,40 @@ where the curve gives the most per unit of cost. Nobody selects a model for a
321
325
  session the loop spawns, which is why delegated roles carry defaults and the
322
326
  coordinator does not. Project-local routing can override this default.
323
327
 
324
- The `implementation`, `remediation`, `blocker-resolution`, and `dog-worker`
325
- roles always use the dedicated worker target, `openai/gpt-5.6-luna` with the
326
- `max` variant. Worker effort stays at the top of that model's range while review
327
- effort stays above it on a stronger model, which is what mandatory source review
328
- is for. Declare `dedicatedWorkerModel` as `openai/gpt-5.6-sol` when you would
329
- rather pay for the stronger worker model up front; that target stays in the
330
- built-in catalog for exactly this reason. `modelRouting`
331
- cannot replace those routes, and only `dedicatedWorkerModel` moves them. For other explicitly
332
- routed roles, resolution is deterministic: Sortie-dogs tries the preferred
333
- target, then ordered fallbacks. Roles without either a built-in default or an
334
- explicit route keep OpenCode's already selected model.
328
+ The `implementation`, `remediation`, `blocker-resolution`, `sol-worker-mk2a2`,
329
+ and `dog-worker` roles always use the stable serial target,
330
+ `openai/gpt-5.6-sol` with the `medium` variant. `dedicatedWorkerModel` may move
331
+ that serial target when a host cannot serve it. The installed `dog-luna-worker`
332
+ route is separately fixed to `openai/gpt-5.6-luna` with the `max` variant. Its
333
+ shared worker contract requires one validated fabric descriptor: the coordinator
334
+ admits a v0.8 DAG contract with `sortie_admit_luna_fabric`, prepares it with
335
+ `sortie_prepare_luna_fabric`, and materializes only the current ready wave, with
336
+ at most five distinct Luna units. The complete DAG may contain up to 64 units.
337
+ After every active artifact is verified, `sortie_advance_luna_fabric_wave`
338
+ integrates them into a runtime-owned hidden candidate, cleans those worktrees,
339
+ and creates fresh worktrees from that exact snapshot. After the final wave,
340
+ `sortie_validate_luna_fabric_candidate` runs canonical validation once and
341
+ `sortie_accept_luna_fabric_candidate` records review before one target CAS.
342
+ Declared shared-path ownership serializes overlapping units
343
+ across waves; unowned overlap or any admission defect routes the whole job back
344
+ to one `dog-worker`. The fabric never duplicates one unit across lanes.
345
+ `modelRouting` cannot replace either fixed
346
+ route, and a serial override naming the Luna fabric model is invalid rather than
347
+ silently collapsing both identities. Version 0.7.0 routed `dog-worker` to Luna
348
+ Max; v0.8 intentionally preserves that history while splitting stable Sol and
349
+ fabric Luna roles. Other explicit routes try the preferred target, then ordered
350
+ fallbacks. Roles without a built-in default or explicit route keep OpenCode's
351
+ already selected model.
335
352
 
336
353
  `dog-reviewer` and `dog-advisor` must never inherit the caller's model, because
337
354
  review and strategy lose their value when they run on the model that produced
338
355
  the candidate. Both default to `anthropic/claude-opus-5` when the catalog
339
356
  declares it, and otherwise fall back to `openai/gpt-5.6-sol` with the `xhigh`
340
- variant. That fallback stays on the stronger model rather than matching the
341
- worker target, because review has to be able to reject work the worker just
342
- produced. A host that redeclares
343
- `dedicatedWorkerModel` keeps that target as its first fallback, since such a
344
- host may not serve the shipped model at all. Nothing here requires a particular
345
- vendor: both roles stay fully configurable, so declare whichever model you can
346
- actually serve.
357
+ variant. That fallback uses higher effort than the Sol Medium worker because
358
+ review has to be able to reject work the worker just produced. Moving
359
+ `dedicatedWorkerModel` does not change consultation policy. Nothing here requires
360
+ a particular vendor: both roles stay fully configurable, so declare whichever
361
+ model you can actually serve.
347
362
 
348
363
  ```json
349
364
  {
@@ -2,5 +2,5 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export declare const RUNTIME_ASSET_VERSION = "0.3.52-acceptance-continuity-v1";
5
+ export declare const RUNTIME_ASSET_VERSION = "0.3.69-luna-combined-validation-replay-v1";
6
6
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
@@ -2,4 +2,4 @@
2
2
  * Version of the installable runtime assets. Kept in its own module so the plugin can compare an
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
- export const RUNTIME_ASSET_VERSION = "0.3.52-acceptance-continuity-v1";
5
+ export const RUNTIME_ASSET_VERSION = "0.3.69-luna-combined-validation-replay-v1";
@@ -38,13 +38,13 @@ export function inspectAcceptanceContinuity(handoff) {
38
38
  raw.task_id.length === 0 || raw.task_id.length > MAX_TASK_ID || !Array.isArray(raw.criteria) ||
39
39
  raw.criteria.length === 0 || raw.criteria.length > MAX_ACCEPTANCE_CRITERIA ||
40
40
  !raw.criteria.every((criterion) => typeof criterion === "string" && criterion.length > 0 && criterion.length <= MAX_CRITERION) ||
41
- new Set(raw.criteria).size !== raw.criteria.length || typeof raw.fingerprint !== "string" ||
41
+ typeof raw.fingerprint !== "string" ||
42
42
  !FINGERPRINT.test(raw.fingerprint) || !(raw.parent_fingerprint === "none" ||
43
43
  (typeof raw.parent_fingerprint === "string" && FINGERPRINT.test(raw.parent_fingerprint)))) {
44
44
  return { ledger: undefined, error: "malformed" };
45
45
  }
46
46
  const criteria = normalizeAcceptanceCriteria(raw.criteria);
47
- if (new Set(criteria).size !== criteria.length || acceptanceContinuityFingerprint(criteria) !== raw.fingerprint) {
47
+ if (acceptanceContinuityFingerprint(criteria) !== raw.fingerprint) {
48
48
  return { ledger: undefined, error: "malformed" };
49
49
  }
50
50
  return {
@@ -8,6 +8,8 @@ const { runtimeAssets } = assets;
8
8
  const OPEN_CODE_DIRECTORY = ".opencode";
9
9
  const VERSION_MARKER = `${OPEN_CODE_DIRECTORY}/sortie-dogs.version`;
10
10
  const GLOBAL_VERSION_MARKER = "sortie-dogs.version";
11
+ const LUNA_FABRIC_CONTROL_IGNORE = `${OPEN_CODE_DIRECTORY}/.gitignore`;
12
+ const LUNA_FABRIC_CONTROL_FILE = "sortie-dogs-luna-fabric.json";
11
13
  const LEGACY_RUNTIME_ASSETS = [
12
14
  {
13
15
  relativePath: ".opencode/agent/coordinator-mk2a2.md",
@@ -263,6 +265,44 @@ async function overwriteFileSafely(root, relativePath, content, beforeMutation)
263
265
  await handle?.close();
264
266
  }
265
267
  }
268
+ async function appendFileSafely(root, relativePath, content) {
269
+ await assertSafeExistingPath(root, relativePath, true);
270
+ let handle;
271
+ try {
272
+ handle = await open(resolve(root, relativePath), constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
273
+ if (!(await handle.stat()).isFile()) {
274
+ throw new ProjectInitializationError("conflict", "An initialization file path is not a regular file.");
275
+ }
276
+ await handle.writeFile(content, "utf8");
277
+ }
278
+ catch (error) {
279
+ if (error.code === "ELOOP") {
280
+ throw new ProjectInitializationError("unsafe-path", "Initialization paths must not contain symbolic links.", {
281
+ cause: error,
282
+ });
283
+ }
284
+ throw error;
285
+ }
286
+ finally {
287
+ await handle?.close();
288
+ }
289
+ }
290
+ async function ensureProjectLunaControlIgnore(root, createdFiles = [], modifiedFiles = [], createdDirectories = []) {
291
+ const relativePath = LUNA_FABRIC_CONTROL_IGNORE;
292
+ const present = await assertSafeExistingPath(root, relativePath, true);
293
+ if (!present) {
294
+ await ensureDirectory(root, OPEN_CODE_DIRECTORY, createdDirectories);
295
+ await createFile(resolve(root, relativePath), `${LUNA_FABRIC_CONTROL_FILE}\n`, createdFiles);
296
+ return;
297
+ }
298
+ const current = await readFile(resolve(root, relativePath));
299
+ const text = current.toString("utf8");
300
+ if (new RegExp(`(?:^|\\r?\\n)${LUNA_FABRIC_CONTROL_FILE.replace(".", "\\.")}(?:\\r?\\n|$)`, "u").test(text))
301
+ return;
302
+ modifiedFiles.push({ relativePath, content: current });
303
+ const separator = text.endsWith("\n") ? "" : "\n";
304
+ await appendFileSafely(root, relativePath, `${separator}${LUNA_FABRIC_CONTROL_FILE}\n`);
305
+ }
266
306
  const PROJECT_LAYOUT = {
267
307
  assetPrefix: OPEN_CODE_DIRECTORY,
268
308
  markerPath: VERSION_MARKER,
@@ -313,6 +353,8 @@ async function initializeRoot(requestedRoot, layout) {
313
353
  const matches = (entry, index) => existing[index]?.equals(Buffer.from(entry.content)) ?? false;
314
354
  const assetsMatch = assetEntries.every(matches);
315
355
  if (markerText !== undefined && parseMarker(markerText.toString("utf8")) === version && assetsMatch) {
356
+ if (!layout.preserveAllLegacy)
357
+ await ensureProjectLunaControlIgnore(root);
316
358
  return {
317
359
  status: "unchanged",
318
360
  version,
@@ -350,6 +392,9 @@ async function initializeRoot(requestedRoot, layout) {
350
392
  const removedFiles = [];
351
393
  const createdDirectories = [];
352
394
  try {
395
+ if (!layout.preserveAllLegacy) {
396
+ await ensureProjectLunaControlIgnore(root, createdFiles, modifiedFiles, createdDirectories);
397
+ }
353
398
  for (let index = 0; index < entries.length; index += 1) {
354
399
  const entry = entries[index];
355
400
  if (matches(entry, index))
@@ -0,0 +1,54 @@
1
+ export declare const LUNA_FABRIC_CONTRACT_VERSION = "0.8.0";
2
+ export type LunaFabricExternalEffect = "release" | "publish" | "tag" | "version" | "remote-state" | "credential" | "irreversible";
3
+ export interface LunaFabricProvenance {
4
+ readonly source: "dog-coordinator";
5
+ readonly acceptance_fingerprint: string;
6
+ readonly target_branch: string;
7
+ readonly target_sha: string;
8
+ }
9
+ export interface LunaFabricValidation {
10
+ readonly level: "targeted" | "full";
11
+ readonly command: readonly string[];
12
+ }
13
+ export interface LunaFabricUnit {
14
+ readonly unit_id: string;
15
+ readonly acceptance_items: readonly string[];
16
+ readonly scope_read: readonly string[];
17
+ readonly scope_write: readonly string[];
18
+ readonly depends_on: readonly string[];
19
+ readonly validation: LunaFabricValidation;
20
+ readonly shared_path_keys: readonly string[];
21
+ readonly exclusive_resources: readonly string[];
22
+ readonly scheduler_order: number;
23
+ }
24
+ export interface LunaFabricSharedPath {
25
+ readonly key: string;
26
+ readonly path: string;
27
+ }
28
+ export interface LunaFabricContract {
29
+ readonly version: typeof LUNA_FABRIC_CONTRACT_VERSION;
30
+ readonly provenance: LunaFabricProvenance;
31
+ readonly acceptance_items: readonly string[];
32
+ readonly effects: readonly LunaFabricExternalEffect[];
33
+ readonly shared_paths: readonly LunaFabricSharedPath[];
34
+ readonly units: readonly LunaFabricUnit[];
35
+ }
36
+ export type LunaFabricSolReason = "malformed-contract" | "external-effect" | "fewer-than-two-units" | "invalid-scope" | "dependency-invalid" | "acceptance-unowned" | "shared-path-unowned" | "exclusive-resource-conflict" | "no-safe-parallel-width";
37
+ export interface LunaFabricDiagnostic {
38
+ readonly code: LunaFabricSolReason;
39
+ readonly pointer: string;
40
+ readonly detail: string;
41
+ }
42
+ export type LunaFabricAdmission = {
43
+ readonly route: "luna-fabric";
44
+ readonly contract: LunaFabricContract;
45
+ readonly contract_fingerprint: string;
46
+ readonly width: number;
47
+ readonly depth: number;
48
+ } | {
49
+ readonly route: "sol-serial";
50
+ readonly reason: LunaFabricSolReason;
51
+ readonly diagnostics: readonly LunaFabricDiagnostic[];
52
+ };
53
+ /** Pure automatic route decision. Unknown or unsafe input always resolves to the stable Sol route. */
54
+ export declare function admitLunaFabric(value: unknown): LunaFabricAdmission;
@@ -0,0 +1,286 @@
1
+ import { createHash } from "node:crypto";
2
+ import { normalizeWorktreeScopePath, worktreeScopesOverlap } from "./worktree-scope.js";
3
+ export const LUNA_FABRIC_CONTRACT_VERSION = "0.8.0";
4
+ const IDS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
5
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
6
+ const FINGERPRINT = /^[a-f0-9]{64}$/u;
7
+ const EFFECTS = new Set([
8
+ "release", "publish", "tag", "version", "remote-state", "credential", "irreversible",
9
+ ]);
10
+ const MAX_UNITS = 64;
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function exactKeys(value, keys) {
15
+ const actual = Object.keys(value).sort();
16
+ const expected = [...keys].sort();
17
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
18
+ }
19
+ function text(value, maximum = 512) {
20
+ return typeof value === "string" && value.length > 0 && value.length <= maximum &&
21
+ !/[\u0000-\u001f\u007f]/u.test(value);
22
+ }
23
+ function stringList(value, maximum = 256) {
24
+ return Array.isArray(value) && value.length <= maximum && value.every((entry) => text(entry));
25
+ }
26
+ function unique(values) {
27
+ return new Set(values).size === values.length;
28
+ }
29
+ function exactPath(value) {
30
+ if (!text(value))
31
+ return false;
32
+ try {
33
+ return normalizeWorktreeScopePath(value) === value;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ function canonical(value) {
40
+ if (Array.isArray(value))
41
+ return value.map(canonical);
42
+ if (!isRecord(value))
43
+ return value;
44
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
45
+ }
46
+ function contractFingerprint(contract) {
47
+ return createHash("sha256").update(JSON.stringify(canonical(contract))).digest("hex");
48
+ }
49
+ function malformed(pointer, detail) {
50
+ return {
51
+ route: "sol-serial",
52
+ reason: "malformed-contract",
53
+ diagnostics: [{ code: "malformed-contract", pointer, detail }],
54
+ };
55
+ }
56
+ function parseContract(value) {
57
+ if (!isRecord(value) || !exactKeys(value, [
58
+ "acceptance_items", "effects", "provenance", "shared_paths", "units", "version",
59
+ ]))
60
+ return malformed("/", "contract shape must contain only the closed v0.8 fields");
61
+ if (value.version !== LUNA_FABRIC_CONTRACT_VERSION)
62
+ return malformed("/version", "unsupported version");
63
+ if (!isRecord(value.provenance) || !exactKeys(value.provenance, [
64
+ "acceptance_fingerprint", "source", "target_branch", "target_sha",
65
+ ]) || value.provenance.source !== "dog-coordinator" ||
66
+ typeof value.provenance.acceptance_fingerprint !== "string" ||
67
+ !FINGERPRINT.test(value.provenance.acceptance_fingerprint) || !text(value.provenance.target_branch, 256) ||
68
+ typeof value.provenance.target_sha !== "string" || !SHA.test(value.provenance.target_sha)) {
69
+ return malformed("/provenance", "provenance must identify dog-coordinator, acceptance, and exact target");
70
+ }
71
+ if (!stringList(value.acceptance_items) || value.acceptance_items.length === 0 ||
72
+ !unique(value.acceptance_items))
73
+ return malformed("/acceptance_items", "acceptance items must be unique");
74
+ if (!Array.isArray(value.effects) || !unique(value.effects) ||
75
+ value.effects.some((effect) => typeof effect !== "string" || !EFFECTS.has(effect))) {
76
+ return malformed("/effects", "effects must use known classifications");
77
+ }
78
+ if (!Array.isArray(value.shared_paths) || value.shared_paths.length > 256) {
79
+ return malformed("/shared_paths", "shared path ownership must be bounded");
80
+ }
81
+ const sharedPaths = [];
82
+ const sharedKeys = new Set();
83
+ const sharedPathIdentities = new Set();
84
+ for (let index = 0; index < value.shared_paths.length; index += 1) {
85
+ const entry = value.shared_paths[index];
86
+ if (isRecord(entry) && typeof entry.path === "string" && !exactPath(entry.path)) {
87
+ return reject("invalid-scope", `/shared_paths/${index}/path`, "shared path must be normalized repository-relative");
88
+ }
89
+ if (!isRecord(entry) || !exactKeys(entry, ["key", "path"]) || !text(entry.key, 128) ||
90
+ !IDS.test(entry.key) || !exactPath(entry.path) || sharedKeys.has(entry.key) ||
91
+ sharedPathIdentities.has(entry.path.toLowerCase())) {
92
+ return malformed(`/shared_paths/${index}`, "shared path key and exact path must be unique");
93
+ }
94
+ sharedKeys.add(entry.key);
95
+ sharedPathIdentities.add(entry.path.toLowerCase());
96
+ sharedPaths.push({ key: entry.key, path: entry.path });
97
+ }
98
+ if (!Array.isArray(value.units) || value.units.length === 0 || value.units.length > MAX_UNITS) {
99
+ return malformed("/units", "unit count must be bounded");
100
+ }
101
+ const units = [];
102
+ const unitIDs = new Set();
103
+ const schedulerOrders = new Set();
104
+ for (let index = 0; index < value.units.length; index += 1) {
105
+ const unit = value.units[index];
106
+ if (isRecord(unit)) {
107
+ for (const field of ["scope_read", "scope_write"]) {
108
+ if (Array.isArray(unit[field]) && unit[field].some((path) => typeof path === "string" && !exactPath(path))) {
109
+ return reject("invalid-scope", `/units/${index}/${field}`, "scope must use normalized repository-relative paths");
110
+ }
111
+ }
112
+ }
113
+ if (!isRecord(unit) || !exactKeys(unit, [
114
+ "acceptance_items", "depends_on", "exclusive_resources", "scheduler_order", "scope_read",
115
+ "scope_write", "shared_path_keys", "unit_id", "validation",
116
+ ]) || !text(unit.unit_id, 128) || !IDS.test(unit.unit_id) || unitIDs.has(unit.unit_id) ||
117
+ !stringList(unit.acceptance_items) || unit.acceptance_items.length === 0 || !unique(unit.acceptance_items) ||
118
+ !stringList(unit.depends_on, MAX_UNITS) || !unique(unit.depends_on) ||
119
+ !stringList(unit.scope_read) || !unique(unit.scope_read) || !unit.scope_read.every(exactPath) ||
120
+ !stringList(unit.scope_write) || unit.scope_write.length === 0 || !unique(unit.scope_write) ||
121
+ !unit.scope_write.every(exactPath) || !stringList(unit.shared_path_keys) || !unique(unit.shared_path_keys) ||
122
+ !unit.shared_path_keys.every((key) => sharedKeys.has(key)) || !stringList(unit.exclusive_resources, 64) ||
123
+ !unique(unit.exclusive_resources) || !unit.exclusive_resources.every((resource) => IDS.test(resource)) ||
124
+ !Number.isInteger(unit.scheduler_order) || unit.scheduler_order < 0 ||
125
+ schedulerOrders.has(unit.scheduler_order) || !isRecord(unit.validation) ||
126
+ !exactKeys(unit.validation, ["command", "level"]) ||
127
+ (unit.validation.level !== "targeted" && unit.validation.level !== "full") ||
128
+ !Array.isArray(unit.validation.command) || unit.validation.command.length === 0 ||
129
+ unit.validation.command.length > 129 || !unit.validation.command.every((part) => text(part, 1000))) {
130
+ return malformed(`/units/${index}`, "unit identity, scope, ownership, resources, and validation must be exact");
131
+ }
132
+ unitIDs.add(unit.unit_id);
133
+ schedulerOrders.add(unit.scheduler_order);
134
+ units.push({
135
+ unit_id: unit.unit_id,
136
+ acceptance_items: Object.freeze([...unit.acceptance_items]),
137
+ scope_read: Object.freeze([...unit.scope_read]),
138
+ scope_write: Object.freeze([...unit.scope_write]),
139
+ depends_on: Object.freeze([...unit.depends_on]),
140
+ validation: Object.freeze({
141
+ level: unit.validation.level,
142
+ command: Object.freeze([...unit.validation.command]),
143
+ }),
144
+ shared_path_keys: Object.freeze([...unit.shared_path_keys]),
145
+ exclusive_resources: Object.freeze([...unit.exclusive_resources]),
146
+ scheduler_order: unit.scheduler_order,
147
+ });
148
+ }
149
+ return Object.freeze({
150
+ version: LUNA_FABRIC_CONTRACT_VERSION,
151
+ provenance: Object.freeze(value.provenance),
152
+ acceptance_items: Object.freeze([...value.acceptance_items]),
153
+ effects: Object.freeze([...value.effects]),
154
+ shared_paths: Object.freeze(sharedPaths.map((entry) => Object.freeze(entry))),
155
+ units: Object.freeze(units.map((unit) => Object.freeze(unit))),
156
+ });
157
+ }
158
+ function dependencyClosure(units) {
159
+ const byID = new Map(units.map((unit) => [unit.unit_id, unit]));
160
+ if (units.some((unit) => unit.depends_on.some((dependency) => dependency === unit.unit_id || !byID.has(dependency)))) {
161
+ return undefined;
162
+ }
163
+ const closure = new Map();
164
+ const visiting = new Set();
165
+ const visit = (unitID) => {
166
+ if (visiting.has(unitID))
167
+ return undefined;
168
+ const cached = closure.get(unitID);
169
+ if (cached !== undefined)
170
+ return cached;
171
+ visiting.add(unitID);
172
+ const result = new Set();
173
+ for (const dependency of byID.get(unitID).depends_on) {
174
+ const nested = visit(dependency);
175
+ if (nested === undefined)
176
+ return undefined;
177
+ result.add(dependency);
178
+ for (const ancestor of nested)
179
+ result.add(ancestor);
180
+ }
181
+ visiting.delete(unitID);
182
+ closure.set(unitID, result);
183
+ return result;
184
+ };
185
+ for (const unit of units)
186
+ if (visit(unit.unit_id) === undefined)
187
+ return undefined;
188
+ return closure;
189
+ }
190
+ function writeRelatedOverlap(left, right) {
191
+ return left.scope_write.some((path) => [...right.scope_write, ...right.scope_read]
192
+ .some((other) => worktreeScopesOverlap(path, other))) ||
193
+ right.scope_write.some((path) => left.scope_read.some((other) => worktreeScopesOverlap(path, other)));
194
+ }
195
+ function sharedOwnerCovers(left, right, sharedPaths) {
196
+ const common = new Set(left.shared_path_keys.filter((key) => right.shared_path_keys.includes(key)));
197
+ return sharedPaths.some((entry) => common.has(entry.key) &&
198
+ [...left.scope_write, ...left.scope_read].some((path) => worktreeScopesOverlap(entry.path, path)) &&
199
+ [...right.scope_write, ...right.scope_read].some((path) => worktreeScopesOverlap(entry.path, path)));
200
+ }
201
+ function incomparable(left, right, closure) {
202
+ return !closure.get(left.unit_id).has(right.unit_id) && !closure.get(right.unit_id).has(left.unit_id);
203
+ }
204
+ function maximumSafeWidth(units, closure) {
205
+ let maximum = 1;
206
+ for (let leftIndex = 0; leftIndex < units.length; leftIndex += 1) {
207
+ const selected = [units[leftIndex]];
208
+ for (let rightIndex = leftIndex + 1; rightIndex < units.length; rightIndex += 1) {
209
+ const candidate = units[rightIndex];
210
+ if (selected.every((existing) => incomparable(existing, candidate, closure) &&
211
+ !writeRelatedOverlap(existing, candidate) &&
212
+ !existing.exclusive_resources.some((resource) => candidate.exclusive_resources.includes(resource)))) {
213
+ selected.push(candidate);
214
+ }
215
+ }
216
+ maximum = Math.max(maximum, selected.length);
217
+ }
218
+ return maximum;
219
+ }
220
+ function dagDepth(units) {
221
+ const byID = new Map(units.map((unit) => [unit.unit_id, unit]));
222
+ const depths = new Map();
223
+ const depth = (unitID) => {
224
+ const cached = depths.get(unitID);
225
+ if (cached !== undefined)
226
+ return cached;
227
+ const dependencies = byID.get(unitID).depends_on;
228
+ const value = dependencies.length === 0 ? 1 : 1 + Math.max(...dependencies.map(depth));
229
+ depths.set(unitID, value);
230
+ return value;
231
+ };
232
+ return Math.max(...units.map((unit) => depth(unit.unit_id)));
233
+ }
234
+ function reject(reason, pointer, detail) {
235
+ return { route: "sol-serial", reason, diagnostics: [{ code: reason, pointer, detail }] };
236
+ }
237
+ /** Pure automatic route decision. Unknown or unsafe input always resolves to the stable Sol route. */
238
+ export function admitLunaFabric(value) {
239
+ const parsed = parseContract(value);
240
+ if ("route" in parsed)
241
+ return parsed;
242
+ if (parsed.effects.length > 0)
243
+ return reject("external-effect", "/effects", parsed.effects.join(","));
244
+ if (parsed.units.length < 2)
245
+ return reject("fewer-than-two-units", "/units", "fabric needs useful width");
246
+ const closure = dependencyClosure(parsed.units);
247
+ if (closure === undefined)
248
+ return reject("dependency-invalid", "/units", "dependency is unknown or cyclic");
249
+ const accepted = new Map(parsed.acceptance_items.map((item) => [item, 0]));
250
+ for (const unit of parsed.units) {
251
+ for (const item of unit.acceptance_items) {
252
+ if (!accepted.has(item))
253
+ return reject("acceptance-unowned", "/units", `unknown acceptance item: ${item}`);
254
+ accepted.set(item, accepted.get(item) + 1);
255
+ }
256
+ }
257
+ const invalidAcceptance = [...accepted].find(([, count]) => count !== 1);
258
+ if (invalidAcceptance !== undefined) {
259
+ return reject("acceptance-unowned", "/acceptance_items", `${invalidAcceptance[0]} ownership=${invalidAcceptance[1]}`);
260
+ }
261
+ for (let leftIndex = 0; leftIndex < parsed.units.length; leftIndex += 1) {
262
+ const left = parsed.units[leftIndex];
263
+ for (let rightIndex = leftIndex + 1; rightIndex < parsed.units.length; rightIndex += 1) {
264
+ const right = parsed.units[rightIndex];
265
+ if (!incomparable(left, right, closure))
266
+ continue;
267
+ if (writeRelatedOverlap(left, right) && !sharedOwnerCovers(left, right, parsed.shared_paths)) {
268
+ return reject("shared-path-unowned", `/units/${rightIndex}/scope_write`, `${left.unit_id}:${right.unit_id}`);
269
+ }
270
+ const resource = left.exclusive_resources.find((entry) => right.exclusive_resources.includes(entry));
271
+ if (resource !== undefined) {
272
+ return reject("exclusive-resource-conflict", `/units/${rightIndex}/exclusive_resources`, resource);
273
+ }
274
+ }
275
+ }
276
+ const width = maximumSafeWidth(parsed.units, closure);
277
+ if (width < 2)
278
+ return reject("no-safe-parallel-width", "/units", "no independent disjoint pair");
279
+ return Object.freeze({
280
+ route: "luna-fabric",
281
+ contract: parsed,
282
+ contract_fingerprint: contractFingerprint(parsed),
283
+ width,
284
+ depth: dagDepth(parsed.units),
285
+ });
286
+ }
@@ -0,0 +1,31 @@
1
+ import type { LunaFabricContract } from "./luna-fabric-contract.js";
2
+ export declare const LUNA_FABRIC_MAX_ACTIVE = 5;
3
+ export interface LunaFabricWave {
4
+ readonly number: number;
5
+ readonly base_sha: string;
6
+ readonly unit_ids: readonly string[];
7
+ readonly lanes: Readonly<Record<string, number>>;
8
+ }
9
+ export interface LunaFabricSchedulerState {
10
+ readonly wave: number;
11
+ readonly base_sha: string;
12
+ readonly pending: readonly string[];
13
+ readonly completed: readonly string[];
14
+ readonly active: LunaFabricWave | null;
15
+ readonly lane_affinity: Readonly<Record<string, number>>;
16
+ }
17
+ export interface LunaFabricAdvance {
18
+ readonly candidate_base: string;
19
+ readonly completed_unit_ids: readonly string[];
20
+ }
21
+ export declare class LunaFabricScheduler {
22
+ private readonly contract;
23
+ private readonly byID;
24
+ constructor(contract: LunaFabricContract, base_sha: string, state?: LunaFabricSchedulerState);
25
+ private state;
26
+ snapshot(): LunaFabricSchedulerState;
27
+ nextWave(): LunaFabricWave | null;
28
+ advance(advancement: LunaFabricAdvance): LunaFabricSchedulerState;
29
+ private validateState;
30
+ }
31
+ export declare function createLunaFabricScheduler(contract: LunaFabricContract, base_sha: string, state?: LunaFabricSchedulerState): LunaFabricScheduler;