diff --git a/agents/honker/work/queue-194/BUILD.md b/agents/honker/work/queue-194/BUILD.md index 6e3568701..d14c8a702 100644 --- a/agents/honker/work/queue-194/BUILD.md +++ b/agents/honker/work/queue-194/BUILD.md @@ -755,3 +755,39 @@ Follow-up (option 2, not in b3): run `beforeWrite` inside `writer.write`, under `organization.mjs:188` check. Until then a window remains: a write landing between the early read and the writer's lock still lets a fire or delete revoke and then refuse `revision-conflict`, and an `outcome-unknown` after a revoke still doesn't say the tokens are gone. + +### b4: rebase onto c3d88840c (sage-2157, sage-2164, 2026-10-21T21:20Z) + +`honker/row194-b4` is b3's five commits replayed onto c3d88840c (rows 196, 210, 213, 234, 235, C2-03a/04 +landed in between). b3 is unchanged. The first commit conflicted in 18 hunks across 11 files and the second +in 4; every one kept both sides (list in honker-0067). The `webui/src/bus.mjs` channel-proof pin was +recomputed on the merged file. + +What the rebase exposed, fixed on b4: +- Creative hire (row 213, M10). `closed()` lets a creative definition through `definitionFor` for the + human's add and change, and hire used `definitionFor`. Hire now refuses `definition-not-hireable` for any + CREATIVE_DEFINITIONS entry, and `checkHireable` refuses a creative name in a role's hireable list at load. +- Reload and library projects (C2-04). A library entry has no `root` and its mirror is ensured at start, but + `fixedAtStart().roots` took every project's root, so a changed mapping reloaded live. `roots` now holds the + non-library roots, the Credentials store's set, and `libraries` fixes each library entry whole (reload T14). +- Creative delete (sage-2164). `deleteRefusal` refuses END_PROTECTED, creative definitions included, while + the listing's `protected` is false for a creative instance, so the Agents page offered a Delete the server + refuses. The listing adds `endProtected`, the same check `deleteRefusal` runs, and the page hides Delete on + it. The refusal stays; the human delete of a creative instance is a DEFERRED entry. A human can't fire one + either (fire refuses END_PROTECTED for every actor); `instances.set` with `launch: false` delists it. +- Row 213's companion listing test (`instance-settings.test.mjs:274`) didn't have 194's `kept`; it now + pins `kept` and `endProtected`. +- DEFERRED C1 (Duckworth N2 on row 196): `testerNoSignIn` bound the next row touching `serve.mjs`. 194 + touches it, so the dead parameter is removed from `serve.mjs`, `webui/src/cli.mjs` and `sso.test.mjs`. +- Manifest notes for Webby (sage-2166): C1 is closed by 194 (the three `testerNoSignIn` lines), and + `docs/plans/DEFERRED.md` is its own manifest path (the creative delete entry; C1 marked at landing). +- Slot run 1 (sage-2217, 2026-10-21T22:36Z, 25 whole files on 646cd493a plus the tree): 312 pass, 2 fail. + Both came from combining rows on the c3d88840c base, so committed 646cd493a fails both too: + - `instance-settings.test.mjs:283` (row 213) expected the writer's `restart` true on a creative definition + change. 194's D3 makes the writer always answer false, and the bus sets restart from its reload, so + bean isn't running and a reload is the designed answer (reload T14 covers the running case). Repinned to false. + - `agents.test.mjs` T16 started the tester listener on a TCP port. Row 196 (7247d80c7) serves it only on + the private socket (`tester listener requires private socket`), so T16 now uses `startTesterSocket` + under a temp data root and `testerRequest`, as `tester-socket.test.mjs` and `sso.test.mjs` do. +- Drift: c3d88840c..eb017d77f overlaps the manifest only on `docs/plans/DEFERRED.md` (merge-tree clean); + the requirements tests changed their own counts, `host-run.test.mjs` took the #453 T27 fix. diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index 1bde9a15d..41752848f 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -372,6 +372,18 @@ at every gate. Started 2026-09-12 during the control board MVP. credential and a real `release.sh activate`, and records both results on the re-pin row. Morgana's wording (row 244 BUILD.md), Webby N2 on row 244 r1 (#1757). (2026-10-21) +- **Creative instances: the human can't delete one.** (Sage's ruling + sage-2164 on the row 194 b4 rebase, #1692.) Row 213 put the creative + definitions in END_PROTECTED, and row 194's `deleteRefusal` + (`instance-settings.mjs`) refuses END_PROTECTED for every actor, so + the human can't delete a companion instance and fire refuses one too. + The Agents page hides Delete on the listing's `endProtected`. The fix + threads the actor through `deleteRefusal` and the launcher's delete + pass, so the human may delete one and the tester may not + (`creativeHumanOnly`). Until then, the human can delist + (instances.set launch: false) but neither fire nor delete removes a + creative entry (sage-2166). The row covers fire and delete together; + Sage files it. (2026-10-21) ## Queue diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 54bd3f928..1f3c55ddd 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -207,9 +207,12 @@ export async function startBroker({ const fixedAtStart = b => ({ roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])), sync: canonicalJson(b.tracker?.sync ?? null), - roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()), + roots: canonicalJson(Object.values(b.projects ?? {}).filter(p => p.kind !== 'library').map(p => p.root).sort()), + // A library entry has no root, and its mirror is ensured at start (C2-04), so each one is fixed whole. + libraries: canonicalJson(Object.fromEntries(Object.entries(b.projects ?? {}).filter(([, p]) => p.kind === 'library'))), }); const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots + && fixedAtStart(b).libraries === boot.libraries && Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null))); const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)])); const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`); diff --git a/packages/bus/tests/reload.test.mjs b/packages/bus/tests/reload.test.mjs index 8fd964095..e7f04196a 100644 --- a/packages/bus/tests/reload.test.mjs +++ b/packages/bus/tests/reload.test.mjs @@ -13,6 +13,7 @@ import { Store } from '../src/store.mjs'; import { loadBusiness } from '../../business/src/business.mjs'; import { resolveInstance } from '../../business/src/resolve.mjs'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs'; +import { mappingEntry } from '../../library/tests/helpers.mjs'; // The bus form of the business file, built as cli bootConfig builds it. function busForm(root, rolesDir, id = 'demo') { @@ -22,11 +23,12 @@ function busForm(root, rolesDir, id = 'demo') { return busBusiness(b, resolved); } -async function setup(t) { +async function setup(t, mutate) { const root = mkdtempSync(join(tmpdir(), 'reload-')); const rolesDir = rolesCopy(root); const doc = businessDoc(root, 'demo'); doc.plugins = { harness: ['pi', 'claude-code'] }; + mutate?.(doc); const file = writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } }); @@ -152,6 +154,20 @@ test('T14 a file whose project root differs from the boot copy is not reloaded, assert.equal(f.reloads.length, 0); }); +test('T14 a file whose library mapping differs from the boot copy is not reloaded, because the mirror is ensured at start', async (t) => { + const f = await setup(t, doc => { doc.projects.books = mappingEntry(); }); + const doc = JSON.parse(readFileSync(f.file, 'utf8')); + doc.projects.books.branch = 'other'; + writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 }); + const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' }); + assert.equal(added.restart, true); + assert.equal(f.reloads.length, 0); + // The same library entry reloads. + const g = await setup(t, doc => { doc.projects.books = mappingEntry(); }); + assert.equal(g.ask('instances.set', { revision: g.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' }).restart, false); + assert.equal(g.reloads.length, 1); +}); + test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => { const root = mkdtempSync(join(tmpdir(), 'reload-unit-')); t.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/packages/business/src/instance-settings.mjs b/packages/business/src/instance-settings.mjs index 73364355f..49a0dabb6 100644 --- a/packages/business/src/instance-settings.mjs +++ b/packages/business/src/instance-settings.mjs @@ -26,11 +26,14 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v const HIRE_VAR = key => key === "model" || key.startsWith("limits."); // Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null. -// The last-listed check runs when the proposed file is validated (deletedDocument). +// The last-listed check runs when the proposed file is validated (deletedDocument). A creative +// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The +// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse. +const endProtected = definition => END_PROTECTED.includes(definition); export function deleteRefusal(document, rolesDir, instance) { if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" }; const entry = document.roles[instance]; - if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" }; + if (endProtected(entry.definition)) return { code: "organization-definition-refused" }; const references = []; if (document.launch?.by === instance) references.push("launch.by"); for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`); @@ -164,6 +167,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r launcher: launch ? launch.by === name : false, protected: closed(entry.definition), kept: (launch?.keep ?? []).includes(name), + endProtected: endProtected(entry.definition), }])); // Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs. return { @@ -297,6 +301,11 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys"); } if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable"); + // Row 213 (M10): definitionFor lets a creative definition through for the human's add and + // change. A hire is the chief's, so it refuses one here. + if (CREATIVE_DEFINITIONS.includes(definition)) { + throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`); + } definitionFor(rolesDir, definition); if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired"); if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept"); diff --git a/packages/business/src/role.mjs b/packages/business/src/role.mjs index 67691c88f..e0868ad62 100644 --- a/packages/business/src/role.mjs +++ b/packages/business/src/role.mjs @@ -81,11 +81,13 @@ export { unhireableAction }; // Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must // be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file -// is validated without its own "hireable" check, so a list can't recurse. +// is validated without its own "hireable" check, so a list can't recurse. A creative definition +// (row 213, M10) is never hireable: only the human adds one. function checkHireable(hireable, file) { const dir = dirname(file); return requireDistinctList(hireable, "role hireable", (name) => { requireId(name, "role hireable entry"); + if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`); const path = join(dir, `${name}.json`); if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`); const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false }); diff --git a/packages/business/tests/instance-settings.test.mjs b/packages/business/tests/instance-settings.test.mjs index cc0271b5c..27f9a5ebb 100644 --- a/packages/business/tests/instance-settings.test.mjs +++ b/packages/business/tests/instance-settings.test.mjs @@ -61,7 +61,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => { const view = f.settings.read("acme"); assert.match(view.revision, /^[a-f0-9]{64}$/); assert.equal(view.launch, true); - assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false }); + assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false }); assert.equal(view.instances.chief.launcher, true); assert.equal(view.instances.assistant.protected, true); assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true }); @@ -75,12 +75,12 @@ test("row 165 add: two instances join, validate exits 0, the log names the human const f = fixture(); const view = f.settings.read("acme"); const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason"); - assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false }); + assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false }); assert.notEqual(added.revision, view.revision); assert.match(added.transaction, /\S/); assert.equal(added.restart, false); const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason"); - assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false }); + assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false, endProtected: false }); const file = JSON.parse(readFileSync(f.file, "utf8")); assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" }); assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]); @@ -271,7 +271,7 @@ test("row 213 a creative instance is the human's: add, vars and definition chang e => e.code === "organization-creative-human-only", `${actor} redefine`); } let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason"); - assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false }); + assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true }); for (const actor of ["chief-of-staff.chief", "tester"]) { for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) { assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor), @@ -280,7 +280,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang } done = f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", definition: "ideas", vars: { ...persona, "persona.name": "Bean Two" } }, "jason"); assert.equal(done.instances.bean.definition, "ideas"); - assert.equal(done.restart, true); + // Row 194 (D3): the writer's restart is false; the bus sets it from its reload, and bean isn't running. + assert.equal(done.restart, false); // A persona var on a definition that isn't creative is refused by the validator (D1). refuses(f, { instance: "bean", op: "change", definition: "security-reviewer" }, "BusinessError"); assert.equal(f.validate().status, 0); diff --git a/packages/business/tests/lifecycle-settings.test.mjs b/packages/business/tests/lifecycle-settings.test.mjs index 176afe6e5..5c0d0015d 100644 --- a/packages/business/tests/lifecycle-settings.test.mjs +++ b/packages/business/tests/lifecycle-settings.test.mjs @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { instanceSettings } from "../src/instance-settings.mjs"; +import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs"; const CLI = join(REPO, "packages", "business", "src", "cli.mjs"); @@ -152,7 +153,7 @@ test("D2.2 hire adds and lists a new instance, logs the chief's context, and set const f = fixture(); const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } }); assert.deepEqual(done.instances["sec-2"], { - definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, + definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false, }); assert.equal(done.restart, false); assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]); @@ -182,6 +183,8 @@ test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fi doc.launch.keep = ["sec-3"]; }); refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable"); + // Row 213 (M10): a creative definition is the human's to add, never the chief's to hire. + for (const definition of CREATIVE_DEFINITIONS) refuses(f, () => hire(f, { name: "muse-1", definition }), "definition-not-hireable"); refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept"); refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists"); refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists"); diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs index 7ee63c3d9..d5d5eb5bd 100644 --- a/packages/business/tests/role.test.mjs +++ b/packages/business/tests/role.test.mjs @@ -4,6 +4,7 @@ import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs"; import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs"; +import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; function refuses(fn, pattern, exitCode = 2) { assert.throws(fn, (error) => { @@ -314,6 +315,10 @@ test("hireable: a mixed definition refuses, a clean list loads, and the named fi test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => { refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/); refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/); + // Row 213 (M10): a creative definition refuses by name, before its file is read. + for (const name of CREATIVE_DEFINITIONS) { + refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`)); + } refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/); refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/); refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/); diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 3dfb76b57..1599d4776 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -114,7 +114,7 @@ try { const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') }; try { testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board, - tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads, + tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads, projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args), libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args), instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args), diff --git a/packages/webui/src/public/agents.js b/packages/webui/src/public/agents.js index 8f3f794a0..756666aaa 100644 --- a/packages/webui/src/public/agents.js +++ b/packages/webui/src/public/agents.js @@ -40,7 +40,8 @@ if (!c.isTester) { acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`)); acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep')); - if (!e.protected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`)); + // endProtected is the server's delete check, creative definitions included (sage-2164). + if (!e.endProtected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`)); } return `
${txt(r)}`).join(' ') : 'none'}