From 0efc9cd656293f0b87250e69dfddb69d6809698a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 02:32:15 +0200 Subject: [PATCH] =?UTF-8?q?docs(sf):=20final=20cluster=20JSDoc=20=E2=80=94?= =?UTF-8?q?=20mcp/preferences/native=20bridges/sf-db/onboarding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extensions/sf/preferences-types.ts | 3 + .../extensions/sf/tests/ask-gate.test.ts | 166 ++++++++++++++++++ .../extensions/sf/workflow-events.ts | 6 +- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/sf/tests/ask-gate.test.ts diff --git a/src/resources/extensions/sf/preferences-types.ts b/src/resources/extensions/sf/preferences-types.ts index 9e95b2643..0678ab62d 100644 --- a/src/resources/extensions/sf/preferences-types.ts +++ b/src/resources/extensions/sf/preferences-types.ts @@ -154,6 +154,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "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[]; diff --git a/src/resources/extensions/sf/tests/ask-gate.test.ts b/src/resources/extensions/sf/tests/ask-gate.test.ts new file mode 100644 index 000000000..cd8a37a86 --- /dev/null +++ b/src/resources/extensions/sf/tests/ask-gate.test.ts @@ -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); + }); +}); diff --git a/src/resources/extensions/sf/workflow-events.ts b/src/resources/extensions/sf/workflow-events.ts index 7958ac5c9..bcca391f8 100644 --- a/src/resources/extensions/sf/workflow-events.ts +++ b/src/resources/extensions/sf/workflow-events.ts @@ -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`, );