diff --git a/extensions/iterator.js b/extensions/iterator.js index 6f326ab..35b6ff2 100644 --- a/extensions/iterator.js +++ b/extensions/iterator.js @@ -57,6 +57,7 @@ import { autoCompleteMessage, AUTO_PHASE_FOR_STEP, bundleExists, + classifyRoleModel, featuresDirEntries, composeAmbientContext, completeFeatureWaveAbort, @@ -365,20 +366,57 @@ export default function iteratorExtension(pi) { const modelOptions = async () => { try { const models = (await lastCtx?.modelRegistry?.getAvailable?.()) || []; - const out = models.map((m) => ({ - id: `${m.provider}/${m.id}`, - label: `${m.provider}/${m.id}`, - })); + // Carry the same verdict the save gate applies, so the form can show + // an unusable choice instead of letting it fail at provider call time. + const out = models.map((m) => { + const id = `${m.provider}/${m.id}`; + const verdict = classifyRoleModel(id, lastCtx?.model, models); + return { + id, + label: id, + ...(verdict.ok ? {} : { unusable: true, note: verdict.detail }), + }; + }); return out.length ? out : null; } catch { return null; } }; + /** + * Refuse role models this session cannot route before anything is written — + * an unusable choice is otherwise invisible until the provider rejects it + * mid-turn, far from the settings form that caused it. + */ + const unusableRoleModels = async (values) => { + const keys = Object.keys(values || {}).filter((k) => + k.endsWith("_model"), + ); + if (!keys.length) return []; + const available = (await lastCtx?.modelRegistry?.getAvailable?.()) || []; + if (!available.length) return []; + return keys + .map((key) => ({ + key, + verdict: classifyRoleModel(values[key], lastCtx?.model, available), + })) + .filter((r) => !r.verdict.ok) + .map( + ({ key, verdict }) => + `${key}: ${verdict.detail}` + + (verdict.suggestion ? ` — did you mean ${verdict.suggestion}?` : ""), + ); + }; + /** Deterministically write the settings op and refresh the dashboard. */ const saveSettings = async (values) => { const cwd = ctxCwd(); try { + // Throw rather than return: the catch below is the one place that + // reports a failed save, so a headless caller cannot silently lose + // the write the way an early return would let it. + const unusable = await unusableRoleModels(values); + if (unusable.length) throw new Error(unusable.join("; ")); const result = await runJson(scriptPath("write"), [], { cwd, stdin: JSON.stringify({ op: "settings", values }), diff --git a/lib/bundle.mjs b/lib/bundle.mjs index 0b64def..06545b8 100644 --- a/lib/bundle.mjs +++ b/lib/bundle.mjs @@ -335,6 +335,57 @@ export const OKF_AREAS = { export const OKF_AREA_NAMES = Object.keys(OKF_AREAS); +// --------------------------------------------------------------------------- +// The agent-facing bundle contract (memory/EXTENSIONS.md) + +/** + * The body of memory/EXTENSIONS.md. It lives here rather than in write.mjs so + * gather can diff a bundle's stored copy against it (`contractStale`) without + * importing the writer — write.mjs already imports gather, so the reverse + * direction would be a cycle. + * + * This is the one document that must make the bundle usable by an agent with + * no iterator extension loaded, so it states the read order, which files are + * machine-owned, and how to discover the writer's ops. + */ +export const EXTENSIONS_BODY = `Guidance for agents and extensions reading or updating this bundle. + +# Reading + +* Start at \`memory/index.md\`, follow the area indexes, then open only the + relevant concept files (progressive disclosure — never bulk-read the bundle). +* For work in progress, read \`memory/plan.md\` and \`memory/features/index.md\` + first: they say what is being built and where each feature stands. +* Before changing code, read the concepts whose \`files:\` anchors match the + files you are about to touch — \`decisions/\` first (new work must not + silently contradict one), then architecture, patterns, pitfalls, and setup. +* A concept ID is the bundle-relative path without \`.md\`; feature IDs/slugs + are their filenames without \`.md\` and are the stable identity used by tools. +* Non-reserved concept files require YAML frontmatter with a non-empty + \`type\`; preserve unknown keys and tolerate unknown concept types. +* \`memory/format.md\` documents the metadata schema for every document type in + this bundle; read it before authoring or parsing frontmatter. + +# Writing + +* Safe writes create or update one concept file at a time, keep markdown + human-readable and diffable, update \`timestamp\`, regenerate affected + indexes, and append a newest-first \`memory/log.md\` entry for meaningful + changes. +* Knowledge writes should go through the iterator writer (\`write.mjs\` ops + \`memorize\` / \`apply-review\`); plan/feature writes through its plan/feature ops. +* These files are machine-owned — never hand-edit them: \`index.md\` and every + area \`index.md\`, \`features/*.md\` frontmatter, \`features/index.md\`, + \`log.md\`, \`state.md\`, \`usage.md\`, \`settings.md\`, and the plan's + \`# Features\` section. Prose in a feature body is yours to write. +* Discover the writer's contract instead of guessing: \`write.mjs --schema\` + lists every op and \`write.mjs --schema \` prints one op's payload. The + writer validates before writing and writes nothing on failure. +* Derived state (a feature's readiness, what it is waiting on, the plan's + stage) comes from \`gather.mjs --step \`. Render what it reports; do + not recompute it from raw statuses. +`; + /** Rebuild an area index's bullet list, preserving its heading and prose. */ export function regenerateAreaIndex(memDir, area) { const dir = join(memDir, area); diff --git a/lib/gather.mjs b/lib/gather.mjs index 399b237..72050e0 100644 --- a/lib/gather.mjs +++ b/lib/gather.mjs @@ -43,6 +43,7 @@ import { planStage, readiness, satisfiedSet } from "./status.mjs"; import { backlogItems, body, + EXTENSIONS_BODY, frontmatter, globToRegExp, listy, @@ -1225,14 +1226,24 @@ export function gatherKnowledge(startDir) { })); // memory/format.md is copied from the template once (on the first plan - // write) and drifts as the template evolves — surface that. + // write or at init) and drifts as the template evolves — surface that. const template = resolveTemplate("format.md"); const formatFile = join(b.memDir, "format.md"); + const formatMissing = !existsSync(formatFile); const formatStale = !!template && - existsSync(formatFile) && + !formatMissing && readFileSync(template, "utf8") !== readFileSync(formatFile, "utf8"); + // EXTENSIONS.md is generated boilerplate, so it drifts the same way once + // the contract gains rules. Compare the stored body against the current + // one, ignoring the frontmatter and any project-specific preamble above it. + const contractFile = join(b.memDir, "EXTENSIONS.md"); + const contractMissing = !existsSync(contractFile); + const contractStale = + !contractMissing && + !body(readFileSync(contractFile, "utf8")).includes(EXTENSIONS_BODY.trim()); + const staleCount = memories.filter((m) => m.stale).length; // The forcing sentence consolidate follows: the review round ALWAYS opens // (even all-keep) so the run produces a visible outcome — the model must @@ -1303,6 +1314,9 @@ export function gatherKnowledge(startDir) { } : null, formatStale, + formatMissing, + contractStale, + contractMissing, }; } diff --git a/lib/pi-tools.mjs b/lib/pi-tools.mjs index eaa7b6a..bec6ada 100644 --- a/lib/pi-tools.mjs +++ b/lib/pi-tools.mjs @@ -526,6 +526,68 @@ export function resolveRoleModel( return model ? { model, switchRequired: true } : null; } +/** + * Judge a configured role model against what this session can actually reach, + * so a bad choice is refused where it is made instead of surfacing later as a + * provider authentication error. + * + * Structural only — identity and the registry listing decide, never a probe + * and never a credential sentinel. Two verdicts are refused: + * + * `unknown` the registry does not list it at all, so no route exists. + * `route` the registry lists it, but the SAME model id is also listed + * under the active runtime model's provider. That duplicate is + * the trap: both entries look valid in a dropdown while only the + * active provider carries the host's routing and credentials. + * + * A model on some other provider with no such duplicate is left alone — it may + * be perfectly usable with its own credentials, and refusing it would make + * legitimate models unsettable. + */ +export function classifyRoleModel(spec, activeModel, available) { + if (!spec || spec === "active") return { ok: true }; + const identity = parseModelSpec(spec); + if (!identity) + return { + ok: false, + reason: "malformed", + detail: `"${spec}" is not "active" or "/"`, + }; + if (modelMatchesSpec(activeModel, spec)) return { ok: true }; + + const list = Array.isArray(available) ? available : []; + // No registry to check against — stay out of the way rather than guess. + if (!list.length) return { ok: true }; + + const listed = list.some( + (m) => m?.provider === identity.provider && m?.id === identity.id, + ); + if (!listed) { + const sameId = list.find((m) => m?.id === identity.id); + return { + ok: false, + reason: "unknown", + detail: `${spec} is not available in this session`, + suggestion: sameId ? `${sameId.provider}/${sameId.id}` : null, + }; + } + + const activeProvider = activeModel?.provider; + if (activeProvider && activeProvider !== identity.provider) { + const onActiveProvider = list.some( + (m) => m?.provider === activeProvider && m?.id === identity.id, + ); + if (onActiveProvider) + return { + ok: false, + reason: "route", + detail: `${spec} is listed, but ${identity.id} is also available on the active provider "${activeProvider}" — the configured one routes elsewhere`, + suggestion: `${activeProvider}/${identity.id}`, + }; + } + return { ok: true }; +} + /** Modern Pi resolves void on success; older runtimes may return a boolean. */ export function modelSwitchSucceeded(result) { return result !== false; diff --git a/lib/views/settings.mjs b/lib/views/settings.mjs index bfe0590..a851576 100644 --- a/lib/views/settings.mjs +++ b/lib/views/settings.mjs @@ -7,7 +7,10 @@ * input: { step:"settings", branch, plan, * settings:{:}, // defaults merged * defined, // memory/settings.md exists - * models?: [{id,label}] } // pi model registry, for model dropdowns + * models?: [{id,label,unusable?,note?}] } // pi model registry, for + * // model dropdowns; the + * // unusable verdict is + * // supplied, never derived * output: one JSON line to stdout — * { type:"settings", values:{:} } * | { type:"settings-close" } when the shell-owned modal is dismissed. @@ -30,6 +33,10 @@ h1{font-family:var(--font-display);font-size:var(--fs-xl);font-weight:600;margin select.ctl,input.ctl{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm); color:var(--text);font-size:var(--fs-sm);font-family:var(--font-mono);padding:6px 10px;outline:none;min-width:140px} select.ctl:focus,input.ctl:focus{border-color:var(--accent)} +.ctlwrap{display:flex;flex-direction:column;gap:4px;align-items:flex-end} +.ctlwarn{font-size:var(--fs-xs);color:var(--dot-yellow);max-width:280px;text-align:right;line-height:1.4} +.ctlwarn:empty{display:none} +select.ctl.bad{border-color:var(--dot-yellow)} input.ctl[type=number]{width:90px;min-width:90px} .toggle{display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden} .toggle button{font-size:var(--fs-sm);padding:6px 16px;border:none;cursor:pointer;background:var(--bg);color:var(--text-muted);font-family:inherit} @@ -152,22 +159,48 @@ function makeControl(key, def){ } // model: dropdown when the pi registry rode along, free text otherwise. if(MODELS && MODELS.length){ + const wrap = document.createElement('div'); wrap.className='ctlwrap'; const s = document.createElement('select'); s.className='ctl'; const act = document.createElement('option'); act.value='active'; act.textContent='active (session model)'; s.appendChild(act); - MODELS.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=m.label||m.id; s.appendChild(o); }); + MODELS.forEach(m => { + const o=document.createElement('option'); o.value=m.id; + // The verdict rides along with the option — a model this session cannot + // route reads as unusable before it is ever saved. + o.textContent=(m.label||m.id)+(m.unusable?' \\u2014 unusable':''); + if(m.unusable){ o.dataset.unusable='1'; if(m.note) o.dataset.note=m.note; } + s.appendChild(o); + }); if(![...s.options].some(o => o.value === String(cur[key]))){ - const o=document.createElement('option'); o.value=String(cur[key]); o.textContent=String(cur[key])+' (unlisted)'; s.appendChild(o); + const o=document.createElement('option'); o.value=String(cur[key]); + o.textContent=String(cur[key])+' (unlisted)'; o.dataset.unusable='1'; + o.dataset.note='not offered by this session\\u2019s model registry'; + s.appendChild(o); } s.value = String(cur[key]); - s.addEventListener('change', () => { cur[key]=s.value; refresh(); }); - return s; + const warn = document.createElement('div'); warn.className='ctlwarn'; + const paint = () => { + const sel = s.options[s.selectedIndex]; + const bad = sel && sel.dataset.unusable === '1'; + warn.textContent = bad ? (sel.dataset.note || 'this session cannot use this model') : ''; + s.classList.toggle('bad', !!bad); + }; + paint(); + s.addEventListener('change', () => { cur[key]=s.value; paint(); refresh(); }); + wrap.appendChild(s); wrap.appendChild(warn); + return wrap; } + const wrap = document.createElement('div'); wrap.className='ctlwrap'; const i = document.createElement('input'); i.className='ctl'; i.placeholder = 'active or provider/model-id'; i.value = String(cur[key]); i.addEventListener('input', () => { cur[key]=i.value.trim(); refresh(); }); - return i; + // No registry rode along, so nothing here is checked against real models — + // say so rather than letting a plain box pass for a validated field. + const note = document.createElement('div'); note.className='ctlwarn'; + note.textContent = 'model list unavailable — this value is not checked against this session'; + wrap.appendChild(i); wrap.appendChild(note); + return wrap; } function syncToggle(t, key){ diff --git a/lib/write.mjs b/lib/write.mjs index cc46d8e..bd98422 100644 --- a/lib/write.mjs +++ b/lib/write.mjs @@ -99,6 +99,7 @@ import { backlogIndex, backlogItems, BACKLOG_KINDS, + EXTENSIONS_BODY, fmScalar, frontmatter, globToRegExp, @@ -354,6 +355,23 @@ export function prependLog(memDir, entries) { prependLogShared(memDir, entries, { header: "# iterator update log" }); } +/** + * Copy templates/format.md into the bundle verbatim, once. Both entry points + * into a bundle seed it — the first plan write and `/iterator-init`'s + * extensions op — so a knowledge-only bundle still documents its own schema. + */ +function seedFormatDoc(memDir) { + const dest = join(memDir, "format.md"); + if (existsSync(dest)) return false; + const src = + resolveTemplate("format.md") || + fail( + "cannot find templates/format.md — is the full iterator plugin installed?", + ); + copyFileSync(src, dest); + return true; +} + // --------------------------------------------------------------------------- // op: plan @@ -364,16 +382,7 @@ function writePlan(payload, root) { if (!s.goal) fail("plan op needs sections.goal"); mkdirSync(join(b.memDir, "features"), { recursive: true }); - // format.md: the self-describing schema, copied verbatim once. - const formatDest = join(b.memDir, "format.md"); - if (!existsSync(formatDest)) { - const src = - resolveTemplate("format.md") || - fail( - "cannot find templates/format.md — is the full iterator plugin installed?", - ); - copyFileSync(src, formatDest); - } + seedFormatDoc(b.memDir); const description = (payload.description || s.goal.split("\n")[0]) .replace(/\s+/g, " ") @@ -2479,26 +2488,8 @@ function applyReview(payload, root) { // --------------------------------------------------------------------------- // op: extensions -const EXTENSIONS_BODY = `Guidance for agents and extensions reading or updating this bundle. - -# Reading - -* Start at \`memory/index.md\`, follow the area indexes, then open only the - relevant concept files (progressive disclosure — never bulk-read the bundle). -* A concept ID is the bundle-relative path without \`.md\`; feature IDs/slugs - are their filenames without \`.md\` and are the stable identity used by tools. -* Non-reserved concept files require YAML frontmatter with a non-empty - \`type\`; preserve unknown keys and tolerate unknown concept types. - -# Writing - -* Safe writes create or update one concept file at a time, keep markdown - human-readable and diffable, update \`timestamp\`, regenerate affected - indexes, and append a newest-first \`memory/log.md\` entry for meaningful - changes. -* Knowledge writes should go through the iterator writer (\`write.mjs\` ops - \`memorize\` / \`apply-review\`); plan/feature writes through its plan/feature ops. -`; +// EXTENSIONS_BODY lives in bundle.mjs so gather can diff a stored copy +// against it without importing this module (write.mjs already imports gather). /** * Write memory/EXTENSIONS.md (the extension-facing memory contract) and link @@ -2519,6 +2510,9 @@ function writeExtensions(payload, root) { ].join("\n"); const bodyText = `\n${preamble ? `${preamble}\n\n` : ""}${EXTENSIONS_BODY}`; writeFileSync(join(b.memDir, "EXTENSIONS.md"), joinDoc(fm, bodyText)); + // The contract points at format.md for the metadata schema, so an + // init-only bundle (no plan yet) must carry it too. + const seededFormat = seedFormatDoc(b.memDir); updateRootIndex(b.memDir, []); const indexFile = join(b.memDir, "index.md"); const raw = readFileSync(indexFile, "utf8"); @@ -2550,7 +2544,12 @@ function writeExtensions(payload, root) { ); return { op: "extensions", - written: ["EXTENSIONS.md", "index.md", "log.md"], + written: [ + "EXTENSIONS.md", + ...(seededFormat ? ["format.md"] : []), + "index.md", + "log.md", + ], memoryDir: b.memDir, }; } diff --git a/memory/features/flag-unusable-model-fields.md b/memory/features/flag-unusable-model-fields.md new file mode 100644 index 0000000..a1270a0 --- /dev/null +++ b/memory/features/flag-unusable-model-fields.md @@ -0,0 +1,42 @@ +--- +type: Feature +title: Show unusable model choices in settings +description: The settings model fields visibly mark a value the current session cannot use, and say so when the registry is unavailable. +status: done +size: small +depends_on: [validate-role-model-on-save] +files: ["lib/views/settings.mjs", "test/ui.test.mjs", "test/client-js-parse.test.mjs"] +memories: [pitfalls/client-js-template-literal-escaping, decisions/code-exact-red-test-review-and-agent-wording, decisions/focus-feature-execution-and-dashboard-ownership, decisions/iterator-dashboard-feature-workflow, decisions/parallel-feature-waves-and-consolidated-review, decisions/polish-dashboard-and-multi-agent-workflows, decisions/review-navigation-and-work-context, decisions/settings-close-returns-to-work] +timestamp: "2026-07-21T20:23:06.785Z" +tags: [] +commits: + - sha: 68facc8941e6b7cab9d48023b5e2e4d26f645c94 + kind: implement + date: 2026-07-21 +done: 2026-07-21 +reviewed: 2026-07-21 +--- + +# Implementation notes + +lib/views/settings.mjs renders supplied state only — take the per-key verdict from the payload and never re-derive it in the client. Mark an unusable selection on the option and the row so it reads before saving, extending the existing '(unlisted)' affordance around line 159. When MODELS is null (Array.isArray guard at line 97) the field falls back to a free-text input at line 166; label that fallback so it cannot be mistaken for a validated dropdown. Cover the rendering in test/ui.test.mjs and keep the client script parseable per pitfalls/client-js-template-literal-escaping (test/client-js-parse.test.mjs). + +# Snippets + +```js +// lib/views/settings.mjs:97 — a Promise fails this guard and silently degrades +const MODELS = Array.isArray(D.models) ? D.models : null; +``` + +# Depends on + +* [Reject unusable role models at save time](/features/validate-role-model-on-save.md) + +# Blast radius + +Settings view rendering only. + +# Review + +## 2026-07-21 +* **Approved** — Verdict supplied by classifyRoleModel rides the payload; client renders it without re-deriving; fallback text input labeled unvalidated; 409/409 tests green. diff --git a/memory/features/index.md b/memory/features/index.md new file mode 100644 index 0000000..d73c096 --- /dev/null +++ b/memory/features/index.md @@ -0,0 +1,5 @@ +# Features + +* [Teach the bundle to explain its own use](self-describing-bundle-usage.md) - ✅ done · medium · Every bundle carries agent-facing usage rules, written at init and drift-checked during consolidate without the dashboard. +* [Reject unusable role models at save time](validate-role-model-on-save.md) - ✅ done · medium · Saving a role model that cannot work in the current session is refused with a named reason instead of failing later as a provider 401. +* [Show unusable model choices in settings](flag-unusable-model-fields.md) - ✅ done · small · depends: validate-role-model-on-save · The settings model fields visibly mark a value the current session cannot use, and say so when the registry is unavailable. diff --git a/memory/features/self-describing-bundle-usage.md b/memory/features/self-describing-bundle-usage.md new file mode 100644 index 0000000..fa845f2 --- /dev/null +++ b/memory/features/self-describing-bundle-usage.md @@ -0,0 +1,33 @@ +--- +type: Feature +title: Teach the bundle to explain its own use +description: Every bundle carries agent-facing usage rules, written at init and drift-checked during consolidate without the dashboard. +status: done +size: medium +depends_on: [] +files: ["templates/format.md", "lib/write.mjs", "skills/iterator-init/SKILL.md", "skills/iterator-consolidate/SKILL.md", "test/write.test.mjs"] +memories: [architecture/knowledge-lifecycle, architecture/workflow-state-ownership, patterns/agent-reviewed-memory-writes, decisions/auto-plan-review-terminal-reset, decisions/consume-accepted-backlog-ideas, decisions/iterator-dashboard-feature-workflow, decisions/manual-role-models-and-runtime-reset, decisions/memory-relevance-usage-and-dashboard-recovery] +timestamp: "2026-07-21T20:14:51.682Z" +tags: [] +commits: + - sha: ddb22431072427c20ee522a5945c5b2849e6fc1c + kind: implement + date: 2026-07-21 +done: 2026-07-21 +--- + +# Implementation notes + +templates/format.md documents the metadata schema but never says how to USE the bundle. Add an agent-facing section: the read order (plan.md and features/index.md first, then decisions before architecture/patterns/pitfalls/setup), that machine-owned files are writer-owned and must be piped through write.mjs rather than hand-edited, and how to discover ops via --schema. Second gap: lib/write.mjs:367 copies format.md only on the first PLAN write, so an init-only knowledge bundle has none — seed it on the init write path too, keeping the copy-once guard. Third gap: gather already computes formatStale (lib/gather.mjs:1231) and a refresh-format op already exists (lib/write.mjs:2277), but only lib/views/knowledge.mjs consumes it — make skills/iterator-consolidate/SKILL.md read formatStale from the knowledge gather and offer refresh-format, so a no-extension agent performs the same check. Cover the init-path copy in test/write.test.mjs. + +# Snippets + +```js +// lib/gather.mjs:1231 — already computed, dashboard-only today +const formatStale = !!template && existsSync(formatFile) && + readFileSync(template, "utf8") !== readFileSync(formatFile, "utf8"); +``` + +# Blast radius + +New bundles gain format.md at init; consolidate gains a drift check. Existing bundles are untouched until refreshed. diff --git a/memory/features/validate-role-model-on-save.md b/memory/features/validate-role-model-on-save.md new file mode 100644 index 0000000..d50435a --- /dev/null +++ b/memory/features/validate-role-model-on-save.md @@ -0,0 +1,38 @@ +--- +type: Feature +title: Reject unusable role models at save time +description: Saving a role model that cannot work in the current session is refused with a named reason instead of failing later as a provider 401. +status: done +size: medium +depends_on: [] +files: ["lib/pi-tools.mjs", "extensions/iterator.js", "test/pi-tools.test.mjs", "test/extension-model-lifecycle.test.mjs"] +memories: [architecture/package-and-skill-layout, decisions/auto-plan-review-terminal-reset, decisions/backlog-planning-and-feature-waves, decisions/code-exact-red-test-review-and-agent-wording, decisions/focus-feature-execution-and-dashboard-ownership, decisions/iterator-dashboard-feature-workflow, decisions/manual-role-models-and-runtime-reset, decisions/memory-relevance-usage-and-dashboard-recovery] +timestamp: "2026-07-21T20:14:51.701Z" +tags: [] +commits: + - sha: 55864983c4afdd284d23f08d4027ddc4f10f88c7 + kind: implement + date: 2026-07-21 + - sha: fca031b80aa87378faa82debe943ea53a92cee45 + kind: implement + date: 2026-07-21 +done: 2026-07-21 +--- + +# Implementation notes + +Add a pure policy helper in lib/pi-tools.mjs beside resolveRoleModel that classifies a configured '/' against the session registry list and the active runtime model: usable (matches active/restorable runtime object), listed-but-different-route (registry has it, but the provider differs from the active runtime provider), or unknown (not in the registry at all). Keep it structural — compare identity and the registry/runtime route metadata only. Never probe a provider and never special-case a credential sentinel. Wire it into saveSettings in extensions/iterator.js: classify every *_model value before the write op runs, and on a non-usable verdict notify with the offending key, the configured value, and the closest listed alternative, then abort without writing. Extend the existing source-assertion tests in test/extension-model-lifecycle.test.mjs and add unit coverage for the classifier in test/pi-tools.test.mjs. + +# Snippets + +```js +// lib/pi-tools.mjs — beside resolveRoleModel +export function classifyRoleModel(spec, activeModel, available) { + const identity = parseModelSpec(spec); + // -> { ok, reason, suggestion } +} +``` + +# Blast radius + +Settings saves from the dashboard modal and the settings step; no runtime role-switch behavior changes. diff --git a/memory/index.md b/memory/index.md index 16d67f2..d35b251 100644 --- a/memory/index.md +++ b/memory/index.md @@ -10,6 +10,8 @@ last_memorized_commit: c5b75e3e4b034bc80a1600e57249e162639d32a8 * [Design](design.md) - A compact dark developer control plane with clear workflow state. * [Backlog](backlog/index.md) - Saved ideas and bugs outside active plan features. * [Settings](settings.md) - Project settings (auto mode, models, git flow). +* [Plan](plan.md) - Reject unusable role models where they are chosen and make the memory bundle explain its own usage to agents without the extension. +* [Features](features/) - One document per implementation feature. * [Architecture](/architecture/) - How the system is structured. * [Decisions](/decisions/) - Durable product and implementation choices agents should preserve. * [Patterns & Conventions](/patterns/) - How code and workflows are written in this repo. diff --git a/memory/log.md b/memory/log.md index ce4b625..54e3057 100644 --- a/memory/log.md +++ b/memory/log.md @@ -1,6 +1,17 @@ # iterator update log ## 2026-07-21 +* **Review**: Reviewed [Show unusable model choices in settings](/features/flag-unusable-model-fields.md); approved. +* **Review**: Accepted [Show unusable model choices in settings](/features/flag-unusable-model-fields.md) (committed as feature(flag-unusable-model-fields)). +* **Implementation**: Committed feature(flag-unusable-model-fields) on branch iterator/make-configuration-and-memory-self-explanatory; awaiting review. +* **Review**: Accepted [Reject unusable role models at save time](/features/validate-role-model-on-save.md) (committed as feature(validate-role-model-on-save)). +* **Review**: Accepted [Teach the bundle to explain its own use](/features/self-describing-bundle-usage.md) (committed as feature(self-describing-bundle-usage)). +* **Implementation**: Committed feature(validate-role-model-on-save) on branch iterator/make-configuration-and-memory-self-explanatory; awaiting review. +* **Implementation**: Committed feature(validate-role-model-on-save) on branch iterator/make-configuration-and-memory-self-explanatory; awaiting review. +* **Implementation**: Committed feature(self-describing-bundle-usage) on branch iterator/make-configuration-and-memory-self-explanatory; awaiting review. +* **Update**: Applied 3 feature adjustment(s). +* Feature breakdown drafted in Claude Code for the self-explanatory configuration and memory plan +* Plan approved in Claude Code: settings-time role model validation and a self-describing memory bundle * **Retirement**: Plan "Focus plan starts and preserve managed model authentication" condensed into [Start plans in Work and reuse runtime models](/decisions/work-first-plan-start-and-runtime-model-reuse.md). * **Plan review**: Whole-plan review recorded (agent). * **Review**: Reviewed [Preserve runtime role-model authentication](/features/preserve-runtime-role-model.md); approved (agent). diff --git a/memory/plan.md b/memory/plan.md new file mode 100644 index 0000000..41151eb --- /dev/null +++ b/memory/plan.md @@ -0,0 +1,42 @@ +--- +type: Plan +title: Make configuration and memory self-explanatory +description: Reject unusable role models where they are chosen and make the memory bundle explain its own usage to agents without the extension. +status: approved +branch: iterator/make-configuration-and-memory-self-explanatory +worktree: /Volumes/Extern/Projects/iterator-iterator-make-configuration-and-memory-self-explanatory +created: 2026-07-21 +timestamp: 2026-07-21T19:41:10.247Z +--- + +# Goal + +Stop Iterator from failing silently when its configuration or its memory bundle is used outside the dashboard. A role model that cannot work in the current session must be rejected where it is chosen, not surface later as an opaque provider 401; and a memory/ bundle must explain its own usage well enough that an agent with no Iterator extension can read and update it correctly. + +# Architecture + +- Extend the settings seam in `extensions/iterator.js` (`modelOptions` / `saveSettings`) per `architecture/package-and-skill-layout`: the registry is only reachable in the extension, so session-scope validation belongs there, with pure identity/compat policy in `lib/pi-tools.mjs` beside `resolveRoleModel`. +- `lib/views/settings.mjs` renders supplied state only (`architecture/workflow-state-ownership`): it marks unlisted or route-incompatible values from data the payload carries, and never re-derives compatibility. +- When the registry is genuinely unavailable, the free-text fallback in `lib/views/settings.mjs` must say so, so it cannot be mistaken for a validated field. +- Add an agent-facing usage section to `templates/format.md` — already copied verbatim into every bundle, so it stays self-describing when moved out of the repo (`decisions/okf-markdown-bundle`). +- `/iterator-init` writes it; `/iterator-consolidate` verifies and repairs it, reusing the existing stale-anchor repair pass rather than a new mechanism (`architecture/knowledge-lifecycle`). +- Canonical `lib/` edits run `npm run sync` (`decisions/synced-droppable-skill-libs`). + +# Dependencies + +(none) + +# Key decisions + +- Validation stays structural: compare a configured `provider/id` against the session registry and the active runtime model's route. Do not probe providers and do not hard-code the `proxy-managed` sentinel — this upholds `decisions/work-first-plan-start-and-runtime-model-reuse`. +- A mismatch warns and blocks the save rather than silently rewriting the user's provider, because auto-correcting `openai/...` to `openai-codex/...` would guess at intent. +- Runtime behavior is unchanged: `resolveRoleModel`'s active/restore/registry precedence and `decisions/safe-role-model-restoration`'s arm-only-on-success rule stay exactly as they are. This plan makes bad configuration visible earlier; it does not alter fallback. +- This extends `decisions/polish-dashboard-and-multi-agent-workflows` (settings limited to models available in the current session scope) — availability proved insufficient, since both `openai/...` and `openai-codex/...` list as available while only one carries the managed route. +- The bundle usage guide documents the deterministic-writer rule as a first-class constraint, so a no-extension agent does not hand-edit machine-owned files. +- No new settings keys, workflow statuses, external dependencies, or visual redesign. + +# Features + +* [Teach the bundle to explain its own use](/features/self-describing-bundle-usage.md) - Every bundle carries agent-facing usage rules, written at init and drift-checked during consolidate without the dashboard. +* [Reject unusable role models at save time](/features/validate-role-model-on-save.md) - Saving a role model that cannot work in the current session is refused with a named reason instead of failing later as a provider 401. +* [Show unusable model choices in settings](/features/flag-unusable-model-fields.md) - The settings model fields visibly mark a value the current session cannot use, and say so when the registry is unavailable. diff --git a/memory/state.md b/memory/state.md index ecb38f4..b673ed8 100644 --- a/memory/state.md +++ b/memory/state.md @@ -4,11 +4,11 @@ title: Runtime state description: Machine-owned iterator flow state — never hand-edited. mode: manual paused: false -phase: done +phase: idle active_feature: null strikes: "{}" escalation: null -timestamp: 2026-07-21T14:12:11.624Z +timestamp: 2026-07-21T19:41:10.265Z --- Runtime flow state; read via gather, written only by the state op. diff --git a/skills/iterator-consolidate/SKILL.md b/skills/iterator-consolidate/SKILL.md index 74dd34f..6e402a0 100644 --- a/skills/iterator-consolidate/SKILL.md +++ b/skills/iterator-consolidate/SKILL.md @@ -41,6 +41,30 @@ never conclude "the knowledge base looks healthy, nothing to do" from the inventory alone. An all-`keep` payload is still a valid round — it shows the user the current state and lets them prune. +## Check the bundle's self-description + +The bundle must stay usable by an agent with no iterator extension loaded, so +repair its two self-describing documents before reviewing concepts. The same +knowledge gather reports them: + +- `formatMissing` / `formatStale` — `memory/format.md` (the metadata schema) + is absent or has drifted from the current template. +- `contractMissing` / `contractStale` — `memory/EXTENSIONS.md` (the read/write + contract: read order, machine-owned files, how to discover writer ops) is + absent or predates the current contract rules. + +Repair whichever is flagged — both writes are idempotent, and a stale copy is +overwritten with the current text (a project-specific preamble must be passed +again if the bundle had one): + +```bash +echo '{"op":"refresh-format"}' | node /../iterator/write.mjs +echo '{"op":"extensions"}' | node /../iterator/write.mjs +``` + +Report what was refreshed. When none of the four flags is set, say so and move +on — this check never blocks the review round. + ## Staleness and attachment scan Beyond the gathered `stale` flags (frontmatter `files:` vs `git ls-files`), diff --git a/skills/iterator-init/SKILL.md b/skills/iterator-init/SKILL.md index fcb1a9c..1ab6e25 100644 --- a/skills/iterator-init/SKILL.md +++ b/skills/iterator-init/SKILL.md @@ -38,9 +38,12 @@ writer seeds `last_memorized_commit`. React and finish per PROTOCOL.md. ## After approval: the extension contract One more deterministic write — the `extensions` op creates -`memory/EXTENSIONS.md` (the extension-facing memory contract: progressive -disclosure, concept IDs, safe-write rules, writer ops) and links it from the -root index; it is idempotent: +`memory/EXTENSIONS.md` (the agent-facing memory contract: read order, +progressive disclosure, concept IDs, machine-owned files, safe-write rules, +how to discover writer ops) and links it from the root index. It also seeds +`memory/format.md` (the metadata schema) when the bundle has none, so a +knowledge-only bundle still describes itself to an agent running without the +iterator extension. It is idempotent: ```bash echo '{"op":"extensions"}' | node /../iterator/write.mjs @@ -53,7 +56,8 @@ contract notes beyond the standard boilerplate. Report created/accepted/rejected counts from `applied` (`applied.summary` is a ready-made line) and mention that `memory/EXTENSIONS.md` was created for -other extensions. +other extensions — naming `memory/format.md` too when the op's `written` list +includes it. When the invocation carries the Planning hero's continuation instruction and saved goal (`when initialization finishes, continue into /skill:iterator-plan diff --git a/skills/iterator/lib/bundle.mjs b/skills/iterator/lib/bundle.mjs index 0b64def..06545b8 100644 --- a/skills/iterator/lib/bundle.mjs +++ b/skills/iterator/lib/bundle.mjs @@ -335,6 +335,57 @@ export const OKF_AREAS = { export const OKF_AREA_NAMES = Object.keys(OKF_AREAS); +// --------------------------------------------------------------------------- +// The agent-facing bundle contract (memory/EXTENSIONS.md) + +/** + * The body of memory/EXTENSIONS.md. It lives here rather than in write.mjs so + * gather can diff a bundle's stored copy against it (`contractStale`) without + * importing the writer — write.mjs already imports gather, so the reverse + * direction would be a cycle. + * + * This is the one document that must make the bundle usable by an agent with + * no iterator extension loaded, so it states the read order, which files are + * machine-owned, and how to discover the writer's ops. + */ +export const EXTENSIONS_BODY = `Guidance for agents and extensions reading or updating this bundle. + +# Reading + +* Start at \`memory/index.md\`, follow the area indexes, then open only the + relevant concept files (progressive disclosure — never bulk-read the bundle). +* For work in progress, read \`memory/plan.md\` and \`memory/features/index.md\` + first: they say what is being built and where each feature stands. +* Before changing code, read the concepts whose \`files:\` anchors match the + files you are about to touch — \`decisions/\` first (new work must not + silently contradict one), then architecture, patterns, pitfalls, and setup. +* A concept ID is the bundle-relative path without \`.md\`; feature IDs/slugs + are their filenames without \`.md\` and are the stable identity used by tools. +* Non-reserved concept files require YAML frontmatter with a non-empty + \`type\`; preserve unknown keys and tolerate unknown concept types. +* \`memory/format.md\` documents the metadata schema for every document type in + this bundle; read it before authoring or parsing frontmatter. + +# Writing + +* Safe writes create or update one concept file at a time, keep markdown + human-readable and diffable, update \`timestamp\`, regenerate affected + indexes, and append a newest-first \`memory/log.md\` entry for meaningful + changes. +* Knowledge writes should go through the iterator writer (\`write.mjs\` ops + \`memorize\` / \`apply-review\`); plan/feature writes through its plan/feature ops. +* These files are machine-owned — never hand-edit them: \`index.md\` and every + area \`index.md\`, \`features/*.md\` frontmatter, \`features/index.md\`, + \`log.md\`, \`state.md\`, \`usage.md\`, \`settings.md\`, and the plan's + \`# Features\` section. Prose in a feature body is yours to write. +* Discover the writer's contract instead of guessing: \`write.mjs --schema\` + lists every op and \`write.mjs --schema \` prints one op's payload. The + writer validates before writing and writes nothing on failure. +* Derived state (a feature's readiness, what it is waiting on, the plan's + stage) comes from \`gather.mjs --step \`. Render what it reports; do + not recompute it from raw statuses. +`; + /** Rebuild an area index's bullet list, preserving its heading and prose. */ export function regenerateAreaIndex(memDir, area) { const dir = join(memDir, area); diff --git a/skills/iterator/lib/gather.mjs b/skills/iterator/lib/gather.mjs index 399b237..72050e0 100644 --- a/skills/iterator/lib/gather.mjs +++ b/skills/iterator/lib/gather.mjs @@ -43,6 +43,7 @@ import { planStage, readiness, satisfiedSet } from "./status.mjs"; import { backlogItems, body, + EXTENSIONS_BODY, frontmatter, globToRegExp, listy, @@ -1225,14 +1226,24 @@ export function gatherKnowledge(startDir) { })); // memory/format.md is copied from the template once (on the first plan - // write) and drifts as the template evolves — surface that. + // write or at init) and drifts as the template evolves — surface that. const template = resolveTemplate("format.md"); const formatFile = join(b.memDir, "format.md"); + const formatMissing = !existsSync(formatFile); const formatStale = !!template && - existsSync(formatFile) && + !formatMissing && readFileSync(template, "utf8") !== readFileSync(formatFile, "utf8"); + // EXTENSIONS.md is generated boilerplate, so it drifts the same way once + // the contract gains rules. Compare the stored body against the current + // one, ignoring the frontmatter and any project-specific preamble above it. + const contractFile = join(b.memDir, "EXTENSIONS.md"); + const contractMissing = !existsSync(contractFile); + const contractStale = + !contractMissing && + !body(readFileSync(contractFile, "utf8")).includes(EXTENSIONS_BODY.trim()); + const staleCount = memories.filter((m) => m.stale).length; // The forcing sentence consolidate follows: the review round ALWAYS opens // (even all-keep) so the run produces a visible outcome — the model must @@ -1303,6 +1314,9 @@ export function gatherKnowledge(startDir) { } : null, formatStale, + formatMissing, + contractStale, + contractMissing, }; } diff --git a/skills/iterator/lib/views/settings.mjs b/skills/iterator/lib/views/settings.mjs index bfe0590..a851576 100644 --- a/skills/iterator/lib/views/settings.mjs +++ b/skills/iterator/lib/views/settings.mjs @@ -7,7 +7,10 @@ * input: { step:"settings", branch, plan, * settings:{:}, // defaults merged * defined, // memory/settings.md exists - * models?: [{id,label}] } // pi model registry, for model dropdowns + * models?: [{id,label,unusable?,note?}] } // pi model registry, for + * // model dropdowns; the + * // unusable verdict is + * // supplied, never derived * output: one JSON line to stdout — * { type:"settings", values:{:} } * | { type:"settings-close" } when the shell-owned modal is dismissed. @@ -30,6 +33,10 @@ h1{font-family:var(--font-display);font-size:var(--fs-xl);font-weight:600;margin select.ctl,input.ctl{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm); color:var(--text);font-size:var(--fs-sm);font-family:var(--font-mono);padding:6px 10px;outline:none;min-width:140px} select.ctl:focus,input.ctl:focus{border-color:var(--accent)} +.ctlwrap{display:flex;flex-direction:column;gap:4px;align-items:flex-end} +.ctlwarn{font-size:var(--fs-xs);color:var(--dot-yellow);max-width:280px;text-align:right;line-height:1.4} +.ctlwarn:empty{display:none} +select.ctl.bad{border-color:var(--dot-yellow)} input.ctl[type=number]{width:90px;min-width:90px} .toggle{display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden} .toggle button{font-size:var(--fs-sm);padding:6px 16px;border:none;cursor:pointer;background:var(--bg);color:var(--text-muted);font-family:inherit} @@ -152,22 +159,48 @@ function makeControl(key, def){ } // model: dropdown when the pi registry rode along, free text otherwise. if(MODELS && MODELS.length){ + const wrap = document.createElement('div'); wrap.className='ctlwrap'; const s = document.createElement('select'); s.className='ctl'; const act = document.createElement('option'); act.value='active'; act.textContent='active (session model)'; s.appendChild(act); - MODELS.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=m.label||m.id; s.appendChild(o); }); + MODELS.forEach(m => { + const o=document.createElement('option'); o.value=m.id; + // The verdict rides along with the option — a model this session cannot + // route reads as unusable before it is ever saved. + o.textContent=(m.label||m.id)+(m.unusable?' \\u2014 unusable':''); + if(m.unusable){ o.dataset.unusable='1'; if(m.note) o.dataset.note=m.note; } + s.appendChild(o); + }); if(![...s.options].some(o => o.value === String(cur[key]))){ - const o=document.createElement('option'); o.value=String(cur[key]); o.textContent=String(cur[key])+' (unlisted)'; s.appendChild(o); + const o=document.createElement('option'); o.value=String(cur[key]); + o.textContent=String(cur[key])+' (unlisted)'; o.dataset.unusable='1'; + o.dataset.note='not offered by this session\\u2019s model registry'; + s.appendChild(o); } s.value = String(cur[key]); - s.addEventListener('change', () => { cur[key]=s.value; refresh(); }); - return s; + const warn = document.createElement('div'); warn.className='ctlwarn'; + const paint = () => { + const sel = s.options[s.selectedIndex]; + const bad = sel && sel.dataset.unusable === '1'; + warn.textContent = bad ? (sel.dataset.note || 'this session cannot use this model') : ''; + s.classList.toggle('bad', !!bad); + }; + paint(); + s.addEventListener('change', () => { cur[key]=s.value; paint(); refresh(); }); + wrap.appendChild(s); wrap.appendChild(warn); + return wrap; } + const wrap = document.createElement('div'); wrap.className='ctlwrap'; const i = document.createElement('input'); i.className='ctl'; i.placeholder = 'active or provider/model-id'; i.value = String(cur[key]); i.addEventListener('input', () => { cur[key]=i.value.trim(); refresh(); }); - return i; + // No registry rode along, so nothing here is checked against real models — + // say so rather than letting a plain box pass for a validated field. + const note = document.createElement('div'); note.className='ctlwarn'; + note.textContent = 'model list unavailable — this value is not checked against this session'; + wrap.appendChild(i); wrap.appendChild(note); + return wrap; } function syncToggle(t, key){ diff --git a/skills/iterator/lib/write.mjs b/skills/iterator/lib/write.mjs index cc46d8e..bd98422 100644 --- a/skills/iterator/lib/write.mjs +++ b/skills/iterator/lib/write.mjs @@ -99,6 +99,7 @@ import { backlogIndex, backlogItems, BACKLOG_KINDS, + EXTENSIONS_BODY, fmScalar, frontmatter, globToRegExp, @@ -354,6 +355,23 @@ export function prependLog(memDir, entries) { prependLogShared(memDir, entries, { header: "# iterator update log" }); } +/** + * Copy templates/format.md into the bundle verbatim, once. Both entry points + * into a bundle seed it — the first plan write and `/iterator-init`'s + * extensions op — so a knowledge-only bundle still documents its own schema. + */ +function seedFormatDoc(memDir) { + const dest = join(memDir, "format.md"); + if (existsSync(dest)) return false; + const src = + resolveTemplate("format.md") || + fail( + "cannot find templates/format.md — is the full iterator plugin installed?", + ); + copyFileSync(src, dest); + return true; +} + // --------------------------------------------------------------------------- // op: plan @@ -364,16 +382,7 @@ function writePlan(payload, root) { if (!s.goal) fail("plan op needs sections.goal"); mkdirSync(join(b.memDir, "features"), { recursive: true }); - // format.md: the self-describing schema, copied verbatim once. - const formatDest = join(b.memDir, "format.md"); - if (!existsSync(formatDest)) { - const src = - resolveTemplate("format.md") || - fail( - "cannot find templates/format.md — is the full iterator plugin installed?", - ); - copyFileSync(src, formatDest); - } + seedFormatDoc(b.memDir); const description = (payload.description || s.goal.split("\n")[0]) .replace(/\s+/g, " ") @@ -2479,26 +2488,8 @@ function applyReview(payload, root) { // --------------------------------------------------------------------------- // op: extensions -const EXTENSIONS_BODY = `Guidance for agents and extensions reading or updating this bundle. - -# Reading - -* Start at \`memory/index.md\`, follow the area indexes, then open only the - relevant concept files (progressive disclosure — never bulk-read the bundle). -* A concept ID is the bundle-relative path without \`.md\`; feature IDs/slugs - are their filenames without \`.md\` and are the stable identity used by tools. -* Non-reserved concept files require YAML frontmatter with a non-empty - \`type\`; preserve unknown keys and tolerate unknown concept types. - -# Writing - -* Safe writes create or update one concept file at a time, keep markdown - human-readable and diffable, update \`timestamp\`, regenerate affected - indexes, and append a newest-first \`memory/log.md\` entry for meaningful - changes. -* Knowledge writes should go through the iterator writer (\`write.mjs\` ops - \`memorize\` / \`apply-review\`); plan/feature writes through its plan/feature ops. -`; +// EXTENSIONS_BODY lives in bundle.mjs so gather can diff a stored copy +// against it without importing this module (write.mjs already imports gather). /** * Write memory/EXTENSIONS.md (the extension-facing memory contract) and link @@ -2519,6 +2510,9 @@ function writeExtensions(payload, root) { ].join("\n"); const bodyText = `\n${preamble ? `${preamble}\n\n` : ""}${EXTENSIONS_BODY}`; writeFileSync(join(b.memDir, "EXTENSIONS.md"), joinDoc(fm, bodyText)); + // The contract points at format.md for the metadata schema, so an + // init-only bundle (no plan yet) must carry it too. + const seededFormat = seedFormatDoc(b.memDir); updateRootIndex(b.memDir, []); const indexFile = join(b.memDir, "index.md"); const raw = readFileSync(indexFile, "utf8"); @@ -2550,7 +2544,12 @@ function writeExtensions(payload, root) { ); return { op: "extensions", - written: ["EXTENSIONS.md", "index.md", "log.md"], + written: [ + "EXTENSIONS.md", + ...(seededFormat ? ["format.md"] : []), + "index.md", + "log.md", + ], memoryDir: b.memDir, }; } diff --git a/test/extension-model-lifecycle.test.mjs b/test/extension-model-lifecycle.test.mjs index f213c80..8b38d6d 100644 --- a/test/extension-model-lifecycle.test.mjs +++ b/test/extension-model-lifecycle.test.mjs @@ -31,6 +31,21 @@ test("role switching accepts modern void success and preserves runtime matches", assert.match(extension, /else if \(target\.switchRequired\)/); }); +test("saving settings refuses unusable role models before the writer runs", () => { + const extension = readFileSync( + new URL("../extensions/iterator.js", import.meta.url), + "utf8", + ); + assert.match(extension, /classifyRoleModel\(/); + // The guard must precede the write and abort it, never merely warn. It + // throws so the single catch reports it, rather than returning quietly. + const save = extension.slice(extension.indexOf("const saveSettings")); + const guard = save.indexOf("unusableRoleModels"); + const write = save.indexOf('op: "settings"'); + assert.ok(guard !== -1 && guard < write, "validation must gate the write"); + assert.match(save.slice(guard, write), /throw new Error\(/); +}); + test("the settings step awaits the model registry so fields stay dropdowns", () => { const extension = readFileSync( new URL("../extensions/iterator.js", import.meta.url), diff --git a/test/gather.test.mjs b/test/gather.test.mjs index 8e286e4..0aaaa31 100644 --- a/test/gather.test.mjs +++ b/test/gather.test.mjs @@ -25,6 +25,7 @@ import { globToRegExp, matchConcepts, } from "../lib/gather.mjs"; +import { EXTENSIONS_BODY } from "../lib/bundle.mjs"; const git = (dir, ...args) => execFileSync("git", args, { @@ -863,6 +864,34 @@ test("gather knowledge reports feature memory pressure and dangling references", } }); +test("gather knowledge flags a missing or outdated extension contract", () => { + const root = makeKnowledgeFixture(); + try { + // No EXTENSIONS.md yet — /iterator-init never ran on this bundle. + let payload = gatherKnowledge(root); + assert.equal(payload.contractMissing, true); + assert.equal(payload.contractStale, false); + + // A copy predating the current contract rules must read as stale... + writeFileSync( + join(root, "memory", "EXTENSIONS.md"), + "---\ntype: Reference\n---\n\nOld contract text.\n", + ); + payload = gatherKnowledge(root); + assert.equal(payload.contractMissing, false); + assert.equal(payload.contractStale, true); + + // ...while the current body is clean, even under a project preamble. + writeFileSync( + join(root, "memory", "EXTENSIONS.md"), + `---\ntype: Reference\n---\n\nProject note.\n\n${EXTENSIONS_BODY}`, + ); + assert.equal(gatherKnowledge(root).contractStale, false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("gather knowledge flags a stale format.md and an uninitialized bundle", () => { const root = makeKnowledgeFixture(); try { diff --git a/test/pi-tools.test.mjs b/test/pi-tools.test.mjs index f7823e0..bed8bfa 100644 --- a/test/pi-tools.test.mjs +++ b/test/pi-tools.test.mjs @@ -18,6 +18,7 @@ import { modelMatchesSpec, modelSwitchSucceeded, parseModelSpec, + classifyRoleModel, resolveRoleModel, runJson, scriptPath, @@ -797,6 +798,58 @@ test("nextAutoAction escalates on conflicts, prior strikes, drafts, and stuck gr assert.match(a.reason, /cycle or missing/); }); +test("role model classification refuses the duplicate that routes around the host", () => { + // The real failure: one id listed under both the managed provider and the + // direct one, so the dropdown shows two identical-looking valid choices. + const available = [ + { provider: "openai-codex", id: "gpt-5.6-sol" }, + { provider: "openai", id: "gpt-5.6-sol" }, + ]; + const active = { provider: "openai-codex", id: "gpt-5.6-sol" }; + + const bad = classifyRoleModel("openai/gpt-5.6-sol", active, available); + assert.equal(bad.ok, false); + assert.equal(bad.reason, "route"); + assert.equal(bad.suggestion, "openai-codex/gpt-5.6-sol"); + + assert.equal( + classifyRoleModel("openai-codex/gpt-5.6-sol", active, available).ok, + true, + ); +}); + +test("role model classification refuses unknown ids and passes legitimate providers", () => { + const available = [ + { provider: "openai-codex", id: "gpt-5.6-sol" }, + { provider: "anthropic", id: "claude-opus" }, + ]; + const active = { provider: "openai-codex", id: "gpt-5.6-sol" }; + + const unknown = classifyRoleModel("openai/gpt-9", active, available); + assert.equal(unknown.ok, false); + assert.equal(unknown.reason, "unknown"); + assert.equal(unknown.suggestion, null); + + // Same id on a provider the registry does list -> name it as the fix. + assert.equal( + classifyRoleModel("openai/gpt-5.6-sol", active, available).suggestion, + "openai-codex/gpt-5.6-sol", + ); + + // A different provider with no same-id duplicate is genuinely usable and + // must stay settable — over-blocking would make valid models unreachable. + assert.equal( + classifyRoleModel("anthropic/claude-opus", active, available).ok, + true, + ); + + // 'active', blank, and an empty registry are never the classifier's call. + assert.equal(classifyRoleModel("active", active, available).ok, true); + assert.equal(classifyRoleModel("", active, available).ok, true); + assert.equal(classifyRoleModel("openai/gpt-9", active, []).ok, true); + assert.equal(classifyRoleModel("nonsense", active, available).reason, "malformed"); +}); + test("runtime role model resolution preserves managed model objects", () => { const active = { provider: "openai-codex", diff --git a/test/ui.test.mjs b/test/ui.test.mjs index 4cac457..3ef1e6e 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -746,6 +746,53 @@ test("planning hero goal box persists an unsent draft and clears it on plan star assert.match(html, /textarea\.goal\{[^}]*min-height:132px/); }); +test("Settings marks a model this session cannot use and never derives that verdict", async () => { + const { render: settings } = await import("../lib/views/settings.mjs"); + const html = settings({ + branch: "main", + plan: "P", + defined: true, + settings: { planner_model: "openai/gpt-5.6-sol" }, + models: [ + { id: "openai-codex/gpt-5.6-sol", label: "openai-codex/gpt-5.6-sol" }, + { + id: "openai/gpt-5.6-sol", + label: "openai/gpt-5.6-sol", + unusable: true, + note: "routes around the active provider", + }, + ], + }); + // The supplied verdict is rendered; the client never recomputes it. + assert.match(html, /routes around the active provider/); + assert.match(html, /dataset\.unusable/); + assert.doesNotMatch(html, /classifyRoleModel/); + assert.match(html, /ctlwarn/); +}); + +test("Settings says so when no model registry rode along", async () => { + const { render: settings } = await import("../lib/views/settings.mjs"); + const withRegistry = settings({ + branch: "main", + plan: "P", + defined: true, + settings: {}, + models: [{ id: "openai-codex/gpt-5.6-sol", label: "x" }], + }); + const without = settings({ + branch: "main", + plan: "P", + defined: true, + settings: {}, + }); + // A plain text box must not pass for a validated field. + assert.match(without, /model list unavailable/); + assert.match(withRegistry, /model list unavailable/); + // Both branches ship in one script; the fallback is chosen at render time + // by the Array.isArray guard, so assert the guard itself is intact. + assert.match(without, /Array\.isArray\(D\.models\)/); +}); + test("Settings closes through the shell modal without a cancellation beacon", async () => { const { render: settings } = await import("../lib/views/settings.mjs"); const html = settings({ diff --git a/test/write.test.mjs b/test/write.test.mjs index 3c77f5a..c54f4b2 100644 --- a/test/write.test.mjs +++ b/test/write.test.mjs @@ -2240,6 +2240,51 @@ test("extensions op writes the contract file and links it from the root index", } }); +test("extensions op seeds format.md for an init-only bundle, once", () => { + const root = makeRepo(); + try { + // /iterator-init writes the knowledge side with no plan, so the plan + // op's format.md copy never ran — the contract still points at it. + mkdirSync(join(root, "memory"), { recursive: true }); + writeFileSync(join(root, "memory", "index.md"), OKF_INDEX); + assert.ok(!existsSync(join(root, "memory", "format.md"))); + + const res = applyOp({ op: "extensions" }, root); + assert.deepEqual(res.written, [ + "EXTENSIONS.md", + "format.md", + "index.md", + "log.md", + ]); + assert.match(read(root, "format.md"), /iterator memory format/); + + // Copied verbatim once: a hand-edited bundle copy is never clobbered. + writeFileSync(join(root, "memory", "format.md"), "# Local edits\n"); + const again = applyOp({ op: "extensions" }, root); + assert.deepEqual(again.written, ["EXTENSIONS.md", "index.md", "log.md"]); + assert.equal(read(root, "format.md"), "# Local edits\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the extension contract tells a no-extension agent how to use the bundle", () => { + const root = makeRepo(); + try { + applyOp(PLAN_OP, root); + applyOp({ op: "extensions" }, root); + const doc = read(root, "EXTENSIONS.md"); + // The rules an agent cannot infer from the bundle's contents alone. + assert.match(doc, /plan\.md.*features\/index\.md|features\/index\.md/s); + assert.match(doc, /decisions\//); + assert.match(doc, /machine-owned/); + assert.match(doc, /--schema/); + assert.match(doc, /gather\.mjs/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("features op warns about globs that match nothing in the repo", () => { const root = makeWaveRepo(); try {