fix: stabilize headless bootstrap and prompt history

This commit is contained in:
Mikael Hugo 2026-05-07 16:44:24 +02:00
parent deeb4dbd4e
commit 3c84bd2fed
6 changed files with 113 additions and 41 deletions

19
TODO.md
View file

@ -1,19 +0,0 @@
# Raw Dump Inbox
This file is only a temporary human dump inbox. Do not treat it as durable
runtime memory or an approved backlog.
## Untriaged Notes
No untriaged notes. Add raw dumps here temporarily, then promote them to
`docs/plans/`, `docs/adr/`, `docs/specs/`, or another durable project artifact
before starting implementation.
## Processed Notes
- SF auto-loop hardening was converted into SF milestone state on 2026-05-02.
Continue from `.sf/STATE.md`, `.sf/milestones/M013/`, and
`.sf/milestones/M014/` instead of reusing the old raw dump.
- Feature gaps and UOK self-evolution research from 2026-05-06 were triaged on
2026-05-06 into `docs/plans/todo-triage-2026-05-06-plan.md` and existing
durable docs such as `docs/dev/UOK-SELF-EVOLUTION.md`.

View file

@ -53,11 +53,13 @@ export default function sfTui(pi) {
registerSessionEmoji(pi);
registerSessionColor(pi);
const promptHistory = readPromptHistory();
const promptHistorySessionId = randomUUID();
let promptHistorySessionId = randomUUID();
let projectBasePath = null;
let wasAutoActive = false;
pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return;
promptHistorySessionId =
ctx.sessionManager?.getSessionId?.() ?? promptHistorySessionId;
try {
projectBasePath = projectRoot();
const projectPromptHistory = readPromptHistory(projectBasePath);

View file

@ -13,6 +13,30 @@ const SCAN_LINE_LIMIT = 2000;
function promptHistoryPath() {
return join(homedir(), ".sf", "agent", "prompt-history.jsonl");
}
function isEnvTruthy(value) {
return ["1", "true", "TRUE", "yes", "YES"].includes(String(value ?? ""));
}
function parseEntryLine(line) {
try {
const text = line.trim();
if (!text) return [];
const entry = JSON.parse(text);
if (
!entry ||
typeof entry !== "object" ||
entry.version !== 1 ||
typeof entry.prompt !== "string" ||
entry.prompt.trim().length === 0 ||
typeof entry.projectRoot !== "string" ||
entry.projectRoot.trim().length === 0
) {
return [];
}
return [entry];
} catch {
return [];
}
}
function readEntries() {
try {
const path = promptHistoryPath();
@ -21,23 +45,7 @@ function readEntries() {
.split(/\r?\n/)
.reverse()
.slice(0, SCAN_LINE_LIMIT)
.flatMap((line) => {
const text = line.trim();
if (!text) return [];
const entry = JSON.parse(text);
if (
!entry ||
typeof entry !== "object" ||
entry.version !== 1 ||
typeof entry.prompt !== "string" ||
entry.prompt.trim().length === 0 ||
typeof entry.projectRoot !== "string" ||
entry.projectRoot.trim().length === 0
) {
return [];
}
return [entry];
});
.flatMap(parseEntryLine);
} catch {
return [];
}
@ -76,6 +84,7 @@ export function readPromptHistory(basePath) {
);
}
export function appendPromptHistory(prompt, basePath, sessionId) {
if (isEnvTruthy(process.env.SF_SKIP_PROMPT_HISTORY)) return;
if (!basePath) return;
const normalized = normalizeHistory([prompt]);
if (!normalized.length) return;

View file

@ -49,10 +49,6 @@ import {
parseDeferredRequirements,
resolveAllOverrides,
} from "./files.js";
import {
checkNeedsReassessment,
checkNeedsRunUat,
} from "./workflow-helpers.js";
import {
getRelevantMemoriesRanked,
isDbAvailable as isMemoryDbAvailable,
@ -100,6 +96,10 @@ import {
readUnitRuntimeRecord,
} from "./uok/unit-runtime.js";
import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
import {
checkNeedsReassessment,
checkNeedsRunUat,
} from "./workflow-helpers.js";
import { logError, logWarning } from "./workflow-logger.js";
const MAX_PARALLEL_RESEARCH_SLICES = 8;

View file

@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import {
clearPathCache,
relMilestoneFile,
resolveMilestoneFile,
resolveMilestonePath,
} from "../paths.js";
test("resolveMilestonePath_when_unique_id_has_bare_disk_dir_uses_canonical_dir", () => {
const root = mkdtempSync(join(tmpdir(), "sf-paths-unique-"));
mkdirSync(join(root, ".sf", "milestones", "M001"), { recursive: true });
writeFileSync(
join(root, ".sf", "milestones", "M001", "M001-CONTEXT.md"),
"# Context\n",
);
clearPathCache();
assert.equal(
resolveMilestonePath(root, "M001-6377a4"),
join(root, ".sf", "milestones", "M001"),
);
assert.equal(
resolveMilestoneFile(root, "M001-6377a4", "CONTEXT"),
join(root, ".sf", "milestones", "M001", "M001-CONTEXT.md"),
);
});
test("relMilestoneFile_when_unique_id_resolves_bare_dir_uses_bare_file_id", () => {
const root = mkdtempSync(join(tmpdir(), "sf-paths-unique-rel-"));
mkdirSync(join(root, ".sf", "milestones", "M001"), { recursive: true });
clearPathCache();
assert.equal(
relMilestoneFile(root, "M001-6377a4", "ROADMAP"),
".sf/milestones/M001/M001-ROADMAP.md",
);
});

View file

@ -16,18 +16,26 @@ import {
describe("prompt history", () => {
let oldHome;
let oldSkipPromptHistory;
let homeDir;
let projectDir;
beforeEach(() => {
oldHome = process.env.HOME;
oldSkipPromptHistory = process.env.SF_SKIP_PROMPT_HISTORY;
homeDir = mkdtempSync(join(tmpdir(), "sf-home-"));
projectDir = mkdtempSync(join(tmpdir(), "sf-project-"));
process.env.HOME = homeDir;
delete process.env.SF_SKIP_PROMPT_HISTORY;
});
afterEach(() => {
process.env.HOME = oldHome;
if (oldSkipPromptHistory === undefined) {
delete process.env.SF_SKIP_PROMPT_HISTORY;
} else {
process.env.SF_SKIP_PROMPT_HISTORY = oldSkipPromptHistory;
}
rmSync(homeDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
});
@ -121,6 +129,27 @@ describe("prompt history", () => {
expect(readPromptHistory(projectDir)).toEqual(["repeat", "newest"]);
});
it("readPromptHistory_when_jsonl_contains_malformed_line_skips_that_line", () => {
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
mkdirSync(join(homeDir, ".sf", "agent"), { recursive: true });
writeFileSync(
globalPath,
[
"{not json",
JSON.stringify({
version: 1,
prompt: "valid",
projectRoot: projectDir,
sessionId: "session-1",
timestamp: 1,
}),
].join("\n") + "\n",
"utf-8",
);
expect(readPromptHistory(projectDir)).toEqual(["valid"]);
});
it("readPromptHistory_when_legacy_untagged_history_exists_ignores_it", () => {
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.json");
mkdirSync(join(homeDir, ".sf", "agent"), { recursive: true });
@ -140,4 +169,13 @@ describe("prompt history", () => {
expect(existsSync(globalPath)).toBe(false);
expect(readPromptHistory()).toEqual([]);
});
it("appendPromptHistory_when_skip_env_enabled_does_not_persist_history", () => {
process.env.SF_SKIP_PROMPT_HISTORY = "1";
appendPromptHistory("secret prompt", projectDir);
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
expect(existsSync(globalPath)).toBe(false);
});
});