docs(sf): final cluster JSDoc — mcp/preferences/native bridges/sf-db/onboarding

This commit is contained in:
Mikael Hugo 2026-05-02 02:32:15 +02:00
parent f761d31d1c
commit 0efc9cd656
3 changed files with 172 additions and 3 deletions

View file

@ -154,6 +154,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"workspace",
"subscription",
"allow_flat_rate_providers",
"planning_depth",
]);
/** Canonical list of all dispatch unit types. */
@ -418,6 +419,8 @@ export interface SFPreferences {
* the shipped body and the on-disk body. Not user-set.
*/
sf_template_hash?: string;
/** Planning depth: "light" (default) or "deep" for full deep-planning-mode artifact gates. */
planning_depth?: "light" | "deep";
mode?: WorkflowMode;
always_use_skills?: string[];
prefer_skills?: string[];

View file

@ -0,0 +1,166 @@
/**
* Tests for the ask_user_questions gate (bootstrap/ask-gate.ts).
*
* Because node:test does not enable --experimental-test-module-mocks,
* we test the gate logic via a locally-injectable mirror of the function
* (same shape as gateAskUserQuestions) and also verify structural invariants
* in the source text to catch drift between the real function and the contract.
*
* Gate contract:
* - Returns { allow: true } when not in autonomous mode (isAutoActive=false)
* - Returns { allow: true } when canAskUser=true (auto/step mode)
* - Returns { allow: false, reason } when isAutoActive=true + canAskUser=false
* - The reason mentions Tier 1, Tier 2, and a structured-blocker exit
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it } from "node:test";
// ─── Local gate mirror (injectable deps) ────────────────────────────────────
interface AskGateDecision {
allow: boolean;
reason?: string;
}
/**
* Mirror of gateAskUserQuestions with injectable state used to unit-test
* the gate logic without real module state or module mocking.
*/
function makeGate(
isAutoActive: boolean,
canAskUser: boolean,
): (payload: unknown) => AskGateDecision {
return function gateAskUserQuestions(
_questionPayload: unknown,
): AskGateDecision {
if (!isAutoActive || canAskUser) {
return { allow: true };
}
const reason =
"ask_user_questions is forbidden in autonomous mode. " +
"Resolve via Tier 1 (code/sift/source files/.sf/KNOWLEDGE.md/.sf/DECISIONS.md) " +
"or Tier 2 (WebSearch/WebFetch/Context7). " +
"If the question is genuinely user-only (a preference, intent, design choice), " +
"exit with a structured blocker message naming the unresolved ambiguity instead of calling this tool.";
return { allow: false, reason };
};
}
// ─── Source-level structural assertions ─────────────────────────────────────
const src = readFileSync(
join(import.meta.dirname, "..", "bootstrap", "ask-gate.ts"),
"utf-8",
);
describe("ask-gate: source structural invariants", () => {
it("exports gateAskUserQuestions", () => {
assert.ok(
src.includes("export function gateAskUserQuestions"),
"ask-gate.ts must export gateAskUserQuestions",
);
});
it("exports AskGateDecision interface", () => {
assert.ok(
src.includes("export interface AskGateDecision"),
"ask-gate.ts must export AskGateDecision interface",
);
});
it("imports isAutoActive and isCanAskUser from auto.js", () => {
assert.ok(
src.includes("isAutoActive") && src.includes("isCanAskUser"),
"ask-gate.ts must import isAutoActive and isCanAskUser",
);
});
it("mentions Tier 1 in the rejection reason", () => {
assert.ok(src.includes("Tier 1"), `ask-gate.ts must mention "Tier 1"`);
});
it("mentions Tier 2 in the rejection reason", () => {
assert.ok(src.includes("Tier 2"), `ask-gate.ts must mention "Tier 2"`);
});
it("mentions structured blocker message in the rejection reason", () => {
assert.ok(
src.includes("structured blocker message"),
`ask-gate.ts must mention "structured blocker message"`,
);
});
it("calls logWarning on block", () => {
assert.ok(
src.includes("logWarning"),
"ask-gate.ts must call logWarning when blocking",
);
});
});
// ─── Behavioural tests via injectable mirror ────────────────────────────────
describe("gateAskUserQuestions: allow path", () => {
it("returns { allow: true } when auto is not active", () => {
const gate = makeGate(false, false);
const decision = gate({ questions: ["What is the version?"] });
assert.equal(decision.allow, true);
assert.equal(decision.reason, undefined);
});
it("returns { allow: true } when canAskUser=true (auto mode)", () => {
const gate = makeGate(true, true);
const decision = gate({ questions: ["What is the version?"] });
assert.equal(decision.allow, true);
assert.equal(decision.reason, undefined);
});
it("returns { allow: true } when not active and canAskUser=false", () => {
const gate = makeGate(false, false);
const decision = gate("plain string payload");
assert.equal(decision.allow, true);
});
});
describe("gateAskUserQuestions: block path (autonomous mode)", () => {
const gate = makeGate(true, false);
it("returns { allow: false } when isAutoActive=true and canAskUser=false", () => {
const decision = gate({ questions: ["What is the repo URL?"] });
assert.equal(decision.allow, false);
});
it("provides a reason string when blocking", () => {
const decision = gate({ questions: ["Preferred code style?"] });
assert.ok(typeof decision.reason === "string" && decision.reason.length > 0);
});
it("reason mentions Tier 1", () => {
const { reason } = gate({ questions: ["What version is library X?"] });
assert.ok(reason?.includes("Tier 1"), `reason must mention Tier 1: ${reason}`);
});
it("reason mentions Tier 2", () => {
const { reason } = gate({ questions: ["What version is library X?"] });
assert.ok(reason?.includes("Tier 2"), `reason must mention Tier 2: ${reason}`);
});
it("reason mentions structured blocker message", () => {
const { reason } = gate({ questions: ["What is the project intent?"] });
assert.ok(
reason?.includes("structured blocker message"),
`reason must mention structured blocker message: ${reason}`,
);
});
it("works with non-object payloads", () => {
const decision = gate("raw string question");
assert.equal(decision.allow, false);
assert.ok(decision.reason);
});
});

View file

@ -149,10 +149,10 @@ export function compactMilestoneEvents(
basePath: string,
milestoneId: string,
): { archived: number } {
const logPath = join(basePath, ".sf", "event-log.jsonl");
const runtimeDir = sfRuntimeRoot(basePath);
const logPath = join(runtimeDir, "event-log.jsonl");
const archivePath = join(
basePath,
".sf",
runtimeDir,
`event-log-${milestoneId}.jsonl.archived`,
);