diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
index 8c5a93e..dc01074 100644
--- a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
+++ b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
@@ -210,6 +210,34 @@
.read-more-block { margin: var(--space-2) 0; }
.read-more-block summary { cursor: pointer; color: var(--color-text-link); font-weight: var(--font-weight-medium); }
+ /* Renderer-batch B inlines (v1.10.0 Sesjon 4). Klasser ikke i vendor —
+ inline her per scope-regel (plugin standalone). Kandidat for hoisting
+ til shared/playground-design-system/ i Sesjon 6 visual QA. */
+ .top-risks { margin: var(--space-4) 0; display: flex; flex-direction: column; gap: var(--space-2); }
+ .top-risks__heading { margin: 0 0 var(--space-2) 0; font-size: var(--font-size-md); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--font-weight-semibold); }
+ .top-risk { display: grid; grid-template-columns: 28px 1fr auto; gap: var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); }
+ .top-risk__rank { font-family: var(--font-family-mono); font-size: var(--font-size-sm); color: var(--color-text-tertiary); font-weight: var(--font-weight-bold); }
+ .top-risk__desc { font-size: var(--font-size-sm); color: var(--color-text-primary); }
+ .top-risk__score { font-family: var(--font-family-mono); font-size: var(--font-size-md); font-weight: var(--font-weight-bold); padding: 2px 10px; border-radius: var(--radius-pill); background: var(--color-bg-soft); color: var(--color-text-primary); }
+ .top-risk[data-severity="critical"] { border-left: 4px solid var(--color-severity-critical); }
+ .top-risk[data-severity="critical"] .top-risk__score { background: var(--color-severity-critical); color: var(--color-severity-critical-on); }
+ .top-risk[data-severity="high"] { border-left: 4px solid var(--color-severity-high); }
+ .top-risk[data-severity="high"] .top-risk__score { background: var(--color-severity-high-soft); color: var(--color-severity-high-on); }
+ .top-risk[data-severity="medium"] { border-left: 4px solid var(--color-severity-medium); }
+ .top-risk[data-severity="medium"] .top-risk__score { background: var(--color-severity-medium-soft); color: var(--color-severity-medium-on); }
+ .top-risk[data-severity="low"] { border-left: 4px solid var(--color-severity-low); }
+
+ .recommendation-card { margin: var(--space-4) 0 0 0; padding: var(--space-4); background: var(--color-bg-soft); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); border-left: 4px solid var(--color-scope-architect); display: flex; flex-direction: column; gap: var(--space-2); }
+ .recommendation-card__label { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); }
+ .recommendation-card__body { font-size: var(--font-size-md); color: var(--color-text-primary); line-height: var(--line-height-snug); }
+
+ .suppressed-panel { margin: var(--space-4) 0 0 0; padding: var(--space-3) var(--space-4); background: var(--color-bg-soft); border: 1px dashed var(--color-border-subtle); border-radius: var(--radius-md); opacity: 0.85; }
+ .suppressed-panel summary { cursor: pointer; color: var(--color-text-secondary); font-weight: var(--font-weight-medium); font-size: var(--font-size-sm); }
+ .suppressed-panel[open] summary { margin-bottom: var(--space-2); }
+ .suppressed-panel__list { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-2) 0 0 0; }
+ .suppressed-panel__item { padding: var(--space-2) var(--space-3); background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-text-secondary); display: flex; gap: var(--space-3); align-items: baseline; }
+ .suppressed-panel__id { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
+
/* Tier 3 A4 — Screen-tabs (segmented). Inline her per scope-regel
(plugin standalone). Kandidat for hoisting til shared/ i Sesjon 6. */
.screen-tabs { display: flex; gap: var(--space-1); padding: 4px; background: var(--color-bg-soft); border-radius: var(--radius-md); width: fit-content; margin: 0 0 var(--space-4) 0; }
@@ -2650,13 +2678,92 @@
recommendation: row[recKey] || ''
};
}) : [];
+ // topRisks: prøv ## Top-risikoer-tabell først, ellers fall tilbake til
+ // matrix_cells sortert desc på score.
+ const topRisksTbl = parseTable(md, /##\s*Top.?risikoer/i);
+ let topRisks = [];
+ if (topRisksTbl && topRisksTbl.rows.length) {
+ const idKey = topRisksTbl.headers[0];
+ const descKey = topRisksTbl.headers.find(function (h) { return /risiko|trussel|description|beskrivelse/i.test(h); }) || topRisksTbl.headers[1];
+ const scKey = topRisksTbl.headers.find(function (h) { return /score/i.test(h); });
+ const sevKey = topRisksTbl.headers.find(function (h) { return /severity|alvorlighet|nivå/i.test(h); });
+ topRisks = topRisksTbl.rows.map(function (row) {
+ return {
+ id: row[idKey] || '',
+ description: row[descKey] || row[idKey] || '',
+ score: scKey ? intOrZero(row[scKey] || '0') : 0,
+ severity: (sevKey && (row[sevKey] || '').toLowerCase().trim()) || ''
+ };
+ }).slice(0, 5);
+ } else if (matrix_cells.length) {
+ topRisks = matrix_cells.slice().sort(function (a, b) {
+ if (b.score !== a.score) return b.score - a.score;
+ return String(a.label || '').localeCompare(String(b.label || ''));
+ }).slice(0, 5).map(function (c) {
+ return {
+ id: '',
+ description: c.label || '',
+ score: c.score,
+ severity: ''
+ };
+ });
+ }
+ // categoryGrades: prøv ## Kategori-snitt-tabell først, ellers utled
+ // fra dimensions[]. Score → letter-grade A-F (5→A, 4→B, 3→C, 2→D, ≤1→F).
+ const gradeFor = function (sc) {
+ const n = Number(sc) || 0;
+ if (n >= 5) return 'A';
+ if (n >= 4) return 'B';
+ if (n >= 3) return 'C';
+ if (n >= 2) return 'D';
+ return 'F';
+ };
+ const catTbl = parseTable(md, /##\s*Kategori.snitt/i);
+ let categoryGrades = [];
+ if (catTbl && catTbl.rows.length) {
+ const nameKey = catTbl.headers[0];
+ const scKey = catTbl.headers.find(function (h) { return /score|snitt/i.test(h); }) || catTbl.headers[1];
+ categoryGrades = catTbl.rows.map(function (row) {
+ const sc = intOrZero(row[scKey] || '0');
+ return { name: row[nameKey] || '', score: sc, grade: gradeFor(sc) };
+ });
+ } else {
+ categoryGrades = dimensions.map(function (d) {
+ return { name: d.name, score: d.score, grade: gradeFor(d.score) };
+ });
+ }
+ // residualPair: same syntax som parseMatrixRisk.
+ let residualPair = null;
+ const rrMatch = md.match(/^Restrisiko\s*:\s*([^\n]+)$/im);
+ if (rrMatch) {
+ const txt = rrMatch[1];
+ const num = /(\d+)\s*[×x*]\s*(\d+)\s*(?:[-=]?[>→]|->)\s*(\d+)\s*[×x*]\s*(\d+)/.exec(txt);
+ if (num) {
+ const b1 = +num[1], b2 = +num[2], a1 = +num[3], a2 = +num[4];
+ residualPair = {
+ before: { prob: b1, cons: b2, score: b1 * b2 },
+ after: { prob: a1, cons: a2, score: a1 * a2 }
+ };
+ } else {
+ const parts = txt.split(/(?:[-=]?[>→]|->)/);
+ if (parts.length === 2) {
+ residualPair = {
+ before: { label: parts[0].trim() },
+ after: { label: parts[1].trim() }
+ };
+ }
+ }
+ }
return {
ok: true,
data: {
dimensions: dimensions,
matrix_cells: matrix_cells,
findings: findings,
- scores: dimensions.map(function (d) { return d.score; })
+ scores: dimensions.map(function (d) { return d.score; }),
+ topRisks: topRisks,
+ categoryGrades: categoryGrades,
+ residualPair: residualPair
}
};
}
@@ -3323,10 +3430,86 @@
// ---- Sub-batch B: Security (3) ----
function renderSecurity(data, slot) {
+ const sevForScore = function (s) {
+ const n = Number(s) || 0;
+ if (n >= 16) return 'critical';
+ if (n >= 9) return 'high';
+ if (n >= 4) return 'medium';
+ return 'low';
+ };
const matrixHtml = renderMatrixHtml(data, 6);
const radarHtml = renderRadarSvg(data.dimensions || []);
+ // C7 small-multiples per OWASP-kategori (driver: categoryGrades).
+ const cats = data.categoryGrades || [];
+ const smallMultiplesHtml = cats.length ? '
' + cats.map(function (c) {
+ const grade = c.grade || '';
+ const fillPct = Math.max(0, Math.min(100, ((Number(c.score) || 0) / 5) * 100));
+ return '
' +
+ '' +
+ '
' +
+ '
Score ' + escapeHtml(String(c.score || 0)) + ' / 5' +
+ '
';
+ }).join('') + '
' : '';
+ // C4 top-risks-list (max 5).
+ const topRisks = (data.topRisks || []).slice(0, 5);
+ const topRisksHtml = topRisks.length ? '' +
+ 'Top-risikoer
' +
+ topRisks.map(function (r, i) {
+ const sev = r.severity || sevForScore(r.score);
+ return '' +
+ '' + (i + 1) + '' +
+ '' + escapeHtml(r.description || '') + '' +
+ '' + escapeHtml(String(r.score || 0)) + '' +
+ '
';
+ }).join('') + '' : '';
+ // B6 residual-pair (når data.residualPair finnes).
+ const rp = data.residualPair;
+ let residualHtml = '';
+ if (rp && rp.before && rp.after) {
+ const labelOf = function (cell) {
+ if (cell.score != null) return cell.prob + '×' + cell.cons + ' = ' + cell.score;
+ return cell.label || '—';
+ };
+ const sevBefore = rp.before.score != null ? sevForScore(rp.before.score) : '';
+ const sevAfter = rp.after.score != null ? sevForScore(rp.after.score) : '';
+ const attrBefore = sevBefore ? ' data-severity="' + sevBefore + '"' : '';
+ const attrAfter = sevAfter ? ' data-severity="' + sevAfter + '"' : '';
+ residualHtml = '' +
+ '
' +
+ 'FØR TILTAK' +
+ '' + escapeHtml(labelOf(rp.before)) + '' +
+ 'Sannsynlighet × konsekvens' +
+ '
' +
+ '
→
' +
+ '
' +
+ 'ETTER TILTAK' +
+ '' + escapeHtml(labelOf(rp.after)) + '' +
+ 'Restrisiko' +
+ '
' +
+ '
';
+ }
const findingsHtml = renderFindingsBlock(data.findings || [], 'Sikkerhetsfunn');
- slot.innerHTML = matrixHtml + radarHtml + findingsHtml;
+ const body = matrixHtml + radarHtml + smallMultiplesHtml + topRisksHtml + residualHtml + findingsHtml;
+ // Utvid matrix-risk-6x5-keyStats med RESTRISIKO når residualPair finnes.
+ const baseStats = inferKeyStats(data, 'matrix-risk-6x5');
+ const stats = (data.keyStats || (rp && rp.after
+ ? baseStats.concat([{
+ label: 'RESTRISIKO',
+ value: rp.after.score != null ? String(rp.after.score) : (rp.after.label || '—'),
+ modifier: rp.after.score != null && rp.after.score >= 9 ? 'high' : 'low',
+ hint: 'etter tiltak'
+ }])
+ : baseStats));
+ slot.innerHTML = renderPageShell({
+ eyebrow: 'SIKKERHET',
+ title: data.title || 'Sikkerhetsvurdering (6×5)',
+ lede: data.lede || 'Score per dimensjon, risikomatrise og topp-risikoer mot NSM, Microsoft Cloud Security og AI Act Art. 15.',
+ verdict: data.verdict || inferVerdict(data, 'matrix-risk-6x5'),
+ keyStats: stats
+ }, body);
}
function renderRos(data, slot) {
diff --git a/plugins/ms-ai-architect/playground/test-fixtures/security.md b/plugins/ms-ai-architect/playground/test-fixtures/security.md
index 08285ef..b1bbb0f 100644
--- a/plugins/ms-ai-architect/playground/test-fixtures/security.md
+++ b/plugins/ms-ai-architect/playground/test-fixtures/security.md
@@ -36,6 +36,29 @@ Rammeverk: NSM Grunnprinsipper + Microsoft Cloud Security + EU AI Act Art. 15
| S-05 | medium | Logging | Implementer ML-basert avviksdeteksjon på AI-output-rate |
| S-06 | medium | Vendor | Bestilt third-party penetrasjons-test for Q3 2026 |
+## Top-risikoer
+
+| ID | Risiko | Score | Severity |
+|----|--------|-------|----------|
+| R-01 | Lekkasje av treningsdata | 10 | high |
+| R-02 | Insider-trussel (unauthorized inference) | 10 | high |
+| R-03 | Prompt injection i forklaringsmodell | 9 | high |
+| R-04 | Adversarielt eksempel forgifter output | 8 | medium |
+| R-05 | Cloud-leverandør-utilgjengelighet | 8 | medium |
+
+## Kategori-snitt
+
+| Kategori | Snitt |
+|----------|-------|
+| Identitet og tilgang | 4 |
+| Datasikkerhet og personvern | 3 |
+| Modell- og prompt-sikkerhet | 3 |
+| Nettverk og perimeter | 5 |
+| Logging og hendelseshåndtering | 4 |
+| Operasjonell og leverandørsikkerhet | 3 |
+
+Restrisiko: 5×4 → 2×3
+
## Aggregat
Totalscore: 22/30 (73%) — modent men ikke best-i-klassen. Modell- og prompt-sikkerhet er svakeste dimensjon.
diff --git a/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs b/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
index f9f1d97..d1cb24d 100644
--- a/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
+++ b/plugins/ultraplan-local/lib/parsers/manifest-yaml.mjs
@@ -20,6 +20,19 @@ const REQUIRED_KEYS = [
'must_contain',
];
+// Optional manifest keys (plan-v2 Step 4). Absence == false.
+// `skip_commit_check`: opt out of the per-step commit assertion (e.g. memory-only steps).
+// `memory_write` : marks a step that writes to ~/.claude/projects/.../memory/
+// so the executor can route it through the memory truth gate.
+const OPTIONAL_KEYS = [
+ 'skip_commit_check',
+ 'memory_write',
+];
+
+const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS);
+
+export { OPTIONAL_KEYS };
+
/**
* Extract the first fenced YAML block whose first non-blank line begins with
* `manifest:`.
@@ -84,6 +97,19 @@ export function parseManifest(stepBody) {
errors.push(issue('MANIFEST_COUNT_TYPE', 'min_file_count must be a number'));
}
+ for (const k of OPTIONAL_BOOLEAN_KEYS) {
+ if (k in parsed) {
+ if (typeof parsed[k] !== 'boolean') {
+ errors.push(issue(
+ 'MANIFEST_OPTIONAL_TYPE',
+ `${k} must be boolean if present (got ${typeof parsed[k]})`,
+ ));
+ }
+ } else {
+ parsed[k] = false; // default: absence == false
+ }
+ }
+
return { valid: errors.length === 0, errors, warnings, parsed };
}
diff --git a/plugins/ultraplan-local/lib/util/autonomy-gate.mjs b/plugins/ultraplan-local/lib/util/autonomy-gate.mjs
new file mode 100644
index 0000000..314899c
--- /dev/null
+++ b/plugins/ultraplan-local/lib/util/autonomy-gate.mjs
@@ -0,0 +1,129 @@
+// lib/util/autonomy-gate.mjs
+// Autonomy-gate state machine for /ultraexecute-local + /ultraplan-local
+// (plan-v2 Step 4 — drives the --gates flag).
+//
+// States:
+// idle — not yet started
+// gates_on — gates enabled, between phases
+// auto_running — running phases continuously without pausing
+// paused_for_gate — stopped at a phase boundary; awaiting `resume`
+// completed — terminal
+//
+// Events:
+// start — begin a run (gates flag chooses route)
+// phase_boundary — a phase finished
+// resume — operator confirmed; leave the gate
+// finish — pipeline reached its end
+//
+// CLI shim:
+// node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
+// → JSON: { ok: true, next_state: "..." } (success)
+// → JSON: { ok: false, error: "..." } (invalid transition; exit 1)
+//
+// Pure data; no I/O. Re-entry to `completed` is idempotent.
+
+export const STATES = Object.freeze({
+ IDLE: 'idle',
+ GATES_ON: 'gates_on',
+ AUTO_RUNNING: 'auto_running',
+ PAUSED_FOR_GATE: 'paused_for_gate',
+ COMPLETED: 'completed',
+});
+
+export const EVENTS = Object.freeze({
+ START: 'start',
+ PHASE_BOUNDARY: 'phase_boundary',
+ RESUME: 'resume',
+ FINISH: 'finish',
+});
+
+const STATE_SET = new Set(Object.values(STATES));
+const EVENT_SET = new Set(Object.values(EVENTS));
+
+/**
+ * Compute the next state given the current state, event, and (optional)
+ * gates-flag intent (only consulted on `start` from `idle`).
+ *
+ * @param {string} state
+ * @param {string} event
+ * @param {{ gates?: boolean }} [opts]
+ * @returns {{ ok: true, next_state: string } | { ok: false, error: string }}
+ */
+export function transition(state, event, opts = {}) {
+ if (!STATE_SET.has(state)) {
+ return { ok: false, error: `unknown state: ${state}` };
+ }
+ if (!EVENT_SET.has(event)) {
+ return { ok: false, error: `unknown event: ${event}` };
+ }
+
+ // completed is terminal & idempotent
+ if (state === STATES.COMPLETED) {
+ return { ok: true, next_state: STATES.COMPLETED };
+ }
+
+ if (state === STATES.IDLE) {
+ if (event === EVENTS.START) {
+ const gates = opts.gates === true;
+ return { ok: true, next_state: gates ? STATES.GATES_ON : STATES.AUTO_RUNNING };
+ }
+ return { ok: false, error: `invalid transition: idle + ${event} (only \`start\` allowed from idle)` };
+ }
+
+ if (state === STATES.GATES_ON) {
+ if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.PAUSED_FOR_GATE };
+ if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
+ return { ok: false, error: `invalid transition: gates_on + ${event}` };
+ }
+
+ if (state === STATES.AUTO_RUNNING) {
+ if (event === EVENTS.PHASE_BOUNDARY) return { ok: true, next_state: STATES.AUTO_RUNNING };
+ if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
+ return { ok: false, error: `invalid transition: auto_running + ${event}` };
+ }
+
+ if (state === STATES.PAUSED_FOR_GATE) {
+ if (event === EVENTS.RESUME) return { ok: true, next_state: STATES.GATES_ON };
+ if (event === EVENTS.FINISH) return { ok: true, next_state: STATES.COMPLETED };
+ return { ok: false, error: `invalid transition: paused_for_gate + ${event}` };
+ }
+
+ return { ok: false, error: `unhandled state: ${state}` };
+}
+
+/**
+ * Convenience: is this state terminal?
+ */
+export function isTerminal(state) {
+ return state === STATES.COMPLETED;
+}
+
+// ---- CLI shim ----------------------------------------------------------------
+
+function parseArgs(argv) {
+ const out = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--state') out.state = argv[++i];
+ else if (a === '--event') out.event = argv[++i];
+ else if (a === '--gates') {
+ const v = argv[++i];
+ out.gates = v === 'true';
+ }
+ }
+ return out;
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.state || !args.event) {
+ process.stdout.write(JSON.stringify({
+ ok: false,
+ error: 'usage: autonomy-gate.mjs --state --event [--gates true|false]',
+ }) + '\n');
+ process.exit(1);
+ }
+ const result = transition(args.state, args.event, { gates: args.gates });
+ process.stdout.write(JSON.stringify(result) + '\n');
+ process.exit(result.ok ? 0 : 1);
+}
diff --git a/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs b/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs
new file mode 100644
index 0000000..3bb77e6
--- /dev/null
+++ b/plugins/ultraplan-local/tests/lib/autonomy-gate.test.mjs
@@ -0,0 +1,147 @@
+// tests/lib/autonomy-gate.test.mjs
+// Cover the autonomy-gate state machine (lib/util/autonomy-gate.mjs):
+// every legal transition + every invalid-transition error + idempotent
+// re-entry to `completed` + CLI-shim JSON-on-stdout contract.
+
+import { test } from 'node:test';
+import { strict as assert } from 'node:assert';
+import { execFileSync } from 'node:child_process';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { transition, isTerminal, STATES, EVENTS } from '../../lib/util/autonomy-gate.mjs';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const SHIM = join(HERE, '..', '..', 'lib', 'util', 'autonomy-gate.mjs');
+
+function runShim(args) {
+ try {
+ const out = execFileSync(process.execPath, [SHIM, ...args], {
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return { code: 0, out };
+ } catch (e) {
+ return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
+ }
+}
+
+test('idle + start + gates=true → gates_on', () => {
+ const r = transition(STATES.IDLE, EVENTS.START, { gates: true });
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.GATES_ON);
+});
+
+test('idle + start + gates=false → auto_running', () => {
+ const r = transition(STATES.IDLE, EVENTS.START, { gates: false });
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.AUTO_RUNNING);
+});
+
+test('idle + start + gates omitted defaults to auto_running', () => {
+ const r = transition(STATES.IDLE, EVENTS.START);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.AUTO_RUNNING);
+});
+
+test('gates_on + phase_boundary → paused_for_gate', () => {
+ const r = transition(STATES.GATES_ON, EVENTS.PHASE_BOUNDARY);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.PAUSED_FOR_GATE);
+});
+
+test('gates_on + finish → completed', () => {
+ const r = transition(STATES.GATES_ON, EVENTS.FINISH);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.COMPLETED);
+});
+
+test('auto_running + phase_boundary → auto_running (no pause)', () => {
+ const r = transition(STATES.AUTO_RUNNING, EVENTS.PHASE_BOUNDARY);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.AUTO_RUNNING);
+});
+
+test('auto_running + finish → completed', () => {
+ const r = transition(STATES.AUTO_RUNNING, EVENTS.FINISH);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.COMPLETED);
+});
+
+test('paused_for_gate + resume → gates_on', () => {
+ const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.RESUME);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.GATES_ON);
+});
+
+test('paused_for_gate + finish → completed', () => {
+ const r = transition(STATES.PAUSED_FOR_GATE, EVENTS.FINISH);
+ assert.equal(r.ok, true);
+ assert.equal(r.next_state, STATES.COMPLETED);
+});
+
+test('completed + any event → completed (idempotent re-entry)', () => {
+ for (const ev of Object.values(EVENTS)) {
+ const r = transition(STATES.COMPLETED, ev);
+ assert.equal(r.ok, true, `event ${ev} should be tolerated from completed`);
+ assert.equal(r.next_state, STATES.COMPLETED, `event ${ev} broke idempotency`);
+ }
+});
+
+test('idle + non-start event → invalid transition error', () => {
+ const r = transition(STATES.IDLE, EVENTS.PHASE_BOUNDARY);
+ assert.equal(r.ok, false);
+ assert.match(r.error, /invalid transition.*idle/);
+});
+
+test('gates_on + resume → invalid (resume is only valid from paused_for_gate)', () => {
+ const r = transition(STATES.GATES_ON, EVENTS.RESUME);
+ assert.equal(r.ok, false);
+});
+
+test('auto_running + resume → invalid (auto-mode never pauses)', () => {
+ const r = transition(STATES.AUTO_RUNNING, EVENTS.RESUME);
+ assert.equal(r.ok, false);
+});
+
+test('unknown state rejected with descriptive error', () => {
+ const r = transition('zombie', EVENTS.START);
+ assert.equal(r.ok, false);
+ assert.match(r.error, /unknown state/);
+});
+
+test('unknown event rejected with descriptive error', () => {
+ const r = transition(STATES.IDLE, 'snooze');
+ assert.equal(r.ok, false);
+ assert.match(r.error, /unknown event/);
+});
+
+test('isTerminal — only completed is terminal', () => {
+ assert.equal(isTerminal(STATES.COMPLETED), true);
+ for (const s of [STATES.IDLE, STATES.GATES_ON, STATES.AUTO_RUNNING, STATES.PAUSED_FOR_GATE]) {
+ assert.equal(isTerminal(s), false, `${s} should not be terminal`);
+ }
+});
+
+test('CLI shim returns valid JSON on success (exit 0)', () => {
+ const r = runShim(['--state', 'idle', '--event', 'start', '--gates', 'true']);
+ assert.equal(r.code, 0);
+ const parsed = JSON.parse(r.out.trim());
+ assert.equal(parsed.ok, true);
+ assert.equal(parsed.next_state, 'gates_on');
+});
+
+test('CLI shim returns JSON error on invalid transition (exit 1)', () => {
+ const r = runShim(['--state', 'idle', '--event', 'phase_boundary']);
+ assert.equal(r.code, 1);
+ const parsed = JSON.parse(r.out.trim());
+ assert.equal(parsed.ok, false);
+ assert.match(parsed.error, /invalid transition/);
+});
+
+test('CLI shim missing required args returns usage error (exit 1)', () => {
+ const r = runShim(['--state', 'idle']);
+ assert.equal(r.code, 1);
+ const parsed = JSON.parse(r.out.trim());
+ assert.equal(parsed.ok, false);
+ assert.match(parsed.error, /usage:/);
+});
diff --git a/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs b/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs
new file mode 100644
index 0000000..fa326f7
--- /dev/null
+++ b/plugins/ultraplan-local/tests/lib/manifest-schema-extensions.test.mjs
@@ -0,0 +1,92 @@
+// tests/lib/manifest-schema-extensions.test.mjs
+// Cover the OPTIONAL_KEYS extension to lib/parsers/manifest-yaml.mjs:
+// - skip_commit_check (boolean, default false)
+// - memory_write (boolean, default false)
+//
+// Defaults must NOT break the REQUIRED_KEYS contract.
+// Non-boolean values must produce MANIFEST_OPTIONAL_TYPE error.
+
+import { test } from 'node:test';
+import { strict as assert } from 'node:assert';
+import { parseManifest, OPTIONAL_KEYS } from '../../lib/parsers/manifest-yaml.mjs';
+
+const BASE = `### Step 1: Cover
+- Manifest:
+ \`\`\`yaml
+ manifest:
+ expected_paths:
+ - lib/foo.mjs
+ min_file_count: 1
+ commit_message_pattern: "^feat:"
+ bash_syntax_check: []
+ forbidden_paths: []
+ must_contain: []`;
+
+function bodyWithExtras(extras) {
+ return `${BASE}\n${extras}\n \`\`\`\n`;
+}
+
+function bodyOnlyRequired() {
+ return `${BASE}\n \`\`\`\n`;
+}
+
+test('OPTIONAL_KEYS exports skip_commit_check + memory_write', () => {
+ assert.deepEqual(
+ [...OPTIONAL_KEYS].sort(),
+ ['memory_write', 'skip_commit_check'].sort(),
+ 'OPTIONAL_KEYS export drift — pin contract',
+ );
+});
+
+test('absence of optional keys → defaults to false (both fields)', () => {
+ const r = parseManifest(bodyOnlyRequired());
+ assert.equal(r.valid, true, JSON.stringify(r.errors));
+ assert.equal(r.parsed.skip_commit_check, false);
+ assert.equal(r.parsed.memory_write, false);
+});
+
+test('skip_commit_check: true honored', () => {
+ const r = parseManifest(bodyWithExtras(' skip_commit_check: true'));
+ assert.equal(r.valid, true, JSON.stringify(r.errors));
+ assert.equal(r.parsed.skip_commit_check, true);
+ assert.equal(r.parsed.memory_write, false);
+});
+
+test('memory_write: true honored', () => {
+ const r = parseManifest(bodyWithExtras(' memory_write: true'));
+ assert.equal(r.valid, true, JSON.stringify(r.errors));
+ assert.equal(r.parsed.memory_write, true);
+ assert.equal(r.parsed.skip_commit_check, false);
+});
+
+test('both optional fields together — both honored', () => {
+ const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true'));
+ assert.equal(r.valid, true, JSON.stringify(r.errors));
+ assert.equal(r.parsed.skip_commit_check, true);
+ assert.equal(r.parsed.memory_write, true);
+});
+
+test('skip_commit_check: non-boolean rejected with MANIFEST_OPTIONAL_TYPE', () => {
+ const r = parseManifest(bodyWithExtras(' skip_commit_check: "yes"'));
+ assert.equal(r.valid, false);
+ const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
+ assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
+ assert.match(found.message, /skip_commit_check/);
+});
+
+test('memory_write: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => {
+ const r = parseManifest(bodyWithExtras(' memory_write: 1'));
+ assert.equal(r.valid, false);
+ const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
+ assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
+ assert.match(found.message, /memory_write/);
+});
+
+test('extension does NOT break REQUIRED_KEYS contract', () => {
+ const r = parseManifest(bodyOnlyRequired());
+ assert.equal(r.valid, true);
+ for (const k of ['expected_paths', 'min_file_count', 'commit_message_pattern',
+ 'bash_syntax_check', 'forbidden_paths', 'must_contain']) {
+ assert.ok(k in r.parsed, `required key ${k} missing after extension`);
+ }
+});