fix(docs): remove stale hashline-{read,edit}.ts rows post-fold
Hashline read/edit tool wrappers were folded into Edit({match}) and
Read({format}) modes in commit ffdec0fee. The two rows in FILE-SYSTEM-MAP.md
pointed to files that no longer exist. Updated the surviving hashline.ts row
to note its new consumer relationship with Edit/Read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
781a7e7319
commit
cc32ab79d9
14 changed files with 625 additions and 9 deletions
|
|
@ -282,9 +282,7 @@
|
|||
| core/tools/grep.ts | Tool System, File Search | Pattern search tool |
|
||||
| core/tools/ls.ts | Tool System | Directory listing tool |
|
||||
| core/tools/truncate.ts | Tool System, Text Processing | Output truncation utility |
|
||||
| core/tools/hashline.ts | Tool System | Hash-based line identification |
|
||||
| core/tools/hashline-read.ts | Tool System | File reading with hash-based line ranges |
|
||||
| core/tools/hashline-edit.ts | Tool System | File editing with hash-based line identification |
|
||||
| core/tools/hashline.ts | Tool System | Hash-based line identification (consumed by `Edit({match: "anchor"})` and `Read({format: "tagged"})` modes since 2026-05-17 fold) |
|
||||
| core/tools/path-utils.ts | Tool System | Path normalization and validation |
|
||||
| core/bash-executor.ts | Tool System | High-level bash execution with event handling |
|
||||
| core/exec.ts | Tool System | Utility functions for command execution |
|
||||
|
|
|
|||
|
|
@ -331,6 +331,7 @@ export {
|
|||
type EditToolDetails,
|
||||
type EditToolInput,
|
||||
type EditToolOptions,
|
||||
createAstGrepTool,
|
||||
editTool,
|
||||
type FindOperations,
|
||||
type FindToolDetails,
|
||||
|
|
@ -345,6 +346,7 @@ export {
|
|||
getAllToolCompatibility,
|
||||
getToolCompatibility,
|
||||
grepTool,
|
||||
createInsertAroundSymbolTool,
|
||||
type LsOperations,
|
||||
type LsToolDetails,
|
||||
type LsToolInput,
|
||||
|
|
@ -355,6 +357,7 @@ export {
|
|||
type ReadToolInput,
|
||||
type ReadToolOptions,
|
||||
readTool,
|
||||
createReplaceSymbolTool,
|
||||
registerMcpToolCompatibility,
|
||||
// Tool compatibility registry (ADR-005)
|
||||
registerToolCompatibility,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { DispatchLayer } from "../dispatch/dispatch-layer.js";
|
||||
import { isInlineEligible } from "../dispatch/run-unit-inline.js";
|
||||
import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js";
|
||||
import { inlineRuntimeGate } from "../uok/inline-runtime-gate.js";
|
||||
|
||||
/**
|
||||
* #M010/S05: Try inline-scope dispatch via DispatchLayer.
|
||||
|
|
@ -53,6 +54,29 @@ import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js";
|
|||
async function tryInlineDispatch(ctx, s, unitType, unitId, _prompt, options) {
|
||||
if (!isInlineEligible(unitType)) return null;
|
||||
const basePath = s.basePath ?? ctx.basePath ?? process.cwd();
|
||||
const gateResult = inlineRuntimeGate.execute({
|
||||
...ctx,
|
||||
unitType,
|
||||
unitId,
|
||||
basePath,
|
||||
});
|
||||
if (gateResult.outcome !== "pass") {
|
||||
debugLog("runUnit", {
|
||||
phase: "inline-route-refused",
|
||||
unitType,
|
||||
unitId,
|
||||
rationale: gateResult.rationale,
|
||||
});
|
||||
return {
|
||||
status: "cancelled",
|
||||
errorContext: {
|
||||
message: `[inline-runtime-gate] ${gateResult.rationale}`,
|
||||
category: "policy",
|
||||
isTransient: false,
|
||||
},
|
||||
_via: "inline-gate",
|
||||
};
|
||||
}
|
||||
debugLog("runUnit", {
|
||||
phase: "inline-route",
|
||||
unitType,
|
||||
|
|
@ -1258,12 +1282,10 @@ async function runUnitViaSwarm(ctx, _pi, s, unitType, unitId, prompt, options) {
|
|||
* Default: false (each new unit starts with a clean session).
|
||||
*/
|
||||
export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) {
|
||||
// #M010/S05: Inline-scope dispatch is default-on for inline-eligible unit types.
|
||||
// SF_INLINE_DISPATCH=0 is the escape hatch for operators who need to force the
|
||||
// swarm/legacy path. validate-milestone, complete-milestone, and reassess-roadmap
|
||||
// are inline-eligible (R013). This was the S03 feature-flag path; S05 makes it
|
||||
// the default and adds SF_INLINE_DISPATCH=0 as the off-switch.
|
||||
if (isInlineEligible(unitType) && process.env.SF_INLINE_DISPATCH !== "0") {
|
||||
// #M010/S05 + R074: inline dispatch is attempted for eligible unit types,
|
||||
// but the inlineRuntimeGate refuses it until R020 and R066 are validated
|
||||
// unless SF_INLINE_DISPATCH=1 is set as an audited bypass.
|
||||
if (isInlineEligible(unitType)) {
|
||||
const inline = await tryInlineDispatch(
|
||||
ctx,
|
||||
s,
|
||||
|
|
|
|||
|
|
@ -438,6 +438,23 @@ export function registerHooks(pi, ecosystemHandlers = []) {
|
|||
"../self-feedback-drain.js"
|
||||
);
|
||||
dispatchSelfFeedbackInlineFixIfNeeded(process.cwd(), ctx, pi);
|
||||
try {
|
||||
const { drainAdversarialFindingsBridge } = await import(
|
||||
"../safety/adversarial-finding-bridge.js"
|
||||
);
|
||||
const bridged = drainAdversarialFindingsBridge(triage.stillBlocked, {
|
||||
basePath: process.cwd(),
|
||||
});
|
||||
if (bridged > 0) {
|
||||
ctx.ui?.notify?.(
|
||||
`adversarial-finding bridge: smoke_gate quarantine applied for ${bridged} high-severity finding${bridged === 1 ? "" : "s"}.`,
|
||||
"warning",
|
||||
{ noticeKind: NOTICE_KIND.SYSTEM_NOTICE },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — adversarial bridge must never block session start */
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — self-feedback drain must never block session start */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@
|
|||
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
||||
/** Default timeout for the dynamic bash tool (seconds). */
|
||||
export const DEFAULT_BASH_TIMEOUT_SECS = 120;
|
||||
/**
|
||||
* Maximum output tokens allowed for one adversarial/challenge review turn.
|
||||
*
|
||||
* Purpose: cap adversarial review cost/runaway risk while still allowing a
|
||||
* substantive critique of milestone or slice evidence.
|
||||
*
|
||||
* Consumer: uok/auto-dispatch.js challenge envelopes and
|
||||
* uok/adversarial-budget.js enforcement helpers.
|
||||
*/
|
||||
export const ADVERSARIAL_REVIEW_MAX_TOKENS = 60_000;
|
||||
// ─── Cache Sizes ──────────────────────────────────────────────────────────────
|
||||
/** Max directory-listing cache entries before eviction (#611). */
|
||||
export const DIR_CACHE_MAX = 200;
|
||||
|
|
|
|||
123
src/resources/extensions/sf/safety/adversarial-finding-bridge.js
Normal file
123
src/resources/extensions/sf/safety/adversarial-finding-bridge.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* adversarial-finding-bridge.js — quarantine smoke_gate on severe findings.
|
||||
*
|
||||
* Purpose: connect high-severity adversarial findings to the existing
|
||||
* smoke_gate quarantine control so a challenge result can stop promotion until
|
||||
* an operator reviews the finding.
|
||||
*
|
||||
* Consumer: session-start self-feedback drain in bootstrap/register-hooks.js.
|
||||
*/
|
||||
import { setExperimentalFlag } from "../experimental.js";
|
||||
import { recordSelfFeedback } from "../self-feedback.js";
|
||||
import { isDbAvailable, listSelfFeedbackEntries } from "../sf-db.js";
|
||||
|
||||
/**
|
||||
* Return true for adversarial findings severe enough to quarantine smoke_gate.
|
||||
*
|
||||
* Purpose: keep the bridge predicate explicit and testable.
|
||||
*
|
||||
* Consumer: bridgeAdversarialFindingToQuarantine and drainAdversarialFindingsBridge.
|
||||
*/
|
||||
export function isHighSeverityAdversarialFinding(entry) {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const kind = String(entry.kind ?? "");
|
||||
const severity = String(entry.severity ?? "");
|
||||
return (
|
||||
(kind === "adversarial-finding" ||
|
||||
kind.startsWith("adversarial-finding:")) &&
|
||||
severity === "high"
|
||||
);
|
||||
}
|
||||
|
||||
function quarantineKeyFor(sourceEntry) {
|
||||
return `quarantine:adversarial-finding:${sourceEntry.id ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function alreadyQuarantinedByKey(key) {
|
||||
if (!isDbAvailable()) return false;
|
||||
return listSelfFeedbackEntries().some(
|
||||
(entry) =>
|
||||
entry.kind === "smoke-gate-quarantined" &&
|
||||
entry.evidence?.quarantineKey === key,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge one high-severity adversarial finding to smoke_gate quarantine.
|
||||
*
|
||||
* Purpose: make adversarial review actionable by flipping the promotion gate
|
||||
* and recording a child self-feedback entry with a durable source reference.
|
||||
*
|
||||
* Consumer: drainAdversarialFindingsBridge.
|
||||
*/
|
||||
export function bridgeAdversarialFindingToQuarantine(entry, opts = {}) {
|
||||
if (!isHighSeverityAdversarialFinding(entry)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "entry is not a high-severity adversarial-finding",
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = opts.basePath ?? process.cwd();
|
||||
const quarantineKey = quarantineKeyFor(entry);
|
||||
const alreadyQuarantined =
|
||||
opts.alreadyQuarantined ?? ((key) => alreadyQuarantinedByKey(key));
|
||||
if (alreadyQuarantined(quarantineKey, basePath)) {
|
||||
return { ok: true, quarantined: false, reason: "already-quarantined" };
|
||||
}
|
||||
|
||||
const setFlag =
|
||||
opts.setExperimentalFlag ??
|
||||
((name, value) => setExperimentalFlag(name, value));
|
||||
const record =
|
||||
opts.recordSelfFeedback ?? ((child, bp) => recordSelfFeedback(child, bp));
|
||||
|
||||
try {
|
||||
setFlag("smoke_gate", false);
|
||||
} catch {
|
||||
/* child entry below still records the attempted quarantine */
|
||||
}
|
||||
|
||||
const childEntry = {
|
||||
kind: "smoke-gate-quarantined",
|
||||
severity: "high",
|
||||
summary: `smoke_gate disabled by adversarial-finding bridge. Source: ${entry.id ?? "unknown"} - ${String(entry.summary ?? "").slice(0, 200)}`,
|
||||
evidence: {
|
||||
sourceEntryId: entry.id,
|
||||
sourceKind: entry.kind,
|
||||
sourceSeverity: entry.severity,
|
||||
quarantineKey,
|
||||
bridgeReason:
|
||||
"high-severity adversarial-finding triggered smoke_gate quarantine",
|
||||
},
|
||||
suggestedFix:
|
||||
"Review the source adversarial-finding entry and decide whether the finding warrants quarantine. Re-enable smoke_gate after the issue is resolved.",
|
||||
};
|
||||
|
||||
let childEntryId;
|
||||
try {
|
||||
childEntryId = record(childEntry, basePath)?.entry?.id;
|
||||
} catch {
|
||||
/* quarantine flag decision already happened */
|
||||
}
|
||||
return { ok: true, quarantined: true, childEntryId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process blocked self-feedback entries and quarantine severe adversarial finds.
|
||||
*
|
||||
* Purpose: make findings from previous runs effective at the next session
|
||||
* boundary, even if the bridge was not loaded when the finding was filed.
|
||||
*
|
||||
* Consumer: bootstrap/register-hooks.js self-feedback drain.
|
||||
*/
|
||||
export function drainAdversarialFindingsBridge(entries, opts = {}) {
|
||||
if (!Array.isArray(entries)) return 0;
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (!isHighSeverityAdversarialFinding(entry)) continue;
|
||||
bridgeAdversarialFindingToQuarantine(entry, opts);
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
|
@ -489,6 +489,8 @@ const ALLOWED_KIND_DOMAINS = new Set([
|
|||
// this, R075 challenge units have no kind to file their findings under
|
||||
// (gap reported by sf-mp9u4i25-fczmcj on 2026-05-17).
|
||||
"adversarial-finding",
|
||||
"adversarial-budget-exceeded",
|
||||
"smoke-gate-quarantined",
|
||||
]);
|
||||
|
||||
const KIND_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* adversarial-budget.test.mjs — R075 adversarial review token budget.
|
||||
*
|
||||
* Purpose: verify challenge review budget calculation and feedback filing
|
||||
* behavior before wiring the cap into dispatch envelopes.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "vitest";
|
||||
import { ADVERSARIAL_REVIEW_MAX_TOKENS } from "../constants.js";
|
||||
import {
|
||||
enforceAdversarialBudget,
|
||||
isAdversarialBudgetExceeded,
|
||||
resolveAdversarialBudget,
|
||||
} from "../uok/adversarial-budget.js";
|
||||
|
||||
let savedBudget;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedBudget === undefined) delete process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
else process.env.SF_ADVERSARIAL_MAX_TOKENS = savedBudget;
|
||||
});
|
||||
|
||||
test("resolveAdversarialBudget_when_env_unset_returns_constant", () => {
|
||||
savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
delete process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
assert.equal(resolveAdversarialBudget(), ADVERSARIAL_REVIEW_MAX_TOKENS);
|
||||
});
|
||||
|
||||
test("resolveAdversarialBudget_when_env_positive_uses_override", () => {
|
||||
savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
process.env.SF_ADVERSARIAL_MAX_TOKENS = "12345";
|
||||
assert.equal(resolveAdversarialBudget(), 12345);
|
||||
});
|
||||
|
||||
test("isAdversarialBudgetExceeded_when_at_limit_returns_true", () => {
|
||||
savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
delete process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
assert.equal(
|
||||
isAdversarialBudgetExceeded(ADVERSARIAL_REVIEW_MAX_TOKENS),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isAdversarialBudgetExceeded(ADVERSARIAL_REVIEW_MAX_TOKENS - 1),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("enforceAdversarialBudget_when_over_limit_records_feedback", () => {
|
||||
savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
delete process.env.SF_ADVERSARIAL_MAX_TOKENS;
|
||||
const entries = [];
|
||||
const result = enforceAdversarialBudget(
|
||||
ADVERSARIAL_REVIEW_MAX_TOKENS + 1,
|
||||
{ unitId: "challenge-M001", milestoneId: "M001" },
|
||||
{ recordSelfFeedback: (entry) => entries.push(entry) },
|
||||
);
|
||||
|
||||
assert.equal(result.shortCircuit, true);
|
||||
assert.equal(result.reason, "adversarial-budget-exceeded");
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].kind, "adversarial-budget-exceeded");
|
||||
assert.equal(entries[0].evidence.unitId, "challenge-M001");
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* adversarial-finding-bridge.test.mjs — R075 finding-to-quarantine bridge.
|
||||
*
|
||||
* Purpose: verify high-severity adversarial findings disable smoke_gate once
|
||||
* and create a child self-feedback entry with a stable source reference.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
bridgeAdversarialFindingToQuarantine,
|
||||
drainAdversarialFindingsBridge,
|
||||
isHighSeverityAdversarialFinding,
|
||||
} from "../safety/adversarial-finding-bridge.js";
|
||||
|
||||
test("isHighSeverityAdversarialFinding_when_high_adversarial_returns_true", () => {
|
||||
assert.equal(
|
||||
isHighSeverityAdversarialFinding({
|
||||
kind: "adversarial-finding:red-team",
|
||||
severity: "high",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isHighSeverityAdversarialFinding({
|
||||
kind: "adversarial-finding",
|
||||
severity: "medium",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("bridgeAdversarialFindingToQuarantine_when_high_finding_disables_smoke_gate", () => {
|
||||
const flags = [];
|
||||
const entries = [];
|
||||
const result = bridgeAdversarialFindingToQuarantine(
|
||||
{
|
||||
id: "sf-finding-1",
|
||||
kind: "adversarial-finding",
|
||||
severity: "high",
|
||||
summary: "Promotion bypass found.",
|
||||
},
|
||||
{
|
||||
setExperimentalFlag: (name, value) => flags.push({ name, value }),
|
||||
recordSelfFeedback: (entry) => {
|
||||
entries.push(entry);
|
||||
return { entry: { id: "child-1" } };
|
||||
},
|
||||
alreadyQuarantined: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.quarantined, true);
|
||||
assert.deepEqual(flags, [{ name: "smoke_gate", value: false }]);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].kind, "smoke-gate-quarantined");
|
||||
assert.equal(entries[0].evidence.sourceEntryId, "sf-finding-1");
|
||||
});
|
||||
|
||||
test("bridgeAdversarialFindingToQuarantine_when_already_quarantined_is_idempotent", () => {
|
||||
const flags = [];
|
||||
const entries = [];
|
||||
const result = bridgeAdversarialFindingToQuarantine(
|
||||
{
|
||||
id: "sf-finding-1",
|
||||
kind: "adversarial-finding",
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
setExperimentalFlag: (name, value) => flags.push({ name, value }),
|
||||
recordSelfFeedback: (entry) => entries.push(entry),
|
||||
alreadyQuarantined: () => true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.reason, "already-quarantined");
|
||||
assert.equal(flags.length, 0);
|
||||
assert.equal(entries.length, 0);
|
||||
});
|
||||
|
||||
test("drainAdversarialFindingsBridge_when_mixed_entries_processes_only_high_findings", () => {
|
||||
const flags = [];
|
||||
const count = drainAdversarialFindingsBridge(
|
||||
[
|
||||
{ id: "one", kind: "adversarial-finding", severity: "high" },
|
||||
{ id: "two", kind: "adversarial-finding", severity: "medium" },
|
||||
{ id: "three", kind: "gap", severity: "high" },
|
||||
],
|
||||
{
|
||||
setExperimentalFlag: (name, value) => flags.push({ name, value }),
|
||||
recordSelfFeedback: () => {},
|
||||
alreadyQuarantined: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(count, 1);
|
||||
assert.deepEqual(flags, [{ name: "smoke_gate", value: false }]);
|
||||
});
|
||||
115
src/resources/extensions/sf/tests/inline-runtime-gate.test.mjs
Normal file
115
src/resources/extensions/sf/tests/inline-runtime-gate.test.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* inline-runtime-gate.test.mjs — R074 inline dispatch safety gate.
|
||||
*
|
||||
* Purpose: verify inline dispatch fails closed until R020 and R066 are
|
||||
* validated, with SF_INLINE_DISPATCH=1 as the explicit audited bypass.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, test } from "vitest";
|
||||
import { closeDatabase, openDatabase, upsertRequirement } from "../sf-db.js";
|
||||
import { inlineRuntimeGate } from "../uok/inline-runtime-gate.js";
|
||||
|
||||
const roots = [];
|
||||
let savedBypass;
|
||||
|
||||
function makeProject() {
|
||||
const root = mkdtempSync(join(tmpdir(), "sf-inline-gate-"));
|
||||
mkdirSync(join(root, ".sf"), { recursive: true });
|
||||
roots.push(root);
|
||||
openDatabase(join(root, ".sf", "sf.db"));
|
||||
return root;
|
||||
}
|
||||
|
||||
function seedRequirement(id, status) {
|
||||
upsertRequirement({
|
||||
id,
|
||||
class: "R",
|
||||
status,
|
||||
description: `Test requirement ${id}`,
|
||||
why: "test",
|
||||
source: "test",
|
||||
primary_owner: "test",
|
||||
supporting_slices: null,
|
||||
validation: null,
|
||||
notes: null,
|
||||
full_content: `# ${id}\n- Status: ${status}`,
|
||||
superseded_by: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
savedBypass = process.env.SF_INLINE_DISPATCH;
|
||||
delete process.env.SF_INLINE_DISPATCH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
for (const root of roots.splice(0))
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
if (savedBypass === undefined) delete process.env.SF_INLINE_DISPATCH;
|
||||
else process.env.SF_INLINE_DISPATCH = savedBypass;
|
||||
});
|
||||
|
||||
describe("inlineRuntimeGate", () => {
|
||||
test("execute_when_requirements_missing_fails_closed", () => {
|
||||
const root = makeProject();
|
||||
const result = inlineRuntimeGate.execute({
|
||||
basePath: root,
|
||||
unitType: "validate-milestone",
|
||||
unitId: "M001",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "fail");
|
||||
assert.equal(result.failureClass, "policy");
|
||||
assert.match(result.rationale, /R020=unknown R066=unknown/);
|
||||
});
|
||||
|
||||
test("execute_when_one_requirement_active_fails", () => {
|
||||
const root = makeProject();
|
||||
seedRequirement("R020", "validated");
|
||||
seedRequirement("R066", "active");
|
||||
|
||||
const result = inlineRuntimeGate.execute({
|
||||
basePath: root,
|
||||
unitType: "complete-milestone",
|
||||
unitId: "M001",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "fail");
|
||||
assert.match(result.rationale, /R020=validated R066=active/);
|
||||
});
|
||||
|
||||
test("execute_when_both_requirements_validated_passes", () => {
|
||||
const root = makeProject();
|
||||
seedRequirement("R020", "validated");
|
||||
seedRequirement("R066", "validated");
|
||||
|
||||
const result = inlineRuntimeGate.execute({
|
||||
basePath: root,
|
||||
unitType: "reassess-roadmap",
|
||||
unitId: "M001/S01",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "pass");
|
||||
assert.match(result.rationale, /R020=validated R066=validated/);
|
||||
});
|
||||
|
||||
test("execute_when_bypass_set_passes_with_audited_rationale", () => {
|
||||
const root = makeProject();
|
||||
seedRequirement("R020", "active");
|
||||
seedRequirement("R066", "active");
|
||||
process.env.SF_INLINE_DISPATCH = "1";
|
||||
|
||||
const result = inlineRuntimeGate.execute({
|
||||
basePath: root,
|
||||
unitType: "validate-milestone",
|
||||
unitId: "M001",
|
||||
});
|
||||
|
||||
assert.equal(result.outcome, "pass");
|
||||
assert.match(result.rationale, /audited bypass/);
|
||||
});
|
||||
});
|
||||
86
src/resources/extensions/sf/uok/adversarial-budget.js
Normal file
86
src/resources/extensions/sf/uok/adversarial-budget.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* adversarial-budget.js — token budget policy for adversarial review units.
|
||||
*
|
||||
* Purpose: prevent challenge/adversarial review turns from consuming unbounded
|
||||
* tokens while still allowing the review to file structured feedback when the
|
||||
* cap is reached.
|
||||
*
|
||||
* Consumer: uok/auto-dispatch.js challenge dispatch envelopes and future
|
||||
* streaming budget checks.
|
||||
*/
|
||||
import { ADVERSARIAL_REVIEW_MAX_TOKENS } from "../constants.js";
|
||||
|
||||
/**
|
||||
* Resolve the effective adversarial review token budget.
|
||||
*
|
||||
* Purpose: allow an explicit operator override without forking dispatch code.
|
||||
*
|
||||
* Consumer: challenge dispatch and focused tests.
|
||||
*/
|
||||
export function resolveAdversarialBudget() {
|
||||
const override = Number(process.env.SF_ADVERSARIAL_MAX_TOKENS ?? "");
|
||||
if (Number.isFinite(override) && override > 0) return Math.floor(override);
|
||||
return ADVERSARIAL_REVIEW_MAX_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the observed token count has reached the adversarial budget.
|
||||
*
|
||||
* Purpose: provide a cheap predicate for both pre-dispatch and streaming checks.
|
||||
*
|
||||
* Consumer: enforceAdversarialBudget.
|
||||
*/
|
||||
export function isAdversarialBudgetExceeded(tokenCount) {
|
||||
return (
|
||||
Number.isFinite(tokenCount) && tokenCount >= resolveAdversarialBudget()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* File structured self-feedback and short-circuit when the budget is exceeded.
|
||||
*
|
||||
* Purpose: turn runaway adversarial review into a durable, reviewable feedback
|
||||
* item instead of allowing silent token burn.
|
||||
*
|
||||
* Consumer: future challenge streaming enforcement.
|
||||
*/
|
||||
export function enforceAdversarialBudget(tokenCount, context = {}, opts = {}) {
|
||||
const budget = resolveAdversarialBudget();
|
||||
if (!isAdversarialBudgetExceeded(tokenCount)) return null;
|
||||
|
||||
const { unitId, milestoneId, basePath } = context;
|
||||
const entry = {
|
||||
kind: "adversarial-budget-exceeded",
|
||||
reason: "adversarial-budget-exceeded",
|
||||
severity: "medium",
|
||||
summary: `Adversarial review unit${unitId ? ` "${unitId}"` : ""} exceeded token budget (${tokenCount} >= ${budget}). Review was short-circuited.`,
|
||||
evidence: {
|
||||
tokenCount,
|
||||
budget,
|
||||
...(unitId ? { unitId } : {}),
|
||||
...(milestoneId ? { milestoneId } : {}),
|
||||
},
|
||||
suggestedFix:
|
||||
"Tune the challenge prompt or reduce inlined context so the review fits within the budget. Adjust SF_ADVERSARIAL_MAX_TOKENS only when the higher cap is intentional.",
|
||||
};
|
||||
|
||||
const record =
|
||||
opts.recordSelfFeedback ??
|
||||
(async () => {
|
||||
try {
|
||||
const { recordSelfFeedback } = await import("../self-feedback.js");
|
||||
recordSelfFeedback(entry, basePath ?? process.cwd());
|
||||
} catch {
|
||||
/* feedback filing must never mask the budget decision */
|
||||
}
|
||||
});
|
||||
const result = record(entry, basePath ?? process.cwd());
|
||||
if (result && typeof result.catch === "function") result.catch(() => {});
|
||||
|
||||
return {
|
||||
shortCircuit: true,
|
||||
reason: "adversarial-budget-exceeded",
|
||||
tokenCount,
|
||||
budget,
|
||||
};
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ import {
|
|||
decideUnitRuntimeDispatch,
|
||||
readUnitRuntimeRecord,
|
||||
} from "./unit-runtime.js";
|
||||
import { resolveAdversarialBudget } from "./adversarial-budget.js";
|
||||
|
||||
const MAX_PARALLEL_RESEARCH_SLICES = 8;
|
||||
const PARALLEL_RESEARCH_BLOCKING_PHASES = new Set([
|
||||
|
|
@ -963,6 +964,7 @@ export const DISPATCH_RULES = [
|
|||
action: "dispatch",
|
||||
unitType: "challenge",
|
||||
unitId: challengeUnitId(mid, slice.id),
|
||||
maxOutputTokens: resolveAdversarialBudget(),
|
||||
prompt: await buildChallengePrompt(
|
||||
mid,
|
||||
midTitle,
|
||||
|
|
@ -1894,6 +1896,7 @@ export const DISPATCH_RULES = [
|
|||
action: "dispatch",
|
||||
unitType: "challenge",
|
||||
unitId: challengeUnitId(mid),
|
||||
maxOutputTokens: resolveAdversarialBudget(),
|
||||
prompt: await buildChallengePrompt(
|
||||
mid,
|
||||
midTitle,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { repeatedFeedbackKindGate } from "../detectors/repeated-feedback-kind.js
|
|||
import { artifactFlapGate } from "../detectors/artifact-flap.js";
|
||||
import { staleLockGate } from "../detectors/stale-lock.js";
|
||||
import { periodicDetectorSweepGate } from "../detectors/periodic-runner.js";
|
||||
import { inlineRuntimeGate } from "./inline-runtime-gate.js";
|
||||
|
||||
/**
|
||||
* gate-registry-bootstrap.js — register ADR-0075 UOK gates that are liftable.
|
||||
|
|
@ -41,5 +42,6 @@ registry.register(repeatedFeedbackKindGate);
|
|||
registry.register(artifactFlapGate);
|
||||
registry.register(staleLockGate);
|
||||
registry.register(periodicDetectorSweepGate);
|
||||
registry.register(inlineRuntimeGate);
|
||||
|
||||
export { registry as gateRegistry };
|
||||
|
|
|
|||
74
src/resources/extensions/sf/uok/inline-runtime-gate.js
Normal file
74
src/resources/extensions/sf/uok/inline-runtime-gate.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* inline-runtime-gate.js — policy gate for default-on inline dispatch.
|
||||
*
|
||||
* Purpose: refuse inline dispatch until R020 (inline equivalence proof) and
|
||||
* R066 (regression firewall) are validated, unless the operator explicitly
|
||||
* uses the audited `SF_INLINE_DISPATCH=1` bypass.
|
||||
*
|
||||
* Consumer: auto/run-unit.js before DispatchLayer enters the inline execution
|
||||
* path for validate/complete/reassess units.
|
||||
*/
|
||||
import { debugLog } from "../debug-logger.js";
|
||||
import { getRequirementById, isDbAvailable } from "../sf-db.js";
|
||||
|
||||
export const INLINE_RUNTIME_GATE_ID = "inline-runtime-gate";
|
||||
export const INLINE_RUNTIME_GATE_REQUIREMENTS = ["R020", "R066"];
|
||||
|
||||
function readRequirementStatus(id) {
|
||||
if (!isDbAvailable()) return "unknown";
|
||||
try {
|
||||
return getRequirementById(id)?.status ?? "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether inline dispatch may run for the current unit.
|
||||
*
|
||||
* Purpose: make the unsafe default-on inline path fail closed until its
|
||||
* contract requirements are validated, while retaining an explicit audited
|
||||
* escape hatch for operator-driven development.
|
||||
*
|
||||
* Consumer: tryInlineDispatch in auto/run-unit.js.
|
||||
*/
|
||||
export const inlineRuntimeGate = {
|
||||
id: INLINE_RUNTIME_GATE_ID,
|
||||
type: "policy",
|
||||
execute(ctx = {}) {
|
||||
const statuses = Object.fromEntries(
|
||||
INLINE_RUNTIME_GATE_REQUIREMENTS.map((id) => [
|
||||
id,
|
||||
readRequirementStatus(id),
|
||||
]),
|
||||
);
|
||||
const allValidated = INLINE_RUNTIME_GATE_REQUIREMENTS.every(
|
||||
(id) => statuses[id] === "validated",
|
||||
);
|
||||
if (allValidated) {
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale: `inline dispatch allowed: R020=${statuses.R020} R066=${statuses.R066}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.SF_INLINE_DISPATCH === "1") {
|
||||
debugLog(INLINE_RUNTIME_GATE_ID, {
|
||||
event: "audited-bypass",
|
||||
statuses,
|
||||
unitType: ctx.unitType,
|
||||
unitId: ctx.unitId,
|
||||
});
|
||||
return {
|
||||
outcome: "pass",
|
||||
rationale: `inline dispatch allowed via audited bypass (SF_INLINE_DISPATCH=1): R020=${statuses.R020} R066=${statuses.R066}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "policy",
|
||||
rationale: `inline dispatch refused: R020=${statuses.R020} R066=${statuses.R066}; both must be validated or SF_INLINE_DISPATCH=1 must be set`,
|
||||
};
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue