@kontextmind/kxm 0.7.128 → 0.7.131

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/README.md +4 -1
  3. package/.kxm/agents/coordinator.yaml +1 -0
  4. package/.kxm/agents/critic-arch.yaml +1 -4
  5. package/.kxm/agents/critic-cli.yaml +1 -4
  6. package/.kxm/agents/implementer.yaml +1 -4
  7. package/.kxm/workflows/implement-only.yaml +1 -1
  8. package/.kxm/workflows/review-arch-only.yaml +1 -1
  9. package/.kxm/workflows/review-cli-only.yaml +1 -1
  10. package/CHANGELOG.md +42 -1
  11. package/docs/architecture/inventory.md +1 -1
  12. package/docs/concepts/architecture.md +1 -1
  13. package/docs/concepts/data-and-storage.md +1 -1
  14. package/docs/contracts/README.md +1 -1
  15. package/docs/contracts/migration.md +12 -8
  16. package/docs/contracts/routing.md +3 -3
  17. package/docs/contributing/assignment-runner.md +14 -15
  18. package/docs/contributing/development.md +22 -6
  19. package/docs/contributing/harness-routing-internals.md +8 -8
  20. package/docs/glossary.md +1 -1
  21. package/docs/guides/agent-skills.md +1 -1
  22. package/docs/operations/backup-and-restore.md +7 -7
  23. package/docs/operations/upgrade.md +2 -2
  24. package/docs/reference/cli-reference.md +37 -14
  25. package/docs/reference/config-reference.md +81 -155
  26. package/docs/reference/harness-routing.md +29 -21
  27. package/docs/reference/workflow-catalog.md +4 -4
  28. package/docs/start/first-workflow.md +1 -1
  29. package/examples/project/.kxm/agents/coordinator.yaml +1 -2
  30. package/examples/project/.kxm/agents/critic-1.yaml +1 -3
  31. package/examples/project/.kxm/agents/critic-2.yaml +1 -2
  32. package/examples/project/.kxm/agents/critic-3.yaml +2 -3
  33. package/examples/project/.kxm/agents/implementer.yaml +1 -2
  34. package/examples/project/.kxm/agents/planner.yaml +1 -2
  35. package/examples/project/.kxm/agents/reproducer.yaml +1 -2
  36. package/examples/project/.kxm/agents/reviewer.yaml +1 -2
  37. package/examples/project/.kxm/workflows/fix.yaml +0 -12
  38. package/package.json +8 -8
  39. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  40. package/plugins/kxm/dist/cli.js +2565 -465
  41. package/plugins/kxm/dist/mcp-server.js +1 -1
  42. package/plugins/kxm/dist/runtime-supervisor.js +683 -348
  43. package/plugins/kxm/dist/runtime.js +694 -358
  44. package/plugins/kxm/dist/server.js +17 -14
  45. package/plugins/kxm/package.json +1 -1
  46. package/plugins/kxm/skills/kxm/SKILL.md +1 -1
  47. package/plugins/kxm/skills/kxm-definitions/SKILL.md +8 -0
  48. package/plugins/kxm/src/cli/lanes.ts +109 -4
  49. package/plugins/kxm/src/cli/project.ts +6 -6
  50. package/plugins/kxm/src/database.ts +25 -19
  51. package/plugins/kxm/src/engine.ts +168 -163
  52. package/plugins/kxm/src/init-guide-setup.ts +75 -15
  53. package/plugins/kxm/src/mcp-server.ts +1 -1
  54. package/plugins/kxm/src/oneshot-producer.ts +3 -15
  55. package/plugins/kxm/src/project-config.ts +83 -70
  56. package/plugins/kxm/src/routes.ts +26 -18
  57. package/plugins/kxm/src/runtime-service.ts +1 -0
  58. package/plugins/kxm/src/runtime-store.ts +390 -77
  59. package/plugins/kxm/src/runtime-supervisor.ts +87 -21
  60. package/plugins/kxm/src/suggest.ts +2 -2
  61. package/plugins/kxm/src/template.ts +2 -14
  62. package/schemas/agent.schema.json +8 -0
  63. package/scripts/roster-policy.d.mts +2 -1
  64. package/scripts/roster-policy.mjs +119 -22
  65. package/scripts/run-bounded.mjs +53 -0
@@ -6,13 +6,13 @@ import { dirname, isAbsolute, join } from "node:path";
6
6
  import { findKxmRepoRoot } from "./repo-root.ts";
