From a7522b4011a81a78b1221a634239df0061776f1b Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:40:17 +0800 Subject: [PATCH 01/28] feat(state): add state class registry v1 --- .../.agent/contracts/state-classes.json | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 templates/_shared/.agent/contracts/state-classes.json diff --git a/templates/_shared/.agent/contracts/state-classes.json b/templates/_shared/.agent/contracts/state-classes.json new file mode 100644 index 00000000..db1ce4fb --- /dev/null +++ b/templates/_shared/.agent/contracts/state-classes.json @@ -0,0 +1,265 @@ +{ + "schema_version": "1.0", + "policies": [ + "tracked", + "local", + "derived", + "evidence", + "legacy", + "ignored" + ], + "classes": [ + { + "id": "governance.decisions.index", + "path": "decisions/index.json", + "kind": "file", + "authority": "projection", + "sync_policy": "derived", + "runtime_scope": "portable", + "rebuildable": true + }, + { + "id": "governance.decisions", + "path": "decisions", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.waitpoints.index", + "path": "waitpoints/index.json", + "kind": "file", + "authority": "projection", + "sync_policy": "derived", + "runtime_scope": "portable", + "rebuildable": true + }, + { + "id": "governance.waitpoints", + "path": "waitpoints", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.inbox.index", + "path": "inbox/index.json", + "kind": "file", + "authority": "projection", + "sync_policy": "derived", + "runtime_scope": "portable", + "rebuildable": true + }, + { + "id": "governance.inbox", + "path": "inbox", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.tasks", + "path": "tasks", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.missions", + "path": "missions", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.plans", + "path": "plans", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.branches", + "path": "branches/registry.json", + "kind": "file", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.operations", + "path": "operations", + "kind": "directory", + "authority": "event-sourced-operational", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.authorizations", + "path": "authorizations", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.readiness", + "path": "readiness", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "governance.checkpoints", + "path": "checkpoints", + "kind": "directory", + "authority": "evidence-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "framework.dispatch", + "path": "dispatch", + "kind": "directory", + "authority": "governance-record", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "framework.workflows", + "path": "workflows", + "kind": "directory", + "authority": "framework-config", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "framework.skills", + "path": "skills", + "kind": "directory", + "authority": "framework-config", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.coordination", + "path": "runtime/coordination", + "kind": "directory", + "authority": "event-sourced-operational", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.dispatch", + "path": "runtime/dispatch", + "kind": "directory", + "authority": "operational", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.cross-project", + "path": "runtime/cross-project", + "kind": "directory", + "authority": "transport", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.continuity", + "path": "runtime/continuity", + "kind": "directory", + "authority": "evidence-transport", + "sync_policy": "tracked", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.evidence", + "path": "runtime/evidence", + "kind": "directory", + "authority": "evidence", + "sync_policy": "evidence", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "runtime.hosts", + "path": "runtime/hosts", + "kind": "directory", + "authority": "machine-local", + "sync_policy": "local", + "runtime_scope": "machine-local", + "rebuildable": true + }, + { + "id": "runtime.worktrees", + "path": "runtime/worktrees", + "kind": "directory", + "authority": "machine-local", + "sync_policy": "local", + "runtime_scope": "instance-local", + "rebuildable": true + }, + { + "id": "evidence.runtime", + "path": "runtime-evidence", + "kind": "directory", + "authority": "evidence", + "sync_policy": "evidence", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "evidence.activities", + "path": "activities", + "kind": "directory", + "authority": "evidence", + "sync_policy": "evidence", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "transport.event-bus", + "path": "event-bus", + "kind": "directory", + "authority": "transport", + "sync_policy": "evidence", + "runtime_scope": "portable", + "rebuildable": false + }, + { + "id": "legacy.runtime-continuity", + "path": "runtime-continuity", + "kind": "directory", + "authority": "compatibility", + "sync_policy": "legacy", + "runtime_scope": "portable", + "rebuildable": false + } + ] +} From 114291378476a0eed2faa620ebc5fc2097a9faa5 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:40:19 +0800 Subject: [PATCH 02/28] feat(state): add state class registry schema --- .../contracts/state-classes.schema.json | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 templates/_shared/.agent/contracts/state-classes.schema.json diff --git a/templates/_shared/.agent/contracts/state-classes.schema.json b/templates/_shared/.agent/contracts/state-classes.schema.json new file mode 100644 index 00000000..85522d10 --- /dev/null +++ b/templates/_shared/.agent/contracts/state-classes.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Cortex Agent State Class Registry", + "type": "object", + "required": [ + "schema_version", + "policies", + "classes" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "policies": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "tracked", + "local", + "derived", + "evidence", + "legacy", + "ignored" + ] + } + }, + "classes": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "path", + "kind", + "authority", + "sync_policy", + "runtime_scope", + "rebuildable" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "authority": { + "type": "string", + "minLength": 1 + }, + "sync_policy": { + "type": "string", + "enum": [ + "tracked", + "local", + "derived", + "evidence", + "legacy", + "ignored" + ] + }, + "runtime_scope": { + "type": "string", + "enum": [ + "portable", + "machine-local", + "instance-local" + ] + }, + "rebuildable": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} From a053141087f4d87a138fae0c6f56821e386e118e Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:40:41 +0800 Subject: [PATCH 03/28] feat(state): add state class registry loader --- lib/state-registry/index.js | 135 ++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 lib/state-registry/index.js diff --git a/lib/state-registry/index.js b/lib/state-registry/index.js new file mode 100644 index 00000000..95fcdba6 --- /dev/null +++ b/lib/state-registry/index.js @@ -0,0 +1,135 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const BUILTIN_REGISTRY_PATH = path.join( + __dirname, + "..", + "..", + "templates", + "_shared", + ".agent", + "contracts", + "state-classes.json", +); + +const SYNCABLE_POLICIES = new Set(["tracked", "derived"]); + +function normalizeStatePath(value) { + if (typeof value !== "string") return ""; + let file = value.replace(/\\/g, "/").replace(/^\.\//, ""); + if (file.startsWith(".agent/")) file = file.slice(".agent/".length); + while (file.startsWith("/")) file = file.slice(1); + return file; +} + +function readRegistry(file = BUILTIN_REGISTRY_PATH) { + const raw = fs.readFileSync(file, "utf8"); + const data = JSON.parse(raw); + validateRegistry(data); + return Object.freeze({ + ...data, + classes: Object.freeze(data.classes.map((entry) => Object.freeze({ ...entry }))), + }); +} + +function validateRegistry(data) { + if (!data || typeof data !== "object") throw new Error("STATE_REGISTRY_INVALID: root must be an object"); + if (data.schema_version !== "1.0") throw new Error("STATE_REGISTRY_INVALID: schema_version must be 1.0"); + if (!Array.isArray(data.classes) || data.classes.length === 0) { + throw new Error("STATE_REGISTRY_INVALID: classes must be a non-empty array"); + } + const ids = new Set(); + const paths = new Set(); + const allowedPolicies = new Set(["tracked", "local", "derived", "evidence", "legacy", "ignored"]); + for (const entry of data.classes) { + if (!entry || typeof entry !== "object") throw new Error("STATE_REGISTRY_INVALID: class entry must be an object"); + for (const key of ["id", "path", "kind", "authority", "sync_policy", "runtime_scope"]) { + if (typeof entry[key] !== "string" || entry[key].length === 0) { + throw new Error(`STATE_REGISTRY_INVALID: ${key} is required`); + } + } + if (typeof entry.rebuildable !== "boolean") { + throw new Error("STATE_REGISTRY_INVALID: rebuildable must be boolean"); + } + if (!["file", "directory"].includes(entry.kind)) { + throw new Error(`STATE_REGISTRY_INVALID: unknown kind ${entry.kind}`); + } + if (!allowedPolicies.has(entry.sync_policy)) { + throw new Error(`STATE_REGISTRY_INVALID: unknown sync_policy ${entry.sync_policy}`); + } + if (ids.has(entry.id)) throw new Error(`STATE_REGISTRY_INVALID: duplicate id ${entry.id}`); + if (paths.has(entry.path)) throw new Error(`STATE_REGISTRY_INVALID: duplicate path ${entry.path}`); + ids.add(entry.id); + paths.add(entry.path); + } + return true; +} + +function matches(entry, file) { + if (entry.kind === "file") return file === entry.path; + return file === entry.path || file.startsWith(entry.path + "/"); +} + +function classifyStatePath(value, registry = readRegistry()) { + const file = normalizeStatePath(value); + if (!file || file === ".." || file.startsWith("../") || file.includes("/../")) return null; + const matchesList = registry.classes + .filter((entry) => matches(entry, file)) + .sort((a, b) => { + if (b.path.length !== a.path.length) return b.path.length - a.path.length; + if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1; + return a.id.localeCompare(b.id); + }); + return matchesList.length > 0 ? matchesList[0] : null; +} + +function isSyncablePolicy(policy) { + return SYNCABLE_POLICIES.has(policy); +} + +function isSyncableStatePath(value, registry = readRegistry()) { + const entry = classifyStatePath(value, registry); + return Boolean(entry && isSyncablePolicy(entry.sync_policy)); +} + +function syncablePathspecs(registry = readRegistry()) { + const syncable = registry.classes.filter((entry) => isSyncablePolicy(entry.sync_policy)); + const directories = syncable + .filter((entry) => entry.kind === "directory") + .map((entry) => entry.path) + .sort(); + const files = syncable + .filter((entry) => entry.kind === "file") + .map((entry) => entry.path) + .filter((file) => !directories.some((dir) => file.startsWith(dir + "/"))) + .sort(); + return { directories, files }; +} + +function registrySummary(registry = readRegistry()) { + const counts = {}; + for (const entry of registry.classes) { + counts[entry.sync_policy] = (counts[entry.sync_policy] || 0) + 1; + } + return { + schema_version: registry.schema_version, + total: registry.classes.length, + policies: counts, + syncable: registry.classes.filter((entry) => isSyncablePolicy(entry.sync_policy)).length, + }; +} + +module.exports = { + BUILTIN_REGISTRY_PATH, + SYNCABLE_POLICIES, + normalizeStatePath, + readRegistry, + validateRegistry, + classifyStatePath, + isSyncablePolicy, + isSyncableStatePath, + syncablePathspecs, + registrySummary, +}; From 6c3816d9d33b88906e1c630e6dce4740986715f6 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:41:03 +0800 Subject: [PATCH 04/28] refactor(state-sync): derive sync paths from state registry --- lib/state-sync/index.js | 72 +++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/lib/state-sync/index.js b/lib/state-sync/index.js index b616f18a..48c7316d 100644 --- a/lib/state-sync/index.js +++ b/lib/state-sync/index.js @@ -3,12 +3,12 @@ // ─── state-sync (T-FOLLOW-002 v2) ───────────────────────────────────────────── // // Purpose: keep `.agent/` state in lock-step across machines by -// (a) scanning 9 state classes for dirty / untracked files, +// (a) scanning registry-managed state classes for dirty / untracked files, // (b) `git add`ing them, optionally `git commit`ing, // (c) optionally `git push`ing to origin. // // Why this lives here (not in `lib/commands.js`): -// - 9 state classes are an inner-`.agent/` concern (own git repo). +// - registry-managed state classes are an inner-`.agent/` concern (own git repo). // Adding it to `lib/commands.js` would force the outer repo to // know about inner-`.agent/` topology. // - Strictly additive: the switch case in bin/cli.js only adds @@ -25,22 +25,18 @@ const fs = require("node:fs"); const path = require("node:path"); const readline = require("node:readline"); const { spawnSync } = require("node:child_process"); - -const STATE_DIRS = Object.freeze([ - "decisions", - "waitpoints", - "inbox", - "tasks", - "missions", - "plans", - "dispatch", - "workflows", - "skills", -]); - -const STATE_FILES = Object.freeze([ - "branches/registry.json", -]); +const { + readRegistry, + classifyStatePath, + isSyncablePolicy, + isSyncableStatePath, + syncablePathspecs, +} = require("../state-registry"); + +const STATE_REGISTRY = readRegistry(); +const STATE_PATHS = syncablePathspecs(STATE_REGISTRY); +const STATE_DIRS = Object.freeze([...STATE_PATHS.directories]); +const STATE_FILES = Object.freeze([...STATE_PATHS.files]); // Backup / temp suffixes that the cortex-agent update flow leaves behind // (e.g. index.js.bak, index.js.bak.prev when `--force-scripts` rewrites @@ -85,16 +81,15 @@ function parsePorcelain(stdout) { return entries; } -// Classify a file path as a state-class path or not. +// Classify a file path as syncable project state through State Class Registry v1. function isStatePath(file) { if (isBackupPath(file)) return false; - for (const dir of STATE_DIRS) { - if (file === dir || file.startsWith(dir + "/")) return true; - } - for (const f of STATE_FILES) { - if (file === f) return true; - } - return false; + return isSyncableStatePath(file, STATE_REGISTRY); +} + +function stateClassForPath(file) { + if (isBackupPath(file)) return null; + return classifyStatePath(file, STATE_REGISTRY); } // Run `git -C ` and return { status, stdout, stderr }. @@ -170,16 +165,16 @@ function suggestCommitMessage(dirty, staged) { const all = [...new Set([...dirty, ...staged])].sort(); const dirs = new Set(); for (const f of all) { - const top = f.split("/")[0]; - if (STATE_DIRS.includes(top) || STATE_FILES.some((sf) => f === sf || f.startsWith(sf + "/"))) { - dirs.add(top); + const entry = stateClassForPath(f); + if (entry && isSyncablePolicy(entry.sync_policy)) { + dirs.add(entry.id); } } const dirList = [...dirs].sort().join(", "); return `chore(state-sync): sync ${all.length} file(s) across ${dirList || "state classes"}`; } -// `git add` the 10 state classes. Returns { ok, error, staged }. +// `git add` the registry-managed state classes. Returns { ok, error, staged }. // Only adds pathspecs that actually exist — `git add -- foo` fails the // whole batch if `foo` doesn't exist, which would block first-time // clones where some state dirs haven't been created yet. @@ -277,16 +272,15 @@ function askYesNo(question) { function printHelp() { console.log(`Usage: cortex-agent state-sync [options] -Scan the 10 state classes in .agent/ and add / commit / push them so +Scan the registry-managed state classes in .agent/ and add / commit / push them so project-management state stays in lock-step across machines. -State classes (10): - decisions/ waitpoints/ inbox/ tasks/ missions/ plans/ - dispatch/ workflows/ skills/ branches/registry.json +State classes are resolved from .agent/contracts/state-classes.json. +Only policies tracked / derived are staged automatically. Options: --dry-run Only report dirty / staged state files (default) - --add git add the 10 state classes + --add git add the registry-managed state classes --commit --add + git commit (uses suggested message) --push --commit + git push origin --yes Skip the Y/N confirmation prompt @@ -365,7 +359,7 @@ async function stateSync(ctx) { const total = scan.dirty.length + scan.staged.length; if (total === 0) { - console.log("✅ .agent/ working tree is clean across the 10 state classes."); + console.log("✅ .agent/ working tree is clean across the registry-managed state classes."); console.log(" (decisions/ waitpoints/ inbox/ tasks/ missions/ plans/"); console.log(" dispatch/ workflows/ skills/ branches/registry.json)"); return; @@ -419,7 +413,7 @@ async function stateSync(ctx) { process.exitCode = 1; return; } - console.log(`✅ git add: ${STATE_DIRS.length} dirs + ${STATE_FILES.length} files staged`); + console.log(`✅ git add: ${STATE_DIRS.length} registry dirs + ${STATE_FILES.length} registry files considered`); } // 5. git commit. @@ -461,7 +455,7 @@ async function stateSync(ctx) { // ─── stateSyncAuto — non-interactive entry for managementWrite etc. ────────── // // Used by `lib/commands.js` `managementWrite` and `init`/`upgrade` so the -// 9 state classes get add+commit+push'd automatically when a workflow +// registry-managed state classes get add+commit+push'd automatically when a workflow // writes to .agent/. Non-throwing: failures return { ok: false, error } // and the caller decides whether to warn or fail. // @@ -640,6 +634,8 @@ module.exports = { pushState, installStateGithooks, fireAndForgetSync, + STATE_REGISTRY, STATE_DIRS, STATE_FILES, + stateClassForPath, }; From c3d9683458fcc32d63641bfa158305f405f4c447 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:41:53 +0800 Subject: [PATCH 05/28] feat(governance): add deterministic index projector --- lib/governance-index/index.js | 287 ++++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 lib/governance-index/index.js diff --git a/lib/governance-index/index.js b/lib/governance-index/index.js new file mode 100644 index 00000000..4e2565a4 --- /dev/null +++ b/lib/governance-index/index.js @@ -0,0 +1,287 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const DECISION_TYPES = new Set(["approval", "architecture", "merge", "release", "risk"]); +const DECISION_STATUSES = new Set(["open", "approved", "rejected", "revision_requested", "canceled", "superseded"]); +const WAITPOINT_STATUSES = new Set(["pending", "blocked", "released", "canceled", "expired"]); +const GATE_ACTIONS = new Set(["architecture", "merge", "release", "destructive", "credential", "external_side_effect"]); + +function readJson(file) { + try { + return { ok: true, value: JSON.parse(fs.readFileSync(file, "utf8")) }; + } catch (error) { + return { ok: false, error: error.message }; + } +} + +function stableJson(value) { + return JSON.stringify(value, null, 2) + "\n"; +} + +function isDateTime(value) { + return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value)); +} + +function isDecisionId(value) { + return typeof value === "string" && /^D-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value); +} + +function isWaitpointId(value) { + return typeof value === "string" && /^WP-[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value); +} + +function isWorkflow(value) { + return typeof value === "string" && /^\/[A-Za-z0-9][A-Za-z0-9-]*$/.test(value); +} + +function validateGate(gate, errors) { + if (!gate || typeof gate !== "object" || Array.isArray(gate)) { + errors.push("gate_missing"); + return; + } + if (!GATE_ACTIONS.has(gate.action)) errors.push("gate_action_invalid"); + if (typeof gate.resource_ref !== "string" || gate.resource_ref.length === 0) errors.push("resource_ref_invalid"); +} + +function projectDecision(data, fileName) { + const errors = []; + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ok: false, errors: ["record_not_object"] }; + } + if (!isDecisionId(data.decision_id)) errors.push("decision_id_invalid"); + if (data.decision_id && fileName !== `${data.decision_id}.json`) errors.push("filename_id_mismatch"); + if (!DECISION_TYPES.has(data.type)) errors.push("type_invalid"); + if (!DECISION_STATUSES.has(data.status)) errors.push("status_invalid"); + validateGate(data.gate, errors); + if (!isDateTime(data.updated_at)) errors.push("updated_at_invalid"); + if (errors.length > 0) return { ok: false, errors }; + return { + ok: true, + entry: { + decision_id: data.decision_id, + path: `.agent/decisions/${fileName}`, + type: data.type, + status: data.status, + gate_action: data.gate.action, + resource_ref: data.gate.resource_ref, + updated_at: data.updated_at, + }, + }; +} + +function projectWaitpoint(data, fileName) { + const errors = []; + if (!data || typeof data !== "object" || Array.isArray(data)) { + return { ok: false, errors: ["record_not_object"] }; + } + if (!isWaitpointId(data.waitpoint_id)) errors.push("waitpoint_id_invalid"); + if (data.waitpoint_id && fileName !== `${data.waitpoint_id}.json`) errors.push("filename_id_mismatch"); + if (!WAITPOINT_STATUSES.has(data.status)) errors.push("status_invalid"); + if (!isWorkflow(data.owner_workflow)) errors.push("owner_workflow_invalid"); + validateGate(data.gate, errors); + if (data.decision_id !== null && data.decision_id !== undefined && !isDecisionId(data.decision_id)) { + errors.push("decision_id_invalid"); + } + if (!isDateTime(data.updated_at)) errors.push("updated_at_invalid"); + if (errors.length > 0) return { ok: false, errors }; + return { + ok: true, + entry: { + waitpoint_id: data.waitpoint_id, + path: `.agent/waitpoints/${fileName}`, + status: data.status, + owner_workflow: data.owner_workflow, + gate_action: data.gate.action, + resource_ref: data.gate.resource_ref, + decision_id: data.decision_id === undefined ? null : data.decision_id, + updated_at: data.updated_at, + }, + }; +} + +function sourceFiles(agentRoot, dir, prefix) { + const base = path.join(agentRoot, dir); + if (!fs.existsSync(base)) return []; + return fs.readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith(".json")) + .map((entry) => entry.name) + .sort(); +} + +function sortEntries(entries, idField) { + return [...entries].sort((a, b) => { + const byTime = String(b.updated_at).localeCompare(String(a.updated_at)); + if (byTime !== 0) return byTime; + return String(a[idField]).localeCompare(String(b[idField])); + }); +} + +function buildKind(agentRoot, options) { + const entries = []; + const invalid = []; + for (const fileName of sourceFiles(agentRoot, options.dir, options.prefix)) { + const file = path.join(agentRoot, options.dir, fileName); + const parsed = readJson(file); + if (!parsed.ok) { + invalid.push({ + path: `.agent/${options.dir}/${fileName}`, + errors: ["invalid_json"], + detail: parsed.error, + }); + continue; + } + const projected = options.project(parsed.value, fileName); + if (!projected.ok) { + invalid.push({ + path: `.agent/${options.dir}/${fileName}`, + errors: projected.errors, + }); + continue; + } + entries.push(projected.entry); + } + return { + index: { [options.key]: sortEntries(entries, options.idField) }, + invalid_sources: invalid, + }; +} + +function buildGovernanceIndexes(projectRoot) { + const root = path.resolve(projectRoot || "."); + const agentRoot = path.join(root, ".agent"); + const decisions = buildKind(agentRoot, { + dir: "decisions", + prefix: "D-", + key: "decisions", + idField: "decision_id", + project: projectDecision, + }); + const waitpoints = buildKind(agentRoot, { + dir: "waitpoints", + prefix: "WP-", + key: "waitpoints", + idField: "waitpoint_id", + project: projectWaitpoint, + }); + return { + ok: decisions.invalid_sources.length === 0 && waitpoints.invalid_sources.length === 0, + project_root: root, + agent_root: agentRoot, + decisions, + waitpoints, + }; +} + +function compareIndex(file, expected) { + if (!fs.existsSync(file)) { + return { drift: true, reason: "missing", actual: null }; + } + const parsed = readJson(file); + if (!parsed.ok) return { drift: true, reason: "invalid_json", actual: null, error: parsed.error }; + const expectedText = stableJson(expected); + const actualText = stableJson(parsed.value); + return { + drift: actualText !== expectedText, + reason: actualText !== expectedText ? "content_mismatch" : null, + actual: parsed.value, + }; +} + +function verifyGovernanceIndexes(projectRoot) { + const built = buildGovernanceIndexes(projectRoot); + const decisionsFile = path.join(built.agent_root, "decisions", "index.json"); + const waitpointsFile = path.join(built.agent_root, "waitpoints", "index.json"); + const decisionCheck = compareIndex(decisionsFile, built.decisions.index); + const waitpointCheck = compareIndex(waitpointsFile, built.waitpoints.index); + const drift = decisionCheck.drift || waitpointCheck.drift; + return { + ok: built.ok && !drift, + read_only: true, + project_root: built.project_root, + invalid_sources: [ + ...built.decisions.invalid_sources, + ...built.waitpoints.invalid_sources, + ], + decisions: { + expected_count: built.decisions.index.decisions.length, + invalid_count: built.decisions.invalid_sources.length, + drift: decisionCheck.drift, + reason: decisionCheck.reason, + }, + waitpoints: { + expected_count: built.waitpoints.index.waitpoints.length, + invalid_count: built.waitpoints.invalid_sources.length, + drift: waitpointCheck.drift, + reason: waitpointCheck.reason, + }, + expected: { + decisions: built.decisions.index, + waitpoints: built.waitpoints.index, + }, + }; +} + +function writeTemp(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temp = `${file}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(temp, content, "utf8"); + return temp; +} + +function rebuildGovernanceIndexes(projectRoot) { + const built = buildGovernanceIndexes(projectRoot); + const invalidSources = [ + ...built.decisions.invalid_sources, + ...built.waitpoints.invalid_sources, + ]; + if (invalidSources.length > 0) { + return { + ok: false, + code: "GOVERNANCE_INDEX_SOURCE_INVALID", + project_root: built.project_root, + invalid_sources: invalidSources, + changed_paths: [], + }; + } + + const decisionsFile = path.join(built.agent_root, "decisions", "index.json"); + const waitpointsFile = path.join(built.agent_root, "waitpoints", "index.json"); + const decisionTemp = writeTemp(decisionsFile, stableJson(built.decisions.index)); + const waitpointTemp = writeTemp(waitpointsFile, stableJson(built.waitpoints.index)); + try { + fs.renameSync(decisionTemp, decisionsFile); + fs.renameSync(waitpointTemp, waitpointsFile); + } finally { + if (fs.existsSync(decisionTemp)) fs.unlinkSync(decisionTemp); + if (fs.existsSync(waitpointTemp)) fs.unlinkSync(waitpointTemp); + } + return { + ok: true, + project_root: built.project_root, + changed_paths: [ + ".agent/decisions/index.json", + ".agent/waitpoints/index.json", + ], + changed_resources: [ + "projection:decisions-index", + "projection:waitpoints-index", + ], + decisions: { count: built.decisions.index.decisions.length }, + waitpoints: { count: built.waitpoints.index.waitpoints.length }, + }; +} + +module.exports = { + DECISION_TYPES, + DECISION_STATUSES, + WAITPOINT_STATUSES, + GATE_ACTIONS, + stableJson, + projectDecision, + projectWaitpoint, + buildGovernanceIndexes, + verifyGovernanceIndexes, + rebuildGovernanceIndexes, +}; From 22018f9d796219315f668cd4c5fab1fd2ff01939 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:42:17 +0800 Subject: [PATCH 06/28] feat(governance): add index verify and rebuild CLI --- lib/governance-index/cli.js | 92 +++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 lib/governance-index/cli.js diff --git a/lib/governance-index/cli.js b/lib/governance-index/cli.js new file mode 100644 index 00000000..e6041c54 --- /dev/null +++ b/lib/governance-index/cli.js @@ -0,0 +1,92 @@ +"use strict"; + +const path = require("node:path"); +const { + verifyGovernanceIndexes, + rebuildGovernanceIndexes, +} = require("./index.js"); +const { + commandNone, + commandRead, + commandMutation, + commandFailure, +} = require("../cli/effect.js"); + +function usage() { + return [ + "Usage:", + " cortex-agent governance-index verify [--project ] [--json]", + " cortex-agent governance-index rebuild [--project ] [--json]", + "", + "verify Read-only: project Decision/Waitpoint indexes from authoritative records and report drift.", + "rebuild Write only decisions/index.json and waitpoints/index.json; fails closed if a source cannot be safely projected.", + ].join("\n"); +} + +function targetRoot(ctx) { + if (ctx.options && ctx.options.project) return path.resolve(ctx.cwd, ctx.options.project); + return ctx.cwd; +} + +function print(payload) { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); +} + +function governanceIndexCommand(ctx) { + const sub = ctx.args[1]; + if (!sub || sub === "help" || ctx.args.includes("--help") || ctx.args.includes("-h")) { + process.stdout.write(usage() + "\n"); + return { ...commandNone({ help: true }), project_root: targetRoot(ctx) }; + } + + const projectRoot = targetRoot(ctx); + if (sub === "verify") { + const result = verifyGovernanceIndexes(projectRoot); + print({ action: "governance-index verify", ...result }); + if (!result.ok) process.exitCode = 1; + return { + ...commandRead({ + resources: ["projection:decisions-index", "projection:waitpoints-index"], + domains: ["filesystem"], + }), + ok: result.ok, + project_root: projectRoot, + verification: result, + }; + } + + if (sub === "rebuild") { + const result = rebuildGovernanceIndexes(projectRoot); + if (!result.ok) { + print({ action: "governance-index rebuild", ...result }); + process.exitCode = 3; + return { + ...commandFailure(result.code || "GOVERNANCE_INDEX_REBUILD_FAILED"), + project_root: projectRoot, + rebuild: result, + }; + } + print({ action: "governance-index rebuild", ...result }); + return { + ...commandMutation({ + committed: true, + exact_paths: true, + resources: result.changed_resources, + paths: result.changed_paths, + domains: ["filesystem"], + }), + project_root: projectRoot, + rebuild: result, + }; + } + + process.stderr.write("governance-index: unknown subcommand: " + sub + "\n"); + process.stderr.write(usage() + "\n"); + process.exitCode = 2; + return { ...commandFailure("INVALID_USAGE"), project_root: projectRoot }; +} + +module.exports = { + usage, + governanceIndexCommand, +}; From f1e080f8fb4750e8ed229b6c49829cc15b47c085 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:42:33 +0800 Subject: [PATCH 07/28] feat(cli): wire governance index command --- bin/cli.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/cli.js b/bin/cli.js index fb36dfdd..d4c56ef4 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -159,6 +159,7 @@ const { eventBusCommand } = require("../lib/event-bus/cli"); // bin/cli.js (not lib/commands.js) so M-001 shadow-init's invariant // "lib/commands.js has 0 changes vs base f8a1d38" stays intact. const { stateSync, installStateGithooks, fireAndForgetSync } = require("../lib/state-sync/index.js"); +const { governanceIndexCommand } = require("../lib/governance-index/cli.js"); const { shouldAutoSyncCoordination } = require("../lib/commands/management/coordination.js"); // GitHub issue #15: decisions / inbox / waitpoints write wrappers now return From 85f2900b68af3a8082b2dbb6d647bd3fd480da4b Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:42:38 +0800 Subject: [PATCH 08/28] docs(cli): register governance index command --- lib/cli/contract.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cli/contract.js b/lib/cli/contract.js index 42b04e4a..f050f563 100644 --- a/lib/cli/contract.js +++ b/lib/cli/contract.js @@ -119,7 +119,8 @@ const commands = [ ), command("branch", "branch [options]", "Git branch lifecycle management for cortex-agent-managed refs.", { mode: "branch_management" }), command("pr", "pr merge --gate user [options]", "Explicitly merge a pull request through the existing vcs-pr runtime. Never automatic; requires user gate.", { mode: "user_gated_vcs", implemented: true, automatic_merge_enabled: false, requires: [".agent/config/vcs.yml", "--gate user"] }), - command("state-sync", "state-sync [--dry-run|--add|--commit|--push]", "Scan the state-class directories and stage/commit/push project-management state so it stays in lock-step across machines.", { mode: "state_sync" }), + command("state-sync", "state-sync [--dry-run|--add|--commit|--push]", "Scan registry-managed state classes and stage/commit/push project-management state so it stays in lock-step across machines.", { mode: "state_sync" }), + command("governance-index", "governance-index [--project ]", "Deterministically project Decision/Waitpoint indexes from authoritative records. verify is read-only; rebuild writes only the two index projections and fails closed on malformed sources.", { mode: "governance_projection", implemented: true }), ]; const options = [ From dd6fed38e7b174a6923b200bc9c2a8d56990a54c Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:43:12 +0800 Subject: [PATCH 09/28] refactor(githook): consume state class registry --- templates/_shared/.agent/.githooks/pre-commit | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/templates/_shared/.agent/.githooks/pre-commit b/templates/_shared/.agent/.githooks/pre-commit index 005724c7..18d87cb8 100755 --- a/templates/_shared/.agent/.githooks/pre-commit +++ b/templates/_shared/.agent/.githooks/pre-commit @@ -1,38 +1,47 @@ #!/usr/bin/env bash -# T-FOLLOW-002 v2: state-class pre-commit reminder +# State Class Registry pre-commit reminder. # -# When the developer commits, this hook scans working-tree changes -# against the 9 state classes (decisions/ waitpoints/ tasks/ missions/ -# plans/ dispatch/ workflows/ skills/ branches/registry.json) and -# prints a reminder if any of them are unstaged / untracked. +# Reads contracts/state-classes.json from the inner .agent/ repository and +# reminds when syncable (tracked / derived) state is unstaged or untracked. # -# It does NOT block the commit. The intent is to surface a forgotten -# `git add` before the commit lands, not to force a workflow. -# -# To disable: `git -C .agent commit --no-verify`. -# To uninstall: `git -C .agent config --unset core.hooksPath`. -# -# Wiring (one-time, per clone): -# git -C .agent config core.hooksPath .githooks -# -# This is part of the state-sync flow. Run -# cortex-agent state-sync --add -# (or --commit / --push) to do the staged work in one shot. +# It does NOT block the commit. +# To disable: git -C .agent commit --no-verify +# To uninstall: git -C .agent config --unset core.hooksPath set -e -STATE_DIRS=( - "decisions" - "waitpoints" - "tasks" - "missions" - "plans" - "dispatch" - "workflows" - "skills" -) -STATE_FILES=( - "branches/registry.json" +REGISTRY="contracts/state-classes.json" + +if [ ! -f "$REGISTRY" ]; then + echo "⚠️ state-sync reminder skipped: $REGISTRY is missing; run cortex-agent update." + exit 0 +fi + +STATE_DIRS=() +STATE_FILES=() + +while IFS=$'\t' read -r kind state_path; do + [ -z "$kind" ] && continue + if [ "$kind" = "directory" ]; then + STATE_DIRS+=("$state_path") + elif [ "$kind" = "file" ]; then + STATE_FILES+=("$state_path") + fi +done < <( + node - "$REGISTRY" <<'NODE' +const fs = require("fs"); +const file = process.argv[2]; +try { + const registry = JSON.parse(fs.readFileSync(file, "utf8")); + for (const entry of registry.classes || []) { + if (!["tracked", "derived"].includes(entry.sync_policy)) continue; + process.stdout.write(entry.kind + "\t" + entry.path + "\n"); + } +} catch (error) { + process.stderr.write("state-sync registry error: " + error.message + "\n"); + process.exit(2); +} +NODE ) is_state_path() { @@ -50,22 +59,15 @@ is_state_path() { return 1 } -# git status --porcelain --untracked-files=all -# Lines look like: -# " M foo" unstaged modification -# "M foo" staged modification -# "?? foo" untracked unstaged_state=() while IFS= read -r line; do [ -z "$line" ] && continue - # Extract filename: skip the 2-char XY status + 1 space, then handle " -> " rename. rest="${line:3}" if [[ "$rest" == *" -> "* ]]; then file="${rest##* -> }" else file="$rest" fi - # Skip if already staged (XY[0] is not ' ' and not '?'). xy="${line:0:2}" xy_staged_char="${xy:0:1}" if [ "$xy_staged_char" != " " ] && [ "$xy_staged_char" != "?" ]; then @@ -78,13 +80,13 @@ done < <(git status --porcelain --untracked-files=all 2>/dev/null || true) if [ "${#unstaged_state[@]}" -gt 0 ]; then echo "" - echo "⚠️ state-sync reminder: ${#unstaged_state[@]} state-class file(s) are unstaged / untracked:" + echo "⚠️ state-sync reminder: ${#unstaged_state[@]} registry-managed state file(s) are unstaged / untracked:" for f in "${unstaged_state[@]}"; do echo " $f" done echo "" - echo " Run \`cortex-agent state-sync --add\` to stage them," - echo " or \`--commit\` / \`--push\` to finish in one shot." + echo " Run `cortex-agent state-sync --add` to stage them," + echo " or `--commit` / `--push` to finish in one shot." echo " (This is a reminder, not a blocker — proceeding with commit.)" echo "" fi From a17dac2d9a76f5bf42133ed8bab8e2e637a2453e Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:44:01 +0800 Subject: [PATCH 10/28] test(state): cover state class registry v1 --- tests/state-registry/state-registry.test.js | 60 +++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/state-registry/state-registry.test.js diff --git a/tests/state-registry/state-registry.test.js b/tests/state-registry/state-registry.test.js new file mode 100644 index 00000000..e26fd0c6 --- /dev/null +++ b/tests/state-registry/state-registry.test.js @@ -0,0 +1,60 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { + readRegistry, + validateRegistry, + classifyStatePath, + isSyncableStatePath, + syncablePathspecs, + registrySummary, +} = require("../../lib/state-registry"); + +test("builtin registry validates and reports policy summary", () => { + const registry = readRegistry(); + assert.equal(registry.schema_version, "1.0"); + assert.ok(registry.classes.length >= 20); + const summary = registrySummary(registry); + assert.ok(summary.policies.tracked > 0); + assert.ok(summary.policies.local > 0); + assert.ok(summary.policies.derived > 0); + assert.ok(summary.policies.evidence > 0); + assert.ok(summary.policies.legacy > 0); +}); + +test("classification prefers the most specific state class", () => { + const registry = readRegistry(); + assert.equal(classifyStatePath("decisions/index.json", registry).id, "governance.decisions.index"); + assert.equal(classifyStatePath("decisions/D-1.json", registry).id, "governance.decisions"); + assert.equal(classifyStatePath(".agent/waitpoints/index.json", registry).id, "governance.waitpoints.index"); +}); + +test("sync policy includes tracked/derived and excludes local/evidence/legacy", () => { + const registry = readRegistry(); + assert.equal(isSyncableStatePath("operations/O-1.json", registry), true); + assert.equal(isSyncableStatePath("decisions/index.json", registry), true); + assert.equal(isSyncableStatePath("runtime/hosts/machine/state.json", registry), false); + assert.equal(isSyncableStatePath("runtime-evidence/foo/run.json", registry), false); + assert.equal(isSyncableStatePath("runtime-continuity/events/e.json", registry), false); +}); + +test("syncable pathspecs are derived from registry", () => { + const specs = syncablePathspecs(readRegistry()); + assert.ok(specs.directories.includes("decisions")); + assert.ok(specs.directories.includes("operations")); + assert.ok(specs.directories.includes("runtime/coordination")); + assert.equal(specs.directories.includes("runtime/hosts"), false); + assert.ok(specs.files.includes("branches/registry.json")); + assert.equal(specs.files.includes("decisions/index.json"), false, "covered by decisions directory"); +}); + +test("registry validation rejects duplicate paths", () => { + assert.throws(() => validateRegistry({ + schema_version: "1.0", + classes: [ + { id: "a", path: "x", kind: "directory", authority: "a", sync_policy: "tracked", runtime_scope: "portable", rebuildable: false }, + { id: "b", path: "x", kind: "directory", authority: "b", sync_policy: "local", runtime_scope: "portable", rebuildable: false }, + ], + }), /duplicate path/); +}); From 4bd4f06ea1b8059efa5192752361f9640858e65d Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:44:03 +0800 Subject: [PATCH 11/28] test(governance): cover deterministic index projection --- .../governance-index/governance-index.test.js | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/governance-index/governance-index.test.js diff --git a/tests/governance-index/governance-index.test.js b/tests/governance-index/governance-index.test.js new file mode 100644 index 00000000..c381efdf --- /dev/null +++ b/tests/governance-index/governance-index.test.js @@ -0,0 +1,149 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { + buildGovernanceIndexes, + verifyGovernanceIndexes, + rebuildGovernanceIndexes, +} = require("../../lib/governance-index"); + +function mkProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-gov-index-")); + for (const dir of ["decisions", "waitpoints"]) { + fs.mkdirSync(path.join(root, ".agent", dir), { recursive: true }); + } + return root; +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n"); +} + +function decision(id, updatedAt, overrides = {}) { + return { + schema_version: 1, + decision_id: id, + type: "architecture", + status: "approved", + requested_by: "test", + prompt: "approve?", + options: ["yes", "no"], + selected_option: "yes", + resolved_by: "user", + resolved_at: updatedAt, + rationale: "ok", + gate: { action: "architecture", resource_ref: "mission:M-X" }, + relations: { task_ids: [], mission_ids: [], run_ids: [], queue_ids: [], session_ids: [], artifact_refs: [], worktree_paths: [] }, + created_at: updatedAt, + updated_at: updatedAt, + ...overrides, + }; +} + +function waitpoint(id, updatedAt, overrides = {}) { + return { + schema_version: 1, + waitpoint_id: id, + status: "released", + owner_workflow: "/mission", + reason: "wait", + gate: { action: "architecture", resource_ref: "mission:M-X" }, + decision_id: "D-1", + evidence_refs: ["decision:D-1"], + release_note: "ok", + released_by: "user", + released_at: updatedAt, + expires_at: null, + relations: { task_ids: [], mission_ids: [], run_ids: [], queue_ids: [], session_ids: [], artifact_refs: [], worktree_paths: [] }, + created_at: updatedAt, + updated_at: updatedAt, + ...overrides, + }; +} + +test("buildGovernanceIndexes projects deterministic stable indexes", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-1.json"), decision("D-1", "2026-09-28T01:00:00Z")); + writeJson(path.join(root, ".agent/decisions/D-2.json"), decision("D-2", "2026-09-28T02:00:00Z")); + writeJson(path.join(root, ".agent/waitpoints/WP-1.json"), waitpoint("WP-1", "2026-09-28T03:00:00Z")); + + const built = buildGovernanceIndexes(root); + assert.equal(built.ok, true); + assert.deepEqual(built.decisions.index.decisions.map((x) => x.decision_id), ["D-2", "D-1"]); + assert.equal(built.decisions.index.decisions[0].path, ".agent/decisions/D-2.json"); + assert.equal(built.waitpoints.index.waitpoints[0].gate_action, "architecture"); + assert.equal(built.waitpoints.index.waitpoints[0].resource_ref, "mission:M-X"); +}); + +test("verify is read-only and reports drift", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-1.json"), decision("D-1", "2026-09-28T01:00:00Z")); + writeJson(path.join(root, ".agent/waitpoints/WP-1.json"), waitpoint("WP-1", "2026-09-28T01:00:00Z")); + writeJson(path.join(root, ".agent/decisions/index.json"), { decisions: [] }); + writeJson(path.join(root, ".agent/waitpoints/index.json"), { waitpoints: [] }); + + const beforeDecision = fs.readFileSync(path.join(root, ".agent/decisions/index.json"), "utf8"); + const beforeWaitpoint = fs.readFileSync(path.join(root, ".agent/waitpoints/index.json"), "utf8"); + const result = verifyGovernanceIndexes(root); + assert.equal(result.ok, false); + assert.equal(result.read_only, true); + assert.equal(result.decisions.drift, true); + assert.equal(result.waitpoints.drift, true); + assert.equal(fs.readFileSync(path.join(root, ".agent/decisions/index.json"), "utf8"), beforeDecision); + assert.equal(fs.readFileSync(path.join(root, ".agent/waitpoints/index.json"), "utf8"), beforeWaitpoint); +}); + +test("rebuild writes only deterministic index projections", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-1.json"), decision("D-1", "2026-09-28T01:00:00Z")); + writeJson(path.join(root, ".agent/waitpoints/WP-1.json"), waitpoint("WP-1", "2026-09-28T01:00:00Z")); + fs.writeFileSync(path.join(root, ".agent/README.md"), "sentinel\n"); + + const result = rebuildGovernanceIndexes(root); + assert.equal(result.ok, true); + assert.deepEqual(result.changed_paths, [".agent/decisions/index.json", ".agent/waitpoints/index.json"]); + assert.equal(fs.readFileSync(path.join(root, ".agent/README.md"), "utf8"), "sentinel\n"); + + const verified = verifyGovernanceIndexes(root); + assert.equal(verified.ok, true); +}); + +test("rebuild fails closed on malformed or legacy source and does not rewrite indexes", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-legacy.json"), decision("D-legacy", "2026-09-28T01:00:00Z", { + status: "pending", + })); + writeJson(path.join(root, ".agent/waitpoints/WP-1.json"), waitpoint("WP-1", "2026-09-28T01:00:00Z")); + const decisionIndex = path.join(root, ".agent/decisions/index.json"); + const waitpointIndex = path.join(root, ".agent/waitpoints/index.json"); + writeJson(decisionIndex, { decisions: [{ legacy: true }] }); + writeJson(waitpointIndex, { waitpoints: [{ legacy: true }] }); + const beforeD = fs.readFileSync(decisionIndex, "utf8"); + const beforeW = fs.readFileSync(waitpointIndex, "utf8"); + + const result = rebuildGovernanceIndexes(root); + assert.equal(result.ok, false); + assert.equal(result.code, "GOVERNANCE_INDEX_SOURCE_INVALID"); + assert.equal(result.invalid_sources.length, 1); + assert.ok(result.invalid_sources[0].errors.includes("status_invalid")); + assert.equal(fs.readFileSync(decisionIndex, "utf8"), beforeD); + assert.equal(fs.readFileSync(waitpointIndex, "utf8"), beforeW); +}); + +test("invalid JSON source is reported and not guessed", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, ".agent/decisions/D-bad.json"), "{broken", "utf8"); + const built = buildGovernanceIndexes(root); + assert.equal(built.ok, false); + assert.deepEqual(built.decisions.invalid_sources[0].errors, ["invalid_json"]); +}); From 9bdd5e27e37ed23e09a26596e61c352c6bbf5b77 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:44:28 +0800 Subject: [PATCH 12/28] test(state-sync): align assertions with state registry --- tests/state-sync/state-sync.test.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/state-sync/state-sync.test.js b/tests/state-sync/state-sync.test.js index f01870f7..40d56aed 100644 --- a/tests/state-sync/state-sync.test.js +++ b/tests/state-sync/state-sync.test.js @@ -4,10 +4,10 @@ // // Coverage: lib/state-sync.js // - parsePorcelain: porcelain status line → { staged, unstaged, untracked } -// - isStatePath: 10 state classes correctly classified +// - isStatePath: registry-managed state classes correctly classified // - suggestCommitMessage: deterministic conventional-commit output // - scanState: detects dirty / staged changes in inner-`.agent/` repo -// - addState: actually stages the 10 state classes +// - addState: actually stages the registry-managed state classes // - commitState: actually commits and returns a SHA // - end-to-end: --dry-run → --add → --commit flow on a temp git repo @@ -98,7 +98,7 @@ test("parsePorcelain: rename picks the destination", () => { assert.equal(entries[0].staged, true); }); -test("isStatePath: 10 state classes return true", () => { +test("isStatePath: registry-managed syncable classes return true", () => { assert.equal(isStatePath("decisions"), true); assert.equal(isStatePath("decisions/D-001.json"), true); assert.equal(isStatePath("waitpoints/WP-x.json"), true); @@ -110,6 +110,9 @@ test("isStatePath: 10 state classes return true", () => { assert.equal(isStatePath("workflows/agent-update.md"), true); assert.equal(isStatePath("skills/secrets/SKILL.md"), true); assert.equal(isStatePath("branches/registry.json"), true); + assert.equal(isStatePath("operations/O-001.json"), true); + assert.equal(isStatePath("authorizations/A-001.json"), true); + assert.equal(isStatePath("runtime/coordination/events.jsonl"), true); }); test("isStatePath: non-state paths return false", () => { @@ -117,6 +120,9 @@ test("isStatePath: non-state paths return false", () => { assert.equal(isStatePath("lib/state-sync.js"), false); assert.equal(isStatePath("metrics/agent-dashboard.html"), false); assert.equal(isStatePath(".gitignore"), false); + assert.equal(isStatePath("runtime/hosts/machine/state.json"), false, "machine-local state must not sync"); + assert.equal(isStatePath("runtime-evidence/run.json"), false, "evidence policy is not automatic sync"); + assert.equal(isStatePath("runtime-continuity/events/e.json"), false, "legacy path must not auto-sync"); assert.equal(isStatePath(""), false); }); @@ -147,10 +153,12 @@ test("suggestCommitMessage: empty input still produces a message", () => { assert.match(msg, /^chore\(state-sync\): sync 0 file\(s\)/); }); -test("STATE_DIRS has 9 entries, STATE_FILES has 1 (total 10)", () => { - assert.equal(STATE_DIRS.length, 9); - assert.equal(STATE_FILES.length, 1); - assert.equal(STATE_FILES[0], "branches/registry.json"); +test("STATE_DIRS / STATE_FILES are registry-derived", () => { + assert.ok(STATE_DIRS.includes("decisions")); + assert.ok(STATE_DIRS.includes("operations")); + assert.ok(STATE_DIRS.includes("runtime/coordination")); + assert.equal(STATE_DIRS.includes("runtime/hosts"), false); + assert.deepEqual(STATE_FILES, ["branches/registry.json"]); }); // ─── Git-backed tests ───────────────────────────────────────────────────────── @@ -205,7 +213,7 @@ test("scanState: non-git dir returns ok=false with error", () => { assert.match(res.error, /not a git repository/); }); -test("addState: stages the 10 state classes", () => { +test("addState: stages the registry-managed state classes", () => { const { agentDir } = mkAgentRepo(); touchStateFile(agentDir, "decisions/D-001.json"); touchStateFile(agentDir, "branches/registry.json", "{}"); From 811d8f5d790dff982a339f617f31b88e008a9b25 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:45:10 +0800 Subject: [PATCH 13/28] test(governance): add index CLI integration --- .../governance-index-cli.test.js | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/governance-index/governance-index-cli.test.js diff --git a/tests/governance-index/governance-index-cli.test.js b/tests/governance-index/governance-index-cli.test.js new file mode 100644 index 00000000..bdfa4548 --- /dev/null +++ b/tests/governance-index/governance-index-cli.test.js @@ -0,0 +1,137 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const CLI = path.join(ROOT, "bin", "cli.js"); + +function mkProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-gov-index-cli-")); + fs.mkdirSync(path.join(root, ".agent", "decisions"), { recursive: true }); + fs.mkdirSync(path.join(root, ".agent", "waitpoints"), { recursive: true }); + return root; +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n"); +} + +function decision(id, status = "approved") { + const ts = "2026-09-28T05:00:00Z"; + return { + schema_version: 1, + decision_id: id, + type: "architecture", + status, + requested_by: "test", + prompt: "approve?", + options: ["yes", "no"], + selected_option: status === "open" ? null : "yes", + resolved_by: status === "open" ? null : "user", + resolved_at: status === "open" ? null : ts, + rationale: "", + gate: { action: "architecture", resource_ref: "mission:M-CLI" }, + relations: { task_ids: [], mission_ids: [], run_ids: [], queue_ids: [], session_ids: [], artifact_refs: [], worktree_paths: [] }, + created_at: ts, + updated_at: ts, + }; +} + +function waitpoint(id) { + const ts = "2026-09-28T05:00:00Z"; + return { + schema_version: 1, + waitpoint_id: id, + status: "released", + owner_workflow: "/mission", + reason: "wait", + gate: { action: "architecture", resource_ref: "mission:M-CLI" }, + decision_id: "D-CLI", + evidence_refs: ["decision:D-CLI"], + release_note: "", + released_by: "user", + released_at: ts, + expires_at: null, + relations: { task_ids: [], mission_ids: [], run_ids: [], queue_ids: [], session_ids: [], artifact_refs: [], worktree_paths: [] }, + created_at: ts, + updated_at: ts, + }; +} + +function run(project, args) { + return spawnSync(process.execPath, [CLI, ...args, "--project", project], { + cwd: project, + encoding: "utf8", + env: { ...process.env, CORTEX_STATE_SYNC: "off", LANG: "en_US.UTF-8" }, + }); +} + +test("governance-index --help is zero-write", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const before = fs.readdirSync(path.join(root, ".agent", "decisions")).join("|"); + const result = run(root, ["governance-index", "--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /governance-index verify/); + const after = fs.readdirSync(path.join(root, ".agent", "decisions")).join("|"); + assert.equal(after, before); +}); + +test("governance-index verify reports drift and writes nothing", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-CLI.json"), decision("D-CLI")); + writeJson(path.join(root, ".agent/waitpoints/WP-CLI.json"), waitpoint("WP-CLI")); + writeJson(path.join(root, ".agent/decisions/index.json"), { decisions: [] }); + writeJson(path.join(root, ".agent/waitpoints/index.json"), { waitpoints: [] }); + const beforeD = fs.readFileSync(path.join(root, ".agent/decisions/index.json"), "utf8"); + const beforeW = fs.readFileSync(path.join(root, ".agent/waitpoints/index.json"), "utf8"); + + const result = run(root, ["governance-index", "verify"]); + assert.equal(result.status, 1); + const body = JSON.parse(result.stdout); + assert.equal(body.read_only, true); + assert.equal(body.decisions.drift, true); + assert.equal(body.waitpoints.drift, true); + assert.equal(fs.readFileSync(path.join(root, ".agent/decisions/index.json"), "utf8"), beforeD); + assert.equal(fs.readFileSync(path.join(root, ".agent/waitpoints/index.json"), "utf8"), beforeW); +}); + +test("governance-index rebuild fixes projection drift", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-CLI.json"), decision("D-CLI")); + writeJson(path.join(root, ".agent/waitpoints/WP-CLI.json"), waitpoint("WP-CLI")); + + let result = run(root, ["governance-index", "rebuild"]); + assert.equal(result.status, 0, result.stderr + result.stdout); + let body = JSON.parse(result.stdout); + assert.deepEqual(body.changed_paths, [".agent/decisions/index.json", ".agent/waitpoints/index.json"]); + + result = run(root, ["governance-index", "verify"]); + assert.equal(result.status, 0, result.stderr + result.stdout); + body = JSON.parse(result.stdout); + assert.equal(body.ok, true); +}); + +test("governance-index rebuild fails closed on legacy source", (t) => { + const root = mkProject(); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + writeJson(path.join(root, ".agent/decisions/D-CLI.json"), decision("D-CLI", "pending")); + const indexFile = path.join(root, ".agent/decisions/index.json"); + writeJson(indexFile, { decisions: [{ sentinel: true }] }); + const before = fs.readFileSync(indexFile, "utf8"); + + const result = run(root, ["governance-index", "rebuild"]); + assert.equal(result.status, 3); + const body = JSON.parse(result.stdout); + assert.equal(body.code, "GOVERNANCE_INDEX_SOURCE_INVALID"); + assert.equal(body.invalid_sources.length, 1); + assert.equal(fs.readFileSync(indexFile, "utf8"), before); +}); From b997c9a852f97e6065e430ed8c1a16dcbed85bd1 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:45:57 +0800 Subject: [PATCH 14/28] fix(state): fail closed on invalid registry metadata --- lib/state-registry/index.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/state-registry/index.js b/lib/state-registry/index.js index 95fcdba6..e88af946 100644 --- a/lib/state-registry/index.js +++ b/lib/state-registry/index.js @@ -37,12 +37,19 @@ function readRegistry(file = BUILTIN_REGISTRY_PATH) { function validateRegistry(data) { if (!data || typeof data !== "object") throw new Error("STATE_REGISTRY_INVALID: root must be an object"); if (data.schema_version !== "1.0") throw new Error("STATE_REGISTRY_INVALID: schema_version must be 1.0"); + const allowedPolicies = new Set(["tracked", "local", "derived", "evidence", "legacy", "ignored"]); + if (!Array.isArray(data.policies) || data.policies.length === 0) { + throw new Error("STATE_REGISTRY_INVALID: policies must be a non-empty array"); + } + for (const policy of data.policies) { + if (!allowedPolicies.has(policy)) throw new Error(`STATE_REGISTRY_INVALID: unknown declared policy ${policy}`); + } if (!Array.isArray(data.classes) || data.classes.length === 0) { throw new Error("STATE_REGISTRY_INVALID: classes must be a non-empty array"); } const ids = new Set(); const paths = new Set(); - const allowedPolicies = new Set(["tracked", "local", "derived", "evidence", "legacy", "ignored"]); + const allowedScopes = new Set(["portable", "machine-local", "instance-local"]); for (const entry of data.classes) { if (!entry || typeof entry !== "object") throw new Error("STATE_REGISTRY_INVALID: class entry must be an object"); for (const key of ["id", "path", "kind", "authority", "sync_policy", "runtime_scope"]) { @@ -59,6 +66,9 @@ function validateRegistry(data) { if (!allowedPolicies.has(entry.sync_policy)) { throw new Error(`STATE_REGISTRY_INVALID: unknown sync_policy ${entry.sync_policy}`); } + if (!allowedScopes.has(entry.runtime_scope)) { + throw new Error(`STATE_REGISTRY_INVALID: unknown runtime_scope ${entry.runtime_scope}`); + } if (ids.has(entry.id)) throw new Error(`STATE_REGISTRY_INVALID: duplicate id ${entry.id}`); if (paths.has(entry.path)) throw new Error(`STATE_REGISTRY_INVALID: duplicate path ${entry.path}`); ids.add(entry.id); From 959ba5e9f84a08d4fecabd9144bf55864fa403c0 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:46:00 +0800 Subject: [PATCH 15/28] refactor(governance): keep verify output bounded --- lib/governance-index/index.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/governance-index/index.js b/lib/governance-index/index.js index 4e2565a4..bed1af37 100644 --- a/lib/governance-index/index.js +++ b/lib/governance-index/index.js @@ -216,10 +216,6 @@ function verifyGovernanceIndexes(projectRoot) { drift: waitpointCheck.drift, reason: waitpointCheck.reason, }, - expected: { - decisions: built.decisions.index, - waitpoints: built.waitpoints.index, - }, }; } From 9a9039c7eff95d292960d7e963e31cddc6a4baff Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:46:16 +0800 Subject: [PATCH 16/28] test(state): satisfy registry metadata in duplicate-path fixture --- tests/state-registry/state-registry.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/state-registry/state-registry.test.js b/tests/state-registry/state-registry.test.js index e26fd0c6..432b2c35 100644 --- a/tests/state-registry/state-registry.test.js +++ b/tests/state-registry/state-registry.test.js @@ -52,6 +52,7 @@ test("syncable pathspecs are derived from registry", () => { test("registry validation rejects duplicate paths", () => { assert.throws(() => validateRegistry({ schema_version: "1.0", + policies: ["tracked", "local", "derived", "evidence", "legacy", "ignored"], classes: [ { id: "a", path: "x", kind: "directory", authority: "a", sync_policy: "tracked", runtime_scope: "portable", rebuildable: false }, { id: "b", path: "x", kind: "directory", authority: "b", sync_policy: "local", runtime_scope: "portable", rebuildable: false }, From 3df40eefd24a32c9af82b5c3a3df51966d98bad8 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:46:33 +0800 Subject: [PATCH 17/28] fix(governance): rollback partial index replacement --- lib/governance-index/index.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/lib/governance-index/index.js b/lib/governance-index/index.js index bed1af37..08aa151f 100644 --- a/lib/governance-index/index.js +++ b/lib/governance-index/index.js @@ -221,11 +221,20 @@ function verifyGovernanceIndexes(projectRoot) { function writeTemp(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true }); - const temp = `${file}.${process.pid}.${Date.now()}.tmp`; + const temp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; fs.writeFileSync(temp, content, "utf8"); return temp; } +function restoreFile(file, previous) { + if (previous === null) { + if (fs.existsSync(file)) fs.unlinkSync(file); + return; + } + const temp = writeTemp(file, previous); + fs.renameSync(temp, file); +} + function rebuildGovernanceIndexes(projectRoot) { const built = buildGovernanceIndexes(projectRoot); const invalidSources = [ @@ -244,11 +253,33 @@ function rebuildGovernanceIndexes(projectRoot) { const decisionsFile = path.join(built.agent_root, "decisions", "index.json"); const waitpointsFile = path.join(built.agent_root, "waitpoints", "index.json"); + const previousDecision = fs.existsSync(decisionsFile) ? fs.readFileSync(decisionsFile, "utf8") : null; + const previousWaitpoint = fs.existsSync(waitpointsFile) ? fs.readFileSync(waitpointsFile, "utf8") : null; const decisionTemp = writeTemp(decisionsFile, stableJson(built.decisions.index)); const waitpointTemp = writeTemp(waitpointsFile, stableJson(built.waitpoints.index)); + let decisionReplaced = false; + let waitpointReplaced = false; try { fs.renameSync(decisionTemp, decisionsFile); + decisionReplaced = true; fs.renameSync(waitpointTemp, waitpointsFile); + waitpointReplaced = true; + } catch (error) { + let rollbackError = null; + try { + if (decisionReplaced) restoreFile(decisionsFile, previousDecision); + if (waitpointReplaced) restoreFile(waitpointsFile, previousWaitpoint); + } catch (rollback) { + rollbackError = rollback.message; + } + return { + ok: false, + code: "GOVERNANCE_INDEX_WRITE_FAILED", + project_root: built.project_root, + error: error.message, + rollback_error: rollbackError, + changed_paths: [], + }; } finally { if (fs.existsSync(decisionTemp)) fs.unlinkSync(decisionTemp); if (fs.existsSync(waitpointTemp)) fs.unlinkSync(waitpointTemp); From a997763c429bd3dd7bceae6d3e53291d73d2b5a2 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:46:47 +0800 Subject: [PATCH 18/28] test(state-sync): keep local evidence and legacy state unsynced --- tests/state-sync/state-sync-auto.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/state-sync/state-sync-auto.test.js b/tests/state-sync/state-sync-auto.test.js index ddd3b0df..9b735942 100644 --- a/tests/state-sync/state-sync-auto.test.js +++ b/tests/state-sync/state-sync-auto.test.js @@ -147,3 +147,23 @@ test("stateSyncAuto: already-staged change → no new commit, push to origin", a const showRes = git(["ls-tree", "-r", "main"], originDir); assert.match(showRes.stdout, /decisions\/D-001\.json/); }); + + +test("stateSyncAuto: machine-local/evidence/legacy classes are not auto-synced", async () => { + const { root, agentDir, originDir } = mkAgentRepoWithOrigin(); + touchStateFile(agentDir, "runtime/hosts/machine/state.json", "{}"); + touchStateFile(agentDir, "runtime-evidence/run.json", "{}"); + touchStateFile(agentDir, "runtime-continuity/events/e.json", "{}"); + + const before = git(["rev-parse", "main"], originDir).stdout.trim(); + const res = await stateSyncAuto({ cwd: root }); + assert.equal(res.ok, true); + assert.equal(res.summary, "clean"); + + const after = git(["rev-parse", "main"], originDir).stdout.trim(); + assert.equal(after, before, "remote must not advance for non-syncable classes"); + const status = git(["status", "--porcelain", "--untracked-files=all"], agentDir).stdout; + assert.match(status, /runtime\/hosts\/machine\/state\.json/); + assert.match(status, /runtime-evidence\/run\.json/); + assert.match(status, /runtime-continuity\/events\/e\.json/); +}); From 8eff9037a2f026c29e806e8161b8b32656cfdd72 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:47:25 +0800 Subject: [PATCH 19/28] test(state): verify pre-commit consumes registry policy --- .../pre-commit-registry.test.js | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/state-registry/pre-commit-registry.test.js diff --git a/tests/state-registry/pre-commit-registry.test.js b/tests/state-registry/pre-commit-registry.test.js new file mode 100644 index 00000000..f7ccf669 --- /dev/null +++ b/tests/state-registry/pre-commit-registry.test.js @@ -0,0 +1,56 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const test = require("node:test"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const HOOK = path.join(ROOT, "templates", "_shared", ".agent", ".githooks", "pre-commit"); +const REGISTRY = path.join(ROOT, "templates", "_shared", ".agent", "contracts", "state-classes.json"); + +function git(args, cwd) { + return spawnSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + env: { + ...process.env, + GIT_AUTHOR_NAME: "T", + GIT_AUTHOR_EMAIL: "t@x", + GIT_COMMITTER_NAME: "T", + GIT_COMMITTER_EMAIL: "t@x", + }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +test("pre-commit hook reads state registry and only reminds for syncable classes", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cortex-state-hook-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const agentDir = path.join(root, ".agent"); + fs.mkdirSync(path.join(agentDir, "contracts"), { recursive: true }); + fs.copyFileSync(REGISTRY, path.join(agentDir, "contracts", "state-classes.json")); + + let r = git(["init", "-q", "-b", "main"], agentDir); + assert.equal(r.status, 0, r.stderr); + fs.writeFileSync(path.join(agentDir, "README.md"), "init\n"); + r = git(["add", "README.md"], agentDir); + assert.equal(r.status, 0, r.stderr); + r = git(["commit", "-q", "-m", "init"], agentDir); + assert.equal(r.status, 0, r.stderr); + + fs.mkdirSync(path.join(agentDir, "decisions"), { recursive: true }); + fs.writeFileSync(path.join(agentDir, "decisions", "D-1.json"), "{}\n"); + fs.mkdirSync(path.join(agentDir, "runtime", "hosts", "machine"), { recursive: true }); + fs.writeFileSync(path.join(agentDir, "runtime", "hosts", "machine", "state.json"), "{}\n"); + + const run = spawnSync("bash", [HOOK], { + cwd: agentDir, + encoding: "utf8", + env: process.env, + }); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, /decisions\/D-1\.json/); + assert.doesNotMatch(run.stdout, /runtime\/hosts\/machine\/state\.json/); +}); From 720cec3488fdf67c39fb1ecdab26fff98b06571f Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:48:02 +0800 Subject: [PATCH 20/28] ci(m042): add state registry and index gate --- .../workflows/m042-state-registry-index.yml | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/m042-state-registry-index.yml diff --git a/.github/workflows/m042-state-registry-index.yml b/.github/workflows/m042-state-registry-index.yml new file mode 100644 index 00000000..a0b22969 --- /dev/null +++ b/.github/workflows/m042-state-registry-index.yml @@ -0,0 +1,65 @@ +name: M-042 State Registry and Index + +on: + pull_request: + paths: + - "bin/cli.js" + - "lib/cli/contract.js" + - "lib/state-registry/**" + - "lib/state-sync/**" + - "lib/governance-index/**" + - "templates/_shared/.agent/contracts/state-classes*" + - "templates/_shared/.agent/.githooks/pre-commit" + - "tests/state-registry/**" + - "tests/state-sync/**" + - "tests/governance-index/**" + - "tests/cli/issue-15-waitpoints-help-state-sync.test.js" + - ".github/workflows/m042-state-registry-index.yml" + workflow_dispatch: + +jobs: + focused: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [18, 22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - name: State Class Registry + run: node --test tests/state-registry/*.test.js + - name: Governance index + run: node --test tests/governance-index/*.test.js + - name: State Sync regression + run: | + node --test tests/state-sync/state-sync.test.js + node --test tests/state-sync/state-sync-auto.test.js + node --test tests/install/install-state-githooks.test.js + - name: M-041 exact-sync regression + run: node --test tests/cli/issue-15-waitpoints-help-state-sync.test.js + - name: Syntax and module smoke + run: | + bash -n templates/_shared/.agent/.githooks/pre-commit + node --check lib/governance-index/index.js + node --check lib/governance-index/cli.js + node -e "require('./lib/state-registry'); require('./lib/state-sync'); require('./lib/governance-index')" + - name: Package contract smoke + run: | + npm pack --dry-run --json > /tmp/m042-pack.json + node - <<'NODE' + const fs = require("fs"); + const data = JSON.parse(fs.readFileSync("/tmp/m042-pack.json", "utf8")); + const files = new Set((data[0].files || []).map((entry) => entry.path)); + for (const required of [ + "templates/_shared/.agent/contracts/state-classes.json", + "templates/_shared/.agent/contracts/state-classes.schema.json", + "lib/state-registry/index.js", + "lib/governance-index/index.js", + "lib/governance-index/cli.js", + ]) { + if (!files.has(required)) throw new Error("package missing " + required); + } + NODE From 735d0bb9c22946c3b6ff54e13e6997e819de6d9b Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:49:06 +0800 Subject: [PATCH 21/28] fix(cli): dispatch governance index command --- bin/cli.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bin/cli.js b/bin/cli.js index d4c56ef4..07aceff9 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -735,6 +735,13 @@ async function initModeGeneral() { case "pr": prCommand(ctx); break; case "event-bus": eventBusCommand(ctx); break; case "state-sync": await stateSync(l1Ctx); break; + case "governance-index": { + const result = governanceIndexCommand(ctx); + if (result && result.ok && result.effect && result.effect.kind === "mutation" && result.effect.committed) { + fireAndForgetSync({ ...l1Ctx, cwd: result.project_root }, { paths: result.effect.paths }).catch(() => {}); + } + break; + } case "help": args.includes("--json") ? cliHelp(ctx) : printHelp(); break; case "dev": await dev(ctx); break; case undefined: From 91b905aae308e810755d40b95e0845d826cc27e5 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:50:20 +0800 Subject: [PATCH 22/28] docs(state-sync): point to registry source of truth --- lib/state-sync/index.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/state-sync/index.js b/lib/state-sync/index.js index 48c7316d..76ee587b 100644 --- a/lib/state-sync/index.js +++ b/lib/state-sync/index.js @@ -14,12 +14,10 @@ // - Strictly additive: the switch case in bin/cli.js only adds // one entry; lib/commands.js is not touched. // -// State classes (10): -// decisions/ waitpoints/ inbox/ tasks/ missions/ plans/ -// dispatch/ workflows/ skills/ branches/registry.json -// -// The first 8 are directories; `branches/registry.json` is a single file. -// New state classes go here and in the matching pre-commit hook. +// State classes come from templates/_shared/.agent/contracts/state-classes.json. +// Policies "tracked" and "derived" are syncable; local/evidence/legacy/ignored +// classes are classified but never staged by this module. The pre-commit hook +// consumes the same registry contract. const fs = require("node:fs"); const path = require("node:path"); From 6c56f367f06a98d607fc7ffa67c3cf0e195f7d1d Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:50:23 +0800 Subject: [PATCH 23/28] docs(cli): describe registry-managed state sync --- bin/cli.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 07aceff9..3b56e740 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -146,10 +146,9 @@ const { eventBusCommand } = require("../lib/event-bus/cli"); // T-FOLLOW-002 v2: `.agent/` state sync CLI surface. // `state-sync [--dry-run|--add|--commit|--push]` lives in -// lib/state-sync.js. It scans the 9 state-class directories -// (decisions/ waitpoints/ tasks/ missions/ plans/ dispatch/ workflows/ -// skills/ branches/registry.json) inside the inner .agent/ git repo -// and stages/commits/pushes them so project-management state stays +// lib/state-sync/index.js. It resolves syncable state through the versioned +// State Class Registry contract in templates/_shared/.agent/contracts/ +// state-classes.json and stages/commits/pushes that project state so it stays // in lock-step across machines. Strictly additive: no changes to // lib/commands.js; the new subcommand is added to the case dispatch below. // From 9b764b7c022c77289c24447e027d9f80e7e911a5 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:50:33 +0800 Subject: [PATCH 24/28] docs(githook): document state registry source of truth --- templates/_shared/.agent/.githooks/README.md | 68 +++++++++----------- 1 file changed, 29 insertions(+), 39 deletions(-) diff --git a/templates/_shared/.agent/.githooks/README.md b/templates/_shared/.agent/.githooks/README.md index 08755cee..be61a6cd 100644 --- a/templates/_shared/.agent/.githooks/README.md +++ b/templates/_shared/.agent/.githooks/README.md @@ -1,64 +1,54 @@ # `.agent/.githooks/` — versioned git hooks for the inner .agent repo -These hooks are committed in this repo so they stay in lock-step with -the rest of the state-sync flow. They are **not** active by default — -each clone must opt in once with `core.hooksPath`. +These hooks are committed so they stay in lock-step with the state-sync flow. +They are **not** active by default; each clone opts in once with +`core.hooksPath`. -## Why a separate `core.hooksPath`? - -- Hooks are per-clone state in normal git. If we put them under - `.git/hooks/`, they get ignored by `git add` and never propagate to - other clones. Storing them under `.githooks/` (visible to git) and - pointing `core.hooksPath` there gives us versioned + distributed - hooks without per-clone maintenance. -- This repo's git is configured to **not** track `.git/hooks/*` as - content. `.githooks/` is the source of truth. - -## One-time setup per clone +## One-time setup ```bash -# from the project root, after cloning: git -C .agent config core.hooksPath .githooks ``` -That's it. The `pre-commit` hook below will now run on every commit -inside the inner `.agent/` repo. +## State classification -## Hooks +The pre-commit reminder does **not** maintain its own list of state directories. +It reads: -| File | Trigger | Behavior | -|---|---|---| -| `pre-commit` | `git commit` | **Reminder only** — scans working tree for un-staged 9 state-class files and prints a warning. Does NOT block the commit. Skip with `git commit --no-verify`. | +```text +.agent/contracts/state-classes.json +``` -## 9 state classes +That contract is the state-class source of truth. Only entries whose +`sync_policy` is `tracked` or `derived` are included in automatic +state-sync reminders. `local`, `evidence`, `legacy`, and `ignored` +classes are not automatically staged. -``` -decisions/ waitpoints/ tasks/ missions/ plans/ -dispatch/ workflows/ skills/ branches/registry.json -``` +This keeps the hook aligned with the outer `lib/state-sync/` implementation. -A new state class goes in three places: +## Hook behavior -1. `lib/state-sync.js` `STATE_DIRS` / `STATE_FILES` (outer repo) -2. `.agent/.githooks/pre-commit` `STATE_DIRS` / `STATE_FILES` (this repo) -3. `tests/state-sync.test.js` (outer repo) +| File | Trigger | Behavior | +|---|---|---| +| `pre-commit` | `git commit` | Reminder only — reports unstaged/untracked registry-managed syncable state. It does not block the commit. | -## Disabling / uninstalling +If the registry contract is missing, the hook fails open with a warning and +asks the developer to run `cortex-agent update`; it does not invent a +fallback state list. + +## Disabling ```bash -# skip for one commit: +# skip once git -C .agent commit --no-verify -# disable permanently for this clone: +# disable for this clone git -C .agent config --unset core.hooksPath ``` -The hook scripts stay on disk in this repo; uninstalling just stops -git from invoking them. - ## See also +- `contracts/state-classes.json` - `bin/cli.js` `state-sync` subcommand -- `lib/state-sync.js` (outer repo) -- `tests/state-sync.test.js` (outer repo) -- `.agent/AGENTS.md` "Workflow" section +- `lib/state-registry/` +- `lib/state-sync/` From b4c6238f58a27bcd4e9c48a2320cbf9fa79f3a7e Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:53:22 +0800 Subject: [PATCH 25/28] fix(governance): keep waitpoint index writes schema-aligned --- .../skills/management-api/scripts/index.js | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/templates/_shared/.agent/skills/management-api/scripts/index.js b/templates/_shared/.agent/skills/management-api/scripts/index.js index 8553fe57..3fde14b5 100755 --- a/templates/_shared/.agent/skills/management-api/scripts/index.js +++ b/templates/_shared/.agent/skills/management-api/scripts/index.js @@ -857,6 +857,23 @@ function isExpired(waitpoint, nowMs) { return Number.isFinite(ts) && ts <= nowMs; } +function isValidOwnerWorkflow(value) { + return typeof value === "string" && /^\/[A-Za-z0-9][A-Za-z0-9-]*$/.test(value); +} + +function waitpointIndexEntry(record, file) { + return { + waitpoint_id: record.waitpoint_id, + path: rel(file), + status: record.status, + owner_workflow: record.owner_workflow, + gate_action: record.gate && record.gate.action, + resource_ref: record.gate && record.gate.resource_ref, + decision_id: record.decision_id === undefined ? null : record.decision_id, + updated_at: record.updated_at, + }; +} + function queryInbox() { const items = listJsonObjects(path.join(agentRoot, "inbox")) .map(({ file, data }) => ({ ...data, path: rel(file) })) @@ -1194,7 +1211,9 @@ function createWaitpoint() { const payload = parsePayload(); const waitpointId = safeId(option("--waitpoint-id", payload.waitpoint_id), "WP"); const ownerWorkflow = String(option("--owner-workflow", payload.owner_workflow || "")).trim(); - if (!ownerWorkflow) fail("invalid_waitpoint_owner", "--owner-workflow is required."); + if (!isValidOwnerWorkflow(ownerWorkflow)) { + fail("invalid_waitpoint_owner", "--owner-workflow must match /."); + } const reason = String(option("--reason", payload.reason || "")).trim(); if (!reason) fail("invalid_waitpoint_reason", "--reason is required."); const action = String(option("--action", payload.gate?.action || "")).trim(); @@ -1229,14 +1248,12 @@ function createWaitpoint() { workflow_gate: gate, }; writeJson(file, next); - upsertIndexEntry("waitpoints", "waitpoints", { - waitpoint_id: waitpointId, - path: rel(file), - status: next.status, - owner_workflow: ownerWorkflow, - decision_id: next.decision_id, - updated_at: timestamp, - }, (entry, current) => entry.waitpoint_id === current.waitpoint_id); + upsertIndexEntry( + "waitpoints", + "waitpoints", + waitpointIndexEntry(next, file), + (entry, current) => entry.waitpoint_id === current.waitpoint_id, + ); printJson({ ok: true, action: "waitpoints create", @@ -1268,14 +1285,12 @@ function cancelWaitpoint() { workflow_gate: gate, }; writeJson(file, next); - upsertIndexEntry("waitpoints", "waitpoints", { - waitpoint_id: waitpointId, - path: rel(file), - status: "canceled", - owner_workflow: existing.owner_workflow, - decision_id: existing.decision_id, - updated_at: timestamp, - }, (entry, current) => entry.waitpoint_id === current.waitpoint_id); + upsertIndexEntry( + "waitpoints", + "waitpoints", + waitpointIndexEntry(next, file), + (entry, current) => entry.waitpoint_id === current.waitpoint_id, + ); printJson({ ok: true, action: "waitpoints cancel", @@ -1330,14 +1345,12 @@ function releaseWaitpoint() { workflow_gate: gate, }; writeJson(file, next); - upsertIndexEntry("waitpoints", "waitpoints", { - waitpoint_id: waitpointId, - path: rel(file), - status: "released", - owner_workflow: existing.owner_workflow, - decision_id: decisionId, - updated_at: timestamp, - }, (entry, current) => entry.waitpoint_id === current.waitpoint_id); + upsertIndexEntry( + "waitpoints", + "waitpoints", + waitpointIndexEntry(next, file), + (entry, current) => entry.waitpoint_id === current.waitpoint_id, + ); printJson({ ok: true, action: "waitpoints release", @@ -1367,14 +1380,12 @@ function releaseMatchingWaitpoints(decisionId, resolvedBy, rationale, timestamp) writeJson(file, next); changedPaths.push(rel(file)); changedResources.push(`waitpoint:${data.waitpoint_id}`); - upsertIndexEntry("waitpoints", "waitpoints", { - waitpoint_id: data.waitpoint_id, - path: rel(file), - status: "released", - owner_workflow: data.owner_workflow, - decision_id: decisionId, - updated_at: timestamp, - }, (entry, current) => entry.waitpoint_id === current.waitpoint_id); + upsertIndexEntry( + "waitpoints", + "waitpoints", + waitpointIndexEntry(next, file), + (entry, current) => entry.waitpoint_id === current.waitpoint_id, + ); } if (changedPaths.length > 0) { changedPaths.push(rel(path.join(agentRoot, "waitpoints", "index.json"))); From a5b5102addcf1ab0e5db5317cd36b4726778bfde Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:53:45 +0800 Subject: [PATCH 26/28] test(governance): assert waitpoint index contract on write --- tests/cli/issue-15-waitpoints-help-state-sync.test.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/cli/issue-15-waitpoints-help-state-sync.test.js b/tests/cli/issue-15-waitpoints-help-state-sync.test.js index 6eb9388e..4a41e042 100644 --- a/tests/cli/issue-15-waitpoints-help-state-sync.test.js +++ b/tests/cli/issue-15-waitpoints-help-state-sync.test.js @@ -238,7 +238,7 @@ test("issue #15: invalid gate value fails closed (exit != 0) and does not push s "create", "--waitpoint-id", "WP-rejected", "--gate", "hacker", // invalid gate value - "--owner-workflow", "test", + "--owner-workflow", "/test", "--reason", "regression", "--action", "release", "--resource-ref", "branch:main", @@ -276,7 +276,7 @@ test("issue #15: successful waitpoints write DOES push state (positive control)" "create", "--waitpoint-id", "WP-OK", "--gate", "mission", - "--owner-workflow", "test", + "--owner-workflow", "/test", "--reason", "regression control", "--action", "release", "--resource-ref", "branch:main", @@ -303,6 +303,13 @@ test("issue #15: successful waitpoints write DOES push state (positive control)" const remoteFiles = originTree(originDir); assert.ok(remoteFiles.includes("waitpoints/WP-OK.json"), "mutation-owned waitpoint must reach remote"); assert.ok(remoteFiles.includes("waitpoints/index.json"), "mutation-owned index must reach remote"); + + const waitpointIndex = JSON.parse(fs.readFileSync(path.join(agentDir, "waitpoints", "index.json"), "utf8")); + const entry = waitpointIndex.waitpoints.find((item) => item.waitpoint_id === "WP-OK"); + assert.equal(entry.owner_workflow, "/test"); + assert.equal(entry.gate_action, "release"); + assert.equal(entry.resource_ref, "branch:main"); + for (const rel of PRE_EXISTING) { assert.equal(remoteFiles.includes(rel), false, rel + " must not be swept into remote commit"); } From 8ab0f44d7ef3216e7744db8c597e5733190dc50d Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:56:24 +0800 Subject: [PATCH 27/28] test(governance): lock waitpoint writer index contract --- .../management/management-writer-cli.test.js | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/management/management-writer-cli.test.js b/tests/management/management-writer-cli.test.js index f3183c85..fd296160 100644 --- a/tests/management/management-writer-cli.test.js +++ b/tests/management/management-writer-cli.test.js @@ -101,3 +101,40 @@ test("decisions request help and writer accept the documented gate-action and op assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).decision.gate.action, "architecture"); }); + + +test("waitpoint writer rejects non-schema owner workflow and writes schema-complete index entries", (t) => { + const project = createProject(); + t.after(() => fs.rmSync(project, { recursive: true, force: true })); + + let result = run(project, project, [ + "waitpoints", "create", + "--waitpoint-id", "WP-OWNER-BAD", + "--gate", "mission", + "--owner-workflow", "test", + "--reason", "schema guard", + "--action", "architecture", + "--resource-ref", "mission:M-TEST", + ]); + assert.notEqual(result.status, 0); + assert.equal(JSON.parse(result.stdout).error.code, "INVALID_WAITPOINT_OWNER"); + + result = run(project, project, [ + "waitpoints", "create", + "--waitpoint-id", "WP-OWNER-GOOD", + "--gate", "mission", + "--owner-workflow", "/test", + "--reason", "schema guard", + "--action", "architecture", + "--resource-ref", "mission:M-TEST", + ]); + assert.equal(result.status, 0, result.stderr + result.stdout); + + const index = JSON.parse(fs.readFileSync(path.join(project, ".agent", "waitpoints", "index.json"), "utf8")); + const entry = index.waitpoints.find((item) => item.waitpoint_id === "WP-OWNER-GOOD"); + assert.ok(entry); + assert.equal(entry.owner_workflow, "/test"); + assert.equal(entry.gate_action, "architecture"); + assert.equal(entry.resource_ref, "mission:M-TEST"); + assert.equal(entry.decision_id, null); +}); From 84cf727257745f64e8462bd44cbc5b39c77d8b27 Mon Sep 17 00:00:00 2001 From: Kucell <50976390+Kucell@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:56:38 +0800 Subject: [PATCH 28/28] ci(m042): lock waitpoint projection writer contract --- .github/workflows/m042-state-registry-index.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/m042-state-registry-index.yml b/.github/workflows/m042-state-registry-index.yml index a0b22969..7fb8ed24 100644 --- a/.github/workflows/m042-state-registry-index.yml +++ b/.github/workflows/m042-state-registry-index.yml @@ -13,6 +13,7 @@ on: - "tests/state-registry/**" - "tests/state-sync/**" - "tests/governance-index/**" + - "tests/management/management-writer-cli.test.js" - "tests/cli/issue-15-waitpoints-help-state-sync.test.js" - ".github/workflows/m042-state-registry-index.yml" workflow_dispatch: @@ -40,6 +41,8 @@ jobs: node --test tests/install/install-state-githooks.test.js - name: M-041 exact-sync regression run: node --test tests/cli/issue-15-waitpoints-help-state-sync.test.js + - name: Management writer projection contract + run: node --test tests/management/management-writer-cli.test.js - name: Syntax and module smoke run: | bash -n templates/_shared/.agent/.githooks/pre-commit