7
7
  import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project-config.ts";
8
8
  import {
9
+ KxmRunEventStore,
9
10
  KxmRuntimeRegistry,
10
11
  projectRuntimeKey,
11
12
  readKxmSupervisorRecord,
12
13
  runtimeError,
13
14
  verifyKxmDriveReceipt,
14
15
  kxmRuntimePaths,
15
- type KxmRunEventStore,
16
16
  type KxmRuntimePaths,
17
17
  type KxmSupervisorRecord,
18
18
  } from "./runtime-store.ts";
@@ -28,7 +28,6 @@ import {
28
28
  type KxmRuntimeContext,
29
29
  } from "./runtime-service.ts";
30
30
  import { createKxmOneShotProducer } from "./oneshot-producer.ts";
31
- import { isRouteAdmitted } from "./routes.ts";
32
31
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
33
32
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
34
33
  import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
@@ -126,6 +125,28 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
126
125
  }
127
126
  }
128
127
 
128
+ const SETTLED_RUN_STATUS = new Set(["completed", "failed", "cancelled"]);
129
+
130
+ /** Same liveness rule as `kxmSupervisorStatus`: running, a fresh heartbeat, and a live pid. */
131
+ export function kxmSupervisorRecordIsLive(record: KxmSupervisorRecord | undefined): boolean {
132
+ return supervisorStatusOf(record).running;
133
+ }
134
+
135
+ function unsettledRunIds(context: KxmRuntimeContext): string[] {
136
+ const ids: string[] = [];
137
+ for (const stored of context.eventStore.runsForProject(context.projectId, 10_000)) {
138
+ let status: string = stored.status;
139
+ try {
140
+ status = projectKxmRunReadOnly(context, stored.runId).status;
141
+ } catch {
142
+ ids.push(stored.runId);
143
+ continue;
144
+ }
145
+ if (!SETTLED_RUN_STATUS.has(status)) ids.push(stored.runId);
146
+ }
147
+ return ids;
148
+ }
149
+
129
150
  function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
130
151
  if (!record) return { running: false };
131
152
  // Pid-only liveness is not enough: after a crash the pid may be reused by
@@ -781,6 +802,65 @@ async function startKxmRuntimeSupervisorInner(
781
802
  return;
782
803
  }
783
804
 
805
+ if (request.method === "POST" && url.pathname === "/v1/projects/unregister") {
806
+ const body = await readJsonBody(request);
807
+ const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
808
+ const force = body.force === true;
809
+ if (!projectRoot || !isAbsolute(projectRoot)) {
810
+ sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot must be an absolute path" });
811
+ return;
812
+ }
813
+ if (!force) {
814
+ const key = projectRuntimeKey(projectRoot);
815
+ const existing = contexts.get(key);
816
+ let openRunIds: string[] = [];
817
+ if (existing) {
818
+ openRunIds = unsettledRunIds(existing);
819
+ } else {
820
+ const registration = registry.projectByRoot(projectRoot);
821
+ const eventsPath = join(paths.projectsDir, registration?.projectKey ?? projectRuntimeKey(projectRoot), "run-events.db");
822
+ if (registration && existsSync(eventsPath)) {
823
+ const store = new KxmRunEventStore(eventsPath);
824
+ try {
825
+ openRunIds = store.runsForProject(registration.projectId, 10_000)
826
+ .filter((run) => !SETTLED_RUN_STATUS.has(run.status))
827
+ .map((run) => run.runId);
828
+ } finally {
829
+ store.close();
830
+ }
831
+ }
832
+ }
833
+ if (openRunIds.length > 0) {
834
+ sendJson(response, 409, {
835
+ ok: false,
836
+ error: "runtime_project_busy",
837
+ message: `unsettled run ${openRunIds.join(", ")}`,
838
+ runIds: openRunIds,
839
+ });
840
+ return;
841
+ }
842
+ const laneCount = registry.homeLaneCount(projectRoot);
843
+ if (laneCount > 0) {
844
+ sendJson(response, 409, {
845
+ ok: false,
846
+ error: "runtime_project_has_lanes",
847
+ message: `home root still has ${laneCount} lane(s)`,
848
+ });
849
+ return;
850
+ }
851
+ }
852
+ const key = projectRuntimeKey(projectRoot);
853
+ const existing = contexts.get(key);
854
+ const unregistered = registry.unregisterProject(projectRoot);
855
+ if (existing) {
856
+ contexts.delete(key);
857
+ syncStatuses.delete(key);
858
+ closeKxmRuntimeContext(existing);
859
+ }
860
+ sendJson(response, 200, { ok: true, unregistered });
861
+ return;
862
+ }
863
+
784
864
  if (request.method === "POST" && url.pathname === "/v1/runs") {
785
865
  const body = await readJsonBody(request);
786
866
  const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
@@ -838,23 +918,6 @@ async function startKxmRuntimeSupervisorInner(
838
918
  ? createKxmOneShotProducer({
839
919
  projectRoot,
840
920
  timeoutMs: kxmProjectAdmissionLimits(bundle).agentStepTimeoutMs,
841
- defaultHarness: String(bundle.project.value.defaultHarness ?? "pi"),
842
- resolveHarness: (agentId) => {
843
- const agent = bundle.agents.get(agentId);
844
- return typeof agent?.value.harness === "string" ? agent.value.harness : undefined;
845
- },
846
- resolveModel: (agentId) => {
847
- const agent = bundle.agents.get(agentId);
848
- const model = agent?.value.model;
849
- if (!model || typeof model !== "object" || Array.isArray(model)) return undefined;
850
- const value = model as Record<string, unknown>;
851
- const provider = typeof value.provider === "string" ? value.provider : undefined;
852
- const modelName = typeof value.model === "string" ? value.model : undefined;
853
- if (!provider || !modelName || !isRouteAdmitted(projectRoot, `${provider}/${modelName}`)) {
854
- throw new Error("producer_route_not_admitted");
855
- }
856
- return { provider, model: modelName };
857
- },
858
921
  })
859
922
  : createKxmSimulatedProducer(async () => {
860
923
  if (delayMs > 0) {
@@ -1026,9 +1089,12 @@ async function startKxmRuntimeSupervisorInner(
1026
1089
  // that returned raw rows would let every consumer — including the portal's
1027
1090
  // tenant read — present cached status as authoritative. Folding replays each
1028
1091
  // run's events; workflows are transition-bounded, so this stays cheap at the
1029
- // 50-run cap. A run that refuses to fold is returned with its cached row plus
1092
+ // default 50-run cap (`limit` may ask for up to 10000). A run that refuses to fold is returned with its cached row plus
1030
1093
  // `projectionError`, so one corrupt run cannot make the listing lie by omission.
1031
- const runs = context.eventStore.runsForProject(requestedProjectId, 50).map((stored) => {
1094
+ const limitText = url.searchParams.get("limit");
1095
+ const parsedLimit = limitText === null ? 50 : Number(limitText);
1096
+ const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10_000) : 50;
1097
+ const runs = context.eventStore.runsForProject(requestedProjectId, limit).map((stored) => {
1032
1098
  try {
1033
1099
  return projectKxmRunReadOnly(context, stored.runId);
1034
1100
  } catch (error) {
@@ -191,10 +191,10 @@ export function suggestWorkflowAndRoles(
191
191
  supported: true,
192
192
  prerequisites: [
193
193
  "Run kxm init in the target repository if needed, then install the exact template with the suggested command. If that workflow ID already exists, stop and review it; the commands below apply only to a newly installed template, not an existing definition with potentially different agents or permissions.",
194
- ...roles.map((role) => `Set harness: ${role.harness} and model: <your authenticated compatible model selector> in .kxm/agents/${role.agent}.yaml. Admit that exact selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
194
+ ...roles.map((role) => `Set role on .kxm/agents/${role.agent}.yaml to a role whose roster names an admitted route for harness ${role.harness}. Admit that route's selector in .kxm/routes.yaml and ensure it is not disabled; this recommendation does not choose a model or change routing.`),
195
195
  ...(writeStep ? [
196
196
  `Keep each writer step at assignments.maximum: 1 and set limits.maxConcurrentRuns: 1 in .kxm/project.yaml; the live ${selected.id} writer profile requires a lone writer in the checkout.`,
197
- `If .kxm/roster.yaml exists, it must be readable kxm.developer-roster.v1 with a lineup.writer route matching harness: ${selected.id} and the selected writer model (model or vendor/model), status: admitted, and permissions containing edit.`,
197
+ `The writer role roster must name a route whose harness is ${selected.id}, whose status is admitted, and whose permissions contain edit.`,
198
198
  "Configure the test gate in .kxm/gates.yaml with kind: command and argv for this repository's actual verification command. A scaffold/example command or simulation is not evidence that the implementation works.",
199
199
  ] : []),
200
200
  `Validate the installed workflow with kxm gate validate --file .kxm/workflows/${bestPattern.id}.yaml and the project configuration with kxm init --dry-run before creating a run.`,
@@ -175,21 +175,9 @@ function coreTemplate(projectId: string, projectName: string, variant: KxmTempla
175
175
  ]);
176
176
  if (variant === "v4-registry") {
177
177
  const coordinator = files.get(".kxm/agents/coordinator.yaml");
178
- if (coordinator) {
179
- files.set(".kxm/agents/coordinator.yaml", {
180
- ...coordinator,
181
- harness: "claude",
182
- model: { provider: "anthropic", model: "fable" },
183
- });
184
- }
178
+ if (coordinator) files.set(".kxm/agents/coordinator.yaml", { ...coordinator, role: "planner" });
185
179
  const implementer = files.get(".kxm/agents/implementer.yaml");
186
- if (implementer) {
187
- files.set(".kxm/agents/implementer.yaml", {
188
- ...implementer,
189
- harness: "grok",
190
- model: { provider: "xai", model: "grok-4.6" },
191
- });
192
- }
180
+ if (implementer) files.set(".kxm/agents/implementer.yaml", { ...implementer, role: "writer" });
193
181
  const workflow = files.get(".kxm/workflows/default.yaml");
194
182
  if (workflow) {
195
183
  const limits = { ...(workflow.limits as JsonObject) };
@@ -12,6 +12,14 @@
12
12
  "model": { "$ref": "common.schema.json#/$defs/modelSelector" },
13
13
  "executor": { "$ref": "common.schema.json#/$defs/identifier" },
14
14
  "harness": { "$ref": "common.schema.json#/$defs/identifier" },
15
+ "role": { "$ref": "common.schema.json#/$defs/identifier" },
16
+ "effort": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] },
17
+ "skills": {
18
+ "type": "array",
19
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
20
+ "maxItems": 64,
21
+ "uniqueItems": true
22
+ },
15
23
  "tools": { "$ref": "common.schema.json#/$defs/toolPolicy" },
16
24
  "defaultRepositoryAccess": { "$ref": "common.schema.json#/$defs/repositoryAccess" },
17
25
  "repositories": {
@@ -7,7 +7,6 @@ export interface RosterRoute {
7
7
  readonly status: 'admitted' | 'retired';
8
8
  }
9
9
  export interface RosterPolicy {
10
- readonly schema: 'kxm.developer-roster.v1';
11
10
  readonly routes: Readonly<Record<string, RosterRoute>>;
12
11
  readonly lineup: Readonly<Record<string, readonly string[]>>;
13
12
  readonly required_critics: Readonly<Record<'review-arch' | 'review-cli', string>>;
@@ -15,9 +14,11 @@ export interface RosterPolicy {
15
14
  }
16
15
  export interface PolicyIdentity { readonly commit: string; readonly blob: string; readonly sha256: string }
17
16
  export interface LoadedPolicy { readonly identity: PolicyIdentity; readonly policy: RosterPolicy }
17
+ export function refuseRetiredRosterFile(root: string): void;
18
18
  export function loadTrustedRosterPolicy(): LoadedPolicy;
19
19
  export function resolveBoundPolicy(identity: PolicyIdentity): LoadedPolicy;
20
20
  export function canonicalVendor(value: string): string;
21
+ export function assembleRosterPolicy(models: readonly Record<string, unknown>[], roles: readonly Record<string, unknown>[]): RosterPolicy;
21
22
  export function validateRosterDocument(
22
23
  policy: RosterPolicy,
23
24
  readBlob: (source: string) => string | Uint8Array | null | undefined,
@@ -8,9 +8,11 @@ import YAML from 'yaml';
8
8
  import { NATIVE_PI_BRAKE_PROVIDERS, PI_ALLOWED_PROVIDERS, PI_ANTIGRAVITY_MODEL_ID, PI_NATIVE_VENDOR_PROVIDERS, ROUTES } from './harness-run.mjs';
9
9
 
10
10
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
- const POLICY = '.kxm/roster.yaml';
12
- const RETIRED_POLICY = '.kxm/roster.json';
11
+ const MODEL_DIR = '.kxm/models';
12
+ const ROLE_DIR = '.kxm/roles';
13
+ const RETIRED_ROSTER_FILES = ['.kxm/roster.json', '.kxm/roster.yaml'];
13
14
  const TRUSTED = 'refs/remotes/origin/main';
15
+ const SKIP_MODELS = new Set(['inventory.yaml']);
14
16
  const ROLES = ['writer', 'planner', 'reviewer-arch', 'reviewer-cli', 'experiment'];
15
17
  const ALIASES = Object.freeze({ 'x-ai': 'xai', moonshotai: 'moonshot', 'google-ai': 'google', qwen: 'alibaba' });
16
18
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
@@ -55,14 +57,19 @@ function frozen(value) {
55
57
  if (value && typeof value === 'object') { for (const child of Object.values(value)) frozen(child); Object.freeze(value); }
56
58
  return value;
57
59
  }
60
+ export function refuseRetiredRosterFile(root) {
61
+ for (const retired of RETIRED_ROSTER_FILES) {
62
+ if (existsSync(path.join(root, retired))) refuse(`retired_roster_file: retired ${retired} present; use ${MODEL_DIR} and ${ROLE_DIR}`);
63
+ }
64
+ }
58
65
  function control() {
59
- if (existsSync(path.join(ROOT, RETIRED_POLICY))) refuse(`retired ${RETIRED_POLICY} present; use ${POLICY}`);
66
+ refuseRetiredRosterFile(ROOT);
60
67
  if (realpathSync(gitText('rev-parse', '--show-toplevel')) !== realpathSync(ROOT)) refuse('module is outside its control repository');
61
68
  const head = gitText('rev-parse', '--verify', 'HEAD^{commit}');
62
69
  const trusted = gitText('rev-parse', '--verify', `${TRUSTED}^{commit}`);
63
70
  git('merge-base', '--is-ancestor', head, trusted);
64
71
  const flags = git('ls-files', '-v', '-z').toString('utf8').split('\0').filter(Boolean);
65
- if (flags.some(entry => entry.slice(2) !== POLICY && (entry[0] === 'S' || entry[0] === entry[0].toLowerCase()))) refuse('hidden index flags on control source');
72
+ if (flags.some(entry => entry[0] === 'S' || entry[0] === entry[0].toLowerCase())) refuse('hidden index flags on control source');
66
73
  if (git('status', '--porcelain=v1', '--untracked-files=all').length) refuse('dirty or untracked control source');
67
74
  return { head, trusted };
68
75
  }
@@ -88,8 +95,7 @@ function workingBytes(source) {
88
95
  return readFileSync(current);
89
96
  }
90
97
  export function validateRosterDocument(policy, readBlob) {
91
- keys(policy, ['schema', 'routes', 'lineup', 'required_critics', 'model_origins'], 'policy');
92
- if (policy.schema !== 'kxm.developer-roster.v1') refuse('unsupported schema');
98
+ keys(policy, ['routes', 'lineup', 'required_critics', 'model_origins'], 'policy');
93
99
  record(policy.routes, 'routes'); record(policy.lineup, 'lineup'); record(policy.model_origins, 'model origins');
94
100
  if (!Object.keys(policy.routes).length) refuse('empty routes');
95
101
  if (typeof readBlob !== 'function') refuse('origin evidence reader required');
@@ -162,35 +168,126 @@ export function validateRosterDocument(policy, readBlob) {
162
168
  }
163
169
  return policy;
164
170
  }
165
- function validate(bytes, commit, trusted) {
166
- let policy;
167
- try { policy = YAML.parse(bytes.toString('utf8')); } catch { refuse('invalid policy YAML'); }
168
- if (policy === undefined || policy === null || typeof policy !== 'object' || Array.isArray(policy)) refuse('invalid policy YAML');
171
+ function parsePolicyYaml(bytes, label) {
172
+ let value;
173
+ try { value = YAML.parse(bytes.toString('utf8')); } catch { refuse(`invalid policy YAML (${label})`); }
174
+ if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) refuse(`invalid policy YAML (${label})`);
175
+ return value;
176
+ }
177
+ function listedPolicyFiles(commit) {
178
+ const raw = git('ls-tree', '-r', '-z', commit, '--', MODEL_DIR, ROLE_DIR).toString('utf8');
179
+ const files = [];
180
+ for (const entry of raw.split('\0').filter(Boolean)) {
181
+ const match = /^(100644|100755) blob ([a-f0-9]{40}(?:[a-f0-9]{24})?)\t(.+)$/u.exec(entry);
182
+ if (!match) refuse('source is missing or not a regular committed file');
183
+ const source = match[3];
184
+ if (!source.endsWith('.yaml')) continue;
185
+ const name = source.slice(source.lastIndexOf('/') + 1);
186
+ if (source.startsWith(`${MODEL_DIR}/`) && SKIP_MODELS.has(name)) continue;
187
+ if (source.startsWith(`${MODEL_DIR}/`) || source.startsWith(`${ROLE_DIR}/`)) files.push(source);
188
+ }
189
+ if (!files.some(source => source.startsWith(`${ROLE_DIR}/`)) || !files.some(source => source.startsWith(`${MODEL_DIR}/`))) {
190
+ refuse('source is missing or not a regular committed file');
191
+ }
192
+ return files.sort();
193
+ }
194
+ /** Build the runner policy object from parsed model and role documents. */
195
+ export function assembleRosterPolicy(models, roles) {
196
+ if (!Array.isArray(models) || !Array.isArray(roles)) refuse('invalid policy YAML');
197
+ const lineup = {};
198
+ const rolesByRoute = {};
199
+ for (const role of [...roles].sort((left, right) => String(left?.id).localeCompare(String(right?.id)))) {
200
+ record(role, 'role');
201
+ text(role.id, 'role id');
202
+ if (!Array.isArray(role.roster)) refuse('lineup contains unsupported or duplicate values');
203
+ const ids = [];
204
+ for (const entry of role.roster) {
205
+ record(entry, 'roster entry');
206
+ text(entry.route, 'roster route');
207
+ ids.push(entry.route);
208
+ if (!rolesByRoute[entry.route]) rolesByRoute[entry.route] = [];
209
+ if (!rolesByRoute[entry.route].includes(role.id)) rolesByRoute[entry.route].push(role.id);
210
+ }
211
+ lineup[role.id] = ids;
212
+ }
213
+ const routes = {};
214
+ const model_origins = {};
215
+ for (const doc of models) {
216
+ record(doc, 'model');
217
+ text(doc.id, 'route id');
218
+ if (!rolesByRoute[doc.id]) continue;
219
+ routes[doc.id] = {
220
+ harness: doc.harness,
221
+ model: doc.model,
222
+ vendor: doc.vendor,
223
+ roles: rolesByRoute[doc.id],
224
+ permissions: doc.permissions,
225
+ status: doc.status,
226
+ };
227
+ if (doc.origin !== undefined) {
228
+ record(doc.origin, 'origin');
229
+ const evidence = { source: doc.origin.source, sha256: doc.origin.sha256 };
230
+ if (doc.origin.commit !== undefined) evidence.commit = doc.origin.commit;
231
+ model_origins[doc.model] = { vendor: doc.vendor, evidence };
232
+ }
233
+ }
234
+ return {
235
+ routes,
236
+ lineup,
237
+ required_critics: {
238
+ 'review-arch': lineup['reviewer-arch']?.[0],
239
+ 'review-cli': lineup['reviewer-cli']?.[0],
240
+ },
241
+ model_origins,
242
+ };
243
+ }
244
+ function readPolicySet(commit, checkWorking) {
245
+ const files = listedPolicyFiles(commit);
246
+ const models = [];
247
+ const roles = [];
248
+ const parts = [];
249
+ for (const source of files) {
250
+ const { bytes } = blobAt(commit, source);
251
+ if (checkWorking && !workingBytes(source).equals(bytes)) refuse('working policy differs from committed bytes');
252
+ const doc = parsePolicyYaml(bytes, source);
253
+ if (source.startsWith(`${MODEL_DIR}/`)) models.push(doc);
254
+ else roles.push(doc);
255
+ parts.push({ source, bytes });
256
+ }
257
+ return { models, roles, digest: compositeDigest(parts) };
258
+ }
259
+ function compositeDigest(parts) {
260
+ const hash = createHash('sha256');
261
+ for (const part of [...parts].sort((left, right) => left.source < right.source ? -1 : left.source > right.source ? 1 : 0)) {
262
+ hash.update(part.source);
263
+ hash.update('\0');
264
+ hash.update(part.bytes);
265
+ hash.update('\0');
266
+ }
267
+ return hash.digest('hex');
268
+ }
269
+ function policyAt(commit, trusted, checkWorking) {
270
+ const loaded = readPolicySet(commit, checkWorking);
169
271
  const readBlob = (source, pinnedCommit) => {
170
272
  if (pinnedCommit === undefined) return blobAt(commit, source).bytes;
171
- // A pinned evidence commit decouples the digest from later edits to the
172
- // evidence file; the pinned commit itself must be part of trusted history.
173
273
  git('merge-base', '--is-ancestor', pinnedCommit, trusted);
174
274
  return blobAt(pinnedCommit, source).bytes;
175
275
  };
176
- return validateRosterDocument(policy, readBlob);
276
+ return { policy: validateRosterDocument(assembleRosterPolicy(loaded.models, loaded.roles), readBlob), digest: loaded.digest };
177
277
  }
178
278
  export function loadTrustedRosterPolicy() {
179
279
  const snapshot = control();
180
- const { blob, bytes } = blobAt(snapshot.head, POLICY);
181
- if (!workingBytes(POLICY).equals(bytes)) refuse('working policy differs from committed bytes');
182
- const policy = validate(bytes, snapshot.head, snapshot.trusted);
280
+ const { policy, digest } = policyAt(snapshot.head, snapshot.trusted, true);
183
281
  unchanged(snapshot);
184
- return frozen({ identity: { commit: snapshot.head, blob, sha256: sha256(bytes) }, policy });
282
+ return frozen({ identity: { commit: snapshot.head, blob: digest, sha256: digest }, policy });
185
283
  }
186
284
  export function resolveBoundPolicy(identity) {
187
285
  keys(identity, ['commit', 'blob', 'sha256'], 'identity');
188
- objectId(identity.commit, 'commit'); objectId(identity.blob, 'blob'); digest(identity.sha256);
286
+ objectId(identity.commit, 'commit'); digest(identity.blob); digest(identity.sha256);
189
287
  const snapshot = control();
190
288
  git('merge-base', '--is-ancestor', identity.commit, snapshot.trusted);
191
- const { blob, bytes } = blobAt(identity.commit, POLICY);
192
- if (blob !== identity.blob || sha256(bytes) !== identity.sha256) refuse('bound identity mismatch');
193
- const policy = validate(bytes, identity.commit, snapshot.trusted);
289
+ const loaded = policyAt(identity.commit, snapshot.trusted, false);
290
+ if (loaded.digest !== identity.blob || loaded.digest !== identity.sha256) refuse('bound identity mismatch');
194
291
  unchanged(snapshot);
195
- return frozen({ identity: { ...identity }, policy });
292
+ return frozen({ identity: { ...identity }, policy: loaded.policy });
196
293
  }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Wall-clock guard for a test suite. Node's --test-timeout also bounds each
4
+ // file (each file is a test), so the suite scripts do not pass it. This
5
+ // bounds the process that runs them.
6
+
7
+ import { spawn } from "node:child_process";
8
+
9
+ const timeoutMs = Number(process.argv[2]);
10
+ const command = process.argv[3];
11
+ const args = process.argv.slice(4);
12
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || command === undefined) {
13
+ console.error("usage: run-bounded.mjs <timeout-ms> <command> [args...]");
14
+ process.exit(2);
15
+ }
16
+
17
+ const child = spawn(command, args, { stdio: "inherit", detached: true });
18
+ let timedOut = false;
19
+
20
+ function signalGroup(signal) {
21
+ if (child.pid === undefined) return;
22
+ try {
23
+ process.kill(-child.pid, signal);
24
+ } catch {
25
+ try { child.kill(signal); } catch { /* already gone */ }
26
+ }
27
+ }
28
+
29
+ const timer = setTimeout(() => {
30
+ timedOut = true;
31
+ console.error(`wall clock ${timeoutMs}ms exceeded`);
32
+ signalGroup("SIGTERM");
33
+ setTimeout(() => signalGroup("SIGKILL"), 2000).unref();
34
+ }, timeoutMs);
35
+
36
+ for (const signal of ["SIGINT", "SIGTERM"]) {
37
+ process.on(signal, () => {
38
+ signalGroup(signal);
39
+ });
40
+ }
41
+
42
+ child.on("error", (error) => {
43
+ clearTimeout(timer);
44
+ console.error(error.message);
45
+ process.exit(1);
46
+ });
47
+
48
+ child.on("exit", (code, signal) => {
49
+ clearTimeout(timer);
50
+ if (timedOut) process.exit(124);
51
+ if (signal) process.exit(1);
52
+ process.exit(code ?? 1);
53
+ });