sf snapshot: uncommitted changes after 258m inactivity

This commit is contained in:
Mikael Hugo 2026-05-07 15:37:55 +02:00
parent e154dad930
commit 8088489e38
13 changed files with 383 additions and 42 deletions

View file

@ -2,7 +2,7 @@
## Purpose ## Purpose
Singularity Forge (SF) is an autonomous agent orchestration system. It runs long-horizon coding work through the Unified Operation Kernel (UOK): milestones → slices → tasks. Each dispatch unit runs a fresh AI context, writes its output to disk, then terminates. UOK owns lifecycle, recovery, and the DB-backed run ledger; runtime files under `.sf/runtime/` are projections for query, UI, and compatibility. A deterministic controller (not an LLM) reads canonical state and decides what to dispatch next. The user is the end-gate — autonomous mode delivers work to human review, it does not merge to production unattended. Singularity Forge (SF) is the product. It runs long-horizon coding work through the Unified Operation Kernel (UOK): milestones → slices → tasks. Each dispatch unit runs a fresh AI context, writes its output to disk, then terminates. UOK owns lifecycle, recovery, and the DB-backed run ledger; runtime files under `.sf/runtime/` are projections for query, UI, and compatibility. A deterministic controller (not an LLM) reads canonical state and decides what to dispatch next. Core changes follow purpose-driven TDD: purpose and consumer first, then failing tests, then implementation. The user is the end-gate — autonomous mode delivers work to human review, it does not merge to production unattended.
## Codemap ## Codemap

View file

@ -4,6 +4,29 @@ A practical cut of the 56 NEW items in `SPEC.md` into tiers. Not every spec item
This document is the answer to: **what should we actually ship for v3?** This document is the answer to: **what should we actually ship for v3?**
## Strategic frame — 2026-05
We are already on a strong base: Forge is the product, UOK is the kernel, and core work is gated by purpose-driven TDD plus the eight PDD fields. The goal of this build plan is not to turn SF into a generic CLI coder. The goal is to sharpen Forge's autonomous single-repo execution while borrowing the best ideas from adjacent systems.
This file is a **planning document**, not a verified implementation ledger. An item can be mapped here and still be open, partial, or only folded into milestone planning. Close-out still requires code evidence, tests, and milestone artifacts that prove the behavior exists in the repo.
Use external comparisons to sharpen, not to steer identity:
- **Claude Code / Codex** — interaction and execution ergonomics
- **Aider / gsd-2** — direct execution and repo work loop
- **Plandex** — workflow decomposition and staged progress
- **ACE Coder** — future multi-repo and large-scale convergence patterns, not the near-term product path for Forge
The end state is not "SF plus a pile of borrowed references." The end state is that proven workflow, execution, and reliability patterns are absorbed into Forge and UOK as first-party behavior.
## High-level milestone sequence
1. **Stabilize the core.** Keep UOK, purpose-driven TDD, the eight PDD fields, and repo-local state/evidence as the non-negotiable base.
2. **Sharpen single-repo execution.** Port the highest-value correctness and workflow ideas from pi-mono, gsd-2, and adjacent CLI systems where they improve Forge without changing its product identity.
3. **Deepen autonomous reliability.** Improve evidence capture, recovery, verification, and self-improvement loops inside the single-repo boundary.
4. **Polish product surfaces.** Make the autonomous workflow legible in TUI, CLI, and docs without introducing separate planning semantics.
5. **Absorb and converge deliberately.** Fold proven external patterns into Forge/UOK as native behavior, and keep interfaces/concepts compatible with ACE Coder where useful, while letting Forge and ACE grow from their different starting points.
--- ---
## Tier 0 — Pi-mono ports (sf: do these FIRST) ## Tier 0 — Pi-mono ports (sf: do these FIRST)

View file

@ -2,6 +2,27 @@
Every BUILD_PLAN.md tier item mapped to a milestone. **Rule D015**: every new milestone must cite which BUILD_PLAN tier/item it implements. Every BUILD_PLAN.md tier item mapped to a milestone. **Rule D015**: every new milestone must cite which BUILD_PLAN tier/item it implements.
This file answers **where work belongs**, not **whether code is done**. "Mapped" means a BUILD_PLAN item has a milestone/slice home. It does **not** mean the implementation is verified in the current repo.
## Mapping vs. code truth
- **Mapped** — the item has a milestone/slice destination.
- **Verified in code** — the behavior exists in the repo and has evidence/tests/artifacts.
- **Open** — still planned or partially folded in, but not yet verified as complete.
- **Deferred** — intentionally out of the active plan.
---
## High-level milestone direction
These are the strategy bands above the itemized mapping:
1. **Core foundation** — UOK, purpose-driven TDD, eight-field PDD gate, repo-local state
2. **Single-repo sharpening** — adopt the best execution/workflow ideas from pi-mono, gsd-2, Claude Code, Codex, Aider, and Plandex where they strengthen Forge
3. **Autonomous reliability** — evidence, recovery, verification, and self-improvement loops
4. **Surface coherence** — CLI, TUI, docs, and workflow language all reflect the same UOK-driven model
5. **ACE convergence prep** — keep concepts compatible with ACE Coder without turning Forge into the multi-repo system
--- ---
## Tier 0 — Pi-mono ports → **M006** ## Tier 0 — Pi-mono ports → **M006**
@ -44,4 +65,6 @@ All mapped. See BUILD_PLAN.md for item-level status.
| Tier 2 | 7 (M012, M009, M013, M016) | 0 | | Tier 2 | 7 (M012, M009, M013, M016) | 0 |
| Tier 3+ | 0 | deferred | | Tier 3+ | 0 | deferred |
**Zero gaps.** Every BUILD_PLAN tier item is either mapped to a milestone or explicitly deferred. **Zero mapping gaps.** Every BUILD_PLAN tier item is either mapped to a milestone or explicitly deferred.
That does **not** mean zero implementation gaps. Open `TODO`, `NEW`, and `⬜` markers in `BUILD_PLAN.md`, this map, and milestone artifacts still represent real work until they are reconciled against code evidence.

View file

@ -2,7 +2,7 @@
# SF # SF
**The evolution of [Singularity Forge](https://github.com/sf-build/get-shit-done) — now a real coding agent.** **The evolution of [Singularity Forge](https://github.com/sf-build/get-shit-done) — now a standalone autonomous repo operator.**
[![npm version](https://img.shields.io/npm/v/singularity-forge?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/singularity-forge) [![npm version](https://img.shields.io/npm/v/singularity-forge?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/singularity-forge)
[![npm downloads](https://img.shields.io/npm/dm/singularity-forge?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/singularity-forge) [![npm downloads](https://img.shields.io/npm/dm/singularity-forge?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/singularity-forge)
@ -15,6 +15,10 @@ The original SF went viral as a prompt framework for Claude Code. It worked, but
This version is different. SF is now a standalone CLI built on the [Pi SDK](https://github.com/badlogic/pi-mono), which gives it direct TypeScript access to the agent harness itself. That means SF can actually _do_ what v1 could only _ask_ the LLM to do: clear context between tasks, inject exactly the right files at dispatch time, manage git branches, track cost and tokens, detect stuck loops, recover from crashes, and auto-advance through an entire milestone without human intervention. This version is different. SF is now a standalone CLI built on the [Pi SDK](https://github.com/badlogic/pi-mono), which gives it direct TypeScript access to the agent harness itself. That means SF can actually _do_ what v1 could only _ask_ the LLM to do: clear context between tasks, inject exactly the right files at dispatch time, manage git branches, track cost and tokens, detect stuck loops, recover from crashes, and auto-advance through an entire milestone without human intervention.
Forge is the product. The Unified Operation Kernel (UOK) is the internal runtime kernel. Core behavior is governed by purpose-driven TDD and the eight PDD fields: purpose, consumer, contract, failure boundary, evidence, non-goals, invariants, and assumptions.
We sharpen Forge against the best external ideas we can find — Claude Code and Codex for ergonomics, Aider and gsd-2 for execution, Plandex for workflow structure — but those are reference inputs, not the destination. Forge stays focused on autonomous single-repo execution. ACE Coder is the separate multi-repo and large-scale path.
One command. Walk away. Come back to a built project with clean git history. One command. Walk away. Come back to a built project with clean git history.
<pre><code>npm install -g singularity-forge@latest</code></pre> <pre><code>npm install -g singularity-forge@latest</code></pre>
@ -153,7 +157,7 @@ The original SF was a collection of markdown prompts installed into `~/.claude/c
- **No crash recovery.** If the session died mid-task, you started over. - **No crash recovery.** If the session died mid-task, you started over.
- **No observability.** No cost tracking, no progress dashboard, no stuck detection. - **No observability.** No cost tracking, no progress dashboard, no stuck detection.
SF v2 solves all of these because it's not a prompt framework anymore — it's a TypeScript application that _controls_ the agent session. SF v2 solves all of these because it's not a prompt framework anymore — it's a TypeScript application that _controls_ the agent session. Forge is the product; UOK is the internal kernel that drives the run loop.
| | v1 (Prompt Framework) | v2 (Agent Application) | | | v1 (Prompt Framework) | v2 (Agent Application) |
| -------------------- | ---------------------------- | ------------------------------------------------------- | | -------------------- | ---------------------------- | ------------------------------------------------------- |

View file

@ -1,6 +1,6 @@
# Vision # Vision
SF is the orchestration layer between you and AI coding agents. It handles planning, execution, verification, and shipping so you can focus on what to build, not how to wrangle the tools. SF is an autonomous single-repo software operator. Forge is the product; UOK is the internal execution kernel. It handles planning, execution, verification, and shipping so you can focus on what to build, not how to wrangle the tools.
## Who it's for ## Who it's for
@ -14,10 +14,21 @@ Anyone who codes with AI agents — solo developers shipping faster, open-source
**Tests are the contract.** If you change behavior, the tests tell you what you broke. Write tests for new behavior. Trust the test suite. **Tests are the contract.** If you change behavior, the tests tell you what you broke. Write tests for new behavior. Trust the test suite.
**Purpose-driven TDD.** The eight PDD fields — purpose, consumer, contract, failure boundary, evidence, non-goals, invariants, and assumptions — are the core gate. Non-trivial work should not move to implementation before purpose is explicit and a falsifier exists.
**Ship fast, fix fast.** Get it out, iterate quickly, don't let perfect be the enemy of good. Every release should work, but we'd rather ship and patch than delay and accumulate. **Ship fast, fix fast.** Get it out, iterate quickly, don't let perfect be the enemy of good. Every release should work, but we'd rather ship and patch than delay and accumulate.
**Provider-agnostic.** SF works with any LLM provider. No architectural decisions should privilege one provider over another. **Provider-agnostic.** SF works with any LLM provider. No architectural decisions should privilege one provider over another.
**Sharpen by comparison, not imitation.** Learn from Claude Code, Codex, Aider, gsd-2, and Plandex where they are strong, but do not collapse Forge into a generic coder CLI. Forge's differentiator is autonomous single-repo execution on top of UOK. When an external pattern proves itself, absorb it into SF/UOK as first-party behavior instead of leaving it as a permanent comparison layer.
## Direction
- **Forge** grows as the single-repo product.
- **UOK** leads the runtime model and execution semantics.
- **ACE Coder** grows the multi-repo and large-scale orchestration path.
- External CLIs are comparison inputs used to sharpen workflow and execution choices.
## What we won't accept ## What we won't accept
These save everyone time. Don't open PRs for: These save everyone time. Don't open PRs for:

View file

@ -0,0 +1,29 @@
# CLI Agent Code Survey — 2026-05-07
We compared Forge-relevant CLI agent implementations to pull workflow and autonomy patterns into SF planning.
## What was checked
- `claude-code`
- `codex`
- `gemini-cli`
- `opencode`
- `aider`
- `goose`
- `qwen-code`
- `crush`
- `plandex`
- agentless-style repos: `Agentless`, `open-codex`, `RA.Aid`, `letta-code`, `neovate-code`, `amazon-q-developer-cli`
- `ace-coder` for curator, memory, and autonomy patterns
## Where the code lives
All reference checkouts are local under `/home/mhugo/code/`.
## Takeaways
- `plandex` is the closest workflow match for SF planning.
- `claude-code`, `aider`, `codex`, and `gemini-cli` are the best ergonomics references.
- `ace-coder` is our own codebase and the long-term direction is convergence between Forge and ACE.
- The code survey is done; future planning can rely on the local checkouts instead of rescanning remote repos.

View file

@ -0,0 +1,28 @@
# Strategy Alignment — 2026-05-07
Aligned the top-level SF docs and roadmap framing around the current architecture and end goal.
## Canonical direction
- **Forge** is the product.
- **UOK** is the internal execution kernel and leads runtime semantics.
- **Purpose-driven TDD** and the **eight PDD fields** are the core gate.
- **ACE Coder** is the multi-repo and large-scale path.
## How external systems are used
External CLIs are comparison inputs used to sharpen SF, not the destination:
- **Claude Code / Codex** for interaction and execution ergonomics
- **Aider / gsd-2** for direct execution patterns
- **Plandex** for workflow structure
When a pattern proves itself in practice, it should be absorbed into Forge/UOK as first-party behavior rather than preserved as a permanent external dependency in the product story.
## High-level milestone framing
1. Stabilize the UOK + PDD/TDD core.
2. Sharpen single-repo execution from external references where the fit is real.
3. Deepen autonomous reliability, evidence, recovery, and self-improvement.
4. Keep product surfaces coherent across CLI, TUI, and docs.
5. Absorb proven patterns into Forge/UOK and prepare concept-level convergence with ACE without collapsing the Forge/ACE split.

View file

@ -7,3 +7,5 @@ Repo-memory audits, decision ledgers, context-gardening notes, and records-keepe
| Date | Note | Summary | | Date | Note | Summary |
|------|------|---------| |------|------|---------|
| 2026-05-01 | [repo-vcs and notifications](./2026-05-01-repo-vcs-and-notifications.md) | repo-vcs skill landed; notification specs drafted; JSDoc annotations added; placeholder docs filled | | 2026-05-01 | [repo-vcs and notifications](./2026-05-01-repo-vcs-and-notifications.md) | repo-vcs skill landed; notification specs drafted; JSDoc annotations added; placeholder docs filled |
| 2026-05-07 | [cli agent code survey](./2026-05-07-cli-agent-code-survey.md) | compared local CLI agent checkouts; Plandex is the workflow analog; ACE is owned code and future convergence target |
| 2026-05-07 | [strategy alignment](./2026-05-07-strategy-alignment.md) | aligned top-level docs and roadmap framing around Forge as product, UOK as kernel, and external CLIs as sharpening inputs |

View file

@ -2,7 +2,7 @@
"id": "sf-tui", "id": "sf-tui",
"name": "SF TUI", "name": "SF TUI",
"version": "1.0.0", "version": "1.0.0",
"description": "Adds SF-specific header, footer, prompt stash, color, emoji, and marketplace UI controls", "description": "Adds SF-specific header, footer, prompt history, color, emoji, and marketplace UI controls",
"tier": "bundled", "tier": "bundled",
"requires": { "platform": ">=2.29.0" }, "requires": { "platform": ">=2.29.0" },
"provides": { "provides": {

View file

@ -4,17 +4,24 @@
* Features: * Features:
* - Powerline footer: git branch, diff stats, last commit, model, cost, context * - Powerline footer: git branch, diff stats, last commit, model, cost, context
* - Header: project name + branch + model * - Header: project name + branch + model
* - Prompt history stash: Ctrl+Alt+H overlay * - Prompt history: Ctrl+Alt+H overlay
*/ */
import { randomUUID } from "node:crypto";
import { Key } from "@singularity-forge/pi-tui"; import { Key } from "@singularity-forge/pi-tui";
import { isAutoActive } from "../sf/auto.js"; import { isAutoActive } from "../sf/auto.js";
import { projectRoot } from "../sf/commands/context.js";
import { registerSessionColor } from "./color-band.js"; import { registerSessionColor } from "./color-band.js";
import { registerSessionEmoji } from "./emoji.js"; import { registerSessionEmoji } from "./emoji.js";
import { renderFooter } from "./footer.js"; import { renderFooter } from "./footer.js";
import { invalidateGitStatus } from "./git.js"; import { invalidateGitStatus } from "./git.js";
import { renderHeader } from "./header.js"; import { renderHeader } from "./header.js";
import { openMarketplaceOverlay } from "./marketplace.js"; import { openMarketplaceOverlay } from "./marketplace.js";
import { openStashOverlay, pushStash, readStash, writeStash } from "./stash.js"; import {
appendPromptHistory,
openPromptHistoryOverlay,
pushPromptHistory,
readPromptHistory,
} from "./prompt-history.js";
function installHeader(ctx) { function installHeader(ctx) {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
@ -45,19 +52,30 @@ function installFooter(ctx) {
export default function sfTui(pi) { export default function sfTui(pi) {
registerSessionEmoji(pi); registerSessionEmoji(pi);
registerSessionColor(pi); registerSessionColor(pi);
const stash = readStash(); const promptHistory = readPromptHistory();
const promptHistorySessionId = randomUUID();
let projectBasePath = null;
let wasAutoActive = false; let wasAutoActive = false;
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
try {
projectBasePath = projectRoot();
const projectPromptHistory = readPromptHistory(projectBasePath);
promptHistory.splice(0, promptHistory.length, ...projectPromptHistory);
} catch {
projectBasePath = null;
}
installHeader(ctx); installHeader(ctx);
installFooter(ctx); installFooter(ctx);
const openProjectPromptHistory = (overlayCtx) =>
openPromptHistoryOverlay(overlayCtx, projectBasePath ?? undefined);
pi.registerShortcut(Key.ctrlAlt("h"), { pi.registerShortcut(Key.ctrlAlt("h"), {
description: "Open prompt history stash", description: "Open prompt history",
handler: openStashOverlay, handler: openProjectPromptHistory,
}); });
pi.registerShortcut(Key.ctrlShift("h"), { pi.registerShortcut(Key.ctrlShift("h"), {
description: "Open prompt history stash (fallback)", description: "Open prompt history (fallback)",
handler: openStashOverlay, handler: openProjectPromptHistory,
}); });
pi.registerShortcut(Key.ctrlAlt("m"), { pi.registerShortcut(Key.ctrlAlt("m"), {
description: "Open marketplace browser", description: "Open marketplace browser",
@ -68,8 +86,18 @@ export default function sfTui(pi) {
pi.on("before_agent_start", async (event) => { pi.on("before_agent_start", async (event) => {
const prompt = event.prompt?.trim(); const prompt = event.prompt?.trim();
if (prompt) { if (prompt) {
pushStash(stash, prompt); pushPromptHistory(promptHistory, prompt);
writeStash(stash); appendPromptHistory(
prompt,
projectBasePath ?? undefined,
promptHistorySessionId,
);
pi.appendEntry("sf-prompt-history", {
prompt,
projectRoot: projectBasePath,
sessionId: promptHistorySessionId,
timestamp: Date.now(),
});
} }
}); });
pi.on("tool_result", async (_event, ctx) => { pi.on("tool_result", async (_event, ctx) => {

View file

@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { import {
@ -9,39 +9,87 @@ import {
} from "@singularity-forge/pi-tui"; } from "@singularity-forge/pi-tui";
const LIMIT = 20; const LIMIT = 20;
function stashPath() { const SCAN_LINE_LIMIT = 2000;
return join(homedir(), ".sf", "agent", "prompt-history.json"); function promptHistoryPath() {
return join(homedir(), ".sf", "agent", "prompt-history.jsonl");
} }
export function readStash() { function readEntries() {
try { try {
const path = stashPath(); const path = promptHistoryPath();
if (!existsSync(path)) return []; if (!existsSync(path)) return [];
const d = JSON.parse(readFileSync(path, "utf-8")); return readFileSync(path, "utf-8")
return d.history.filter( .split(/\r?\n/)
(h) => typeof h === "string" && h.trim().length > 0, .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];
});
} catch { } catch {
return []; return [];
} }
} }
export function writeStash(history) { function normalizeHistory(history) {
const seen = new Set();
const merged = [];
for (const item of history) {
const text = String(item ?? "").trim();
if (!text || seen.has(text)) continue;
seen.add(text);
merged.push(text);
if (merged.length >= LIMIT) break;
}
return merged;
}
function appendEntries(entries) {
try { try {
const path = stashPath(); const path = promptHistoryPath();
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
writeFileSync( appendFileSync(
path, path,
JSON.stringify( entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n",
{ version: 1, history: history.slice(0, LIMIT) }, { encoding: "utf-8", mode: 0o600 },
null,
2,
) + "\n",
"utf-8",
); );
} catch { } catch {
/* non-fatal */ /* non-fatal */
} }
} }
export function pushStash(history, text) { export function readPromptHistory(basePath) {
if (!basePath) return [];
return normalizeHistory(
readEntries()
.filter((entry) => entry.projectRoot === basePath)
.map((entry) => entry.prompt),
);
}
export function appendPromptHistory(prompt, basePath, sessionId) {
if (!basePath) return;
const normalized = normalizeHistory([prompt]);
if (!normalized.length) return;
const now = Date.now();
const entries = normalized.toReversed().map((prompt, index) => ({
version: 1,
prompt,
projectRoot: basePath,
sessionId: sessionId ?? null,
timestamp: now - (normalized.length - index - 1),
}));
appendEntries(entries);
}
export function pushPromptHistory(history, text) {
const t = text.trim(); const t = text.trim();
if (!t || history[0] === t) return; if (!t || history[0] === t) return;
history.unshift(t); history.unshift(t);
@ -53,7 +101,7 @@ function preview(text, maxWidth) {
const c = text.replace(/\s+/g, " ").trim(); const c = text.replace(/\s+/g, " ").trim();
return c ? truncateToWidth(c, maxWidth, "…") : "(empty)"; return c ? truncateToWidth(c, maxWidth, "…") : "(empty)";
} }
class StashOverlay { class PromptHistoryOverlay {
tui; tui;
theme; theme;
done; done;
@ -138,7 +186,7 @@ class StashOverlay {
} }
lines.push(pad(box(""))); lines.push(pad(box("")));
lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤")));
lines.push(pad(box(th.fg("dim", `${this.items.length} stashed prompts`)))); lines.push(pad(box(th.fg("dim", `${this.items.length} prompts`))));
lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯")));
lines.push(""); lines.push("");
this.cacheL = lines; this.cacheL = lines;
@ -146,22 +194,22 @@ class StashOverlay {
return lines; return lines;
} }
} }
export async function openStashOverlay(ctx) { export async function openPromptHistoryOverlay(ctx, basePath) {
if (!ctx.hasUI) { if (!ctx.hasUI) {
ctx.ui.notify("Prompt history requires interactive mode", "error"); ctx.ui.notify("Prompt history requires interactive mode", "error");
return; return;
} }
const items = readStash(); const items = readPromptHistory(basePath ?? undefined);
if (!items.length) { if (!items.length) {
ctx.ui.notify( ctx.ui.notify(
"No stashed prompts yet. Send a message to build history.", "No prompt history yet. Send a message to build history.",
"info", "info",
); );
return; return;
} }
const selected = await ctx.ui.custom( const selected = await ctx.ui.custom(
(tui, theme, _kb, done) => { (tui, theme, _kb, done) => {
const o = new StashOverlay(tui, theme, items, done); const o = new PromptHistoryOverlay(tui, theme, items, done);
return { return {
render: (w) => o.render(w), render: (w) => o.render(w),
invalidate: () => o.invalidate(), invalidate: () => o.invalidate(),

View file

@ -33,8 +33,6 @@ import {
buildRunUatPrompt, buildRunUatPrompt,
buildValidateMilestonePrompt, buildValidateMilestonePrompt,
buildWorkflowPreferencesPrompt, buildWorkflowPreferencesPrompt,
checkNeedsReassessment,
checkNeedsRunUat,
} from "./auto-prompts.js"; } from "./auto-prompts.js";
import { hasImplementationArtifacts } from "./auto-recovery.js"; import { hasImplementationArtifacts } from "./auto-recovery.js";
import { getCanonicalMilestonePlan } from "./canonical-milestone-plan.js"; import { getCanonicalMilestonePlan } from "./canonical-milestone-plan.js";
@ -51,6 +49,10 @@ import {
parseDeferredRequirements, parseDeferredRequirements,
resolveAllOverrides, resolveAllOverrides,
} from "./files.js"; } from "./files.js";
import {
checkNeedsReassessment,
checkNeedsRunUat,
} from "./workflow-helpers.js";
import { import {
getRelevantMemoriesRanked, getRelevantMemoriesRanked,
isDbAvailable as isMemoryDbAvailable, isDbAvailable as isMemoryDbAvailable,

View file

@ -0,0 +1,143 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
appendPromptHistory,
readPromptHistory,
} from "../../sf-tui/prompt-history.js";
describe("prompt history", () => {
let oldHome;
let homeDir;
let projectDir;
beforeEach(() => {
oldHome = process.env.HOME;
homeDir = mkdtempSync(join(tmpdir(), "sf-home-"));
projectDir = mkdtempSync(join(tmpdir(), "sf-project-"));
process.env.HOME = homeDir;
});
afterEach(() => {
process.env.HOME = oldHome;
rmSync(homeDir, { recursive: true, force: true });
rmSync(projectDir, { recursive: true, force: true });
});
it("appendPromptHistory_when_projectPathProvided_persists_tagged_global_entry", () => {
appendPromptHistory("first prompt", projectDir, "session-1");
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
expect(existsSync(globalPath)).toBe(true);
expect(
readFileSync(globalPath, "utf-8")
.trim()
.split(/\r?\n/)
.map((line) => JSON.parse(line)),
).toMatchObject([
{
version: 1,
prompt: "first prompt",
projectRoot: projectDir,
sessionId: "session-1",
},
]);
});
it("readPromptHistory_when_history_contains_multiple_projects_returns_current_project_only", () => {
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
mkdirSync(join(homeDir, ".sf", "agent"), { recursive: true });
writeFileSync(
globalPath,
[
JSON.stringify({
version: 1,
prompt: "shared",
projectRoot: projectDir,
sessionId: "session-1",
timestamp: 1,
}),
JSON.stringify({
version: 1,
prompt: "project",
projectRoot: projectDir,
sessionId: "session-1",
timestamp: 2,
}),
JSON.stringify({
version: 1,
prompt: "other project",
projectRoot: join(projectDir, "other"),
sessionId: "session-2",
timestamp: 3,
}),
].join("\n") + "\n",
"utf-8",
);
expect(readPromptHistory(projectDir)).toEqual(["project", "shared"]);
});
it("readPromptHistory_when_project_history_has_duplicates_returns_newest_unique_prompts", () => {
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
mkdirSync(join(homeDir, ".sf", "agent"), { recursive: true });
writeFileSync(
globalPath,
[
JSON.stringify({
version: 1,
prompt: "repeat",
projectRoot: projectDir,
sessionId: "session-1",
timestamp: 1,
}),
JSON.stringify({
version: 1,
prompt: "newest",
projectRoot: projectDir,
sessionId: "session-1",
timestamp: 2,
}),
JSON.stringify({
version: 1,
prompt: "repeat",
projectRoot: projectDir,
sessionId: "session-2",
timestamp: 3,
}),
].join("\n") + "\n",
"utf-8",
);
expect(readPromptHistory(projectDir)).toEqual(["repeat", "newest"]);
});
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 });
writeFileSync(
globalPath,
JSON.stringify({ version: 1, history: ["global leak"] }, null, 2) + "\n",
"utf-8",
);
expect(readPromptHistory(projectDir)).toEqual([]);
});
it("appendPromptHistory_when_projectPathMissing_does_not_persist_history", () => {
appendPromptHistory("global leak");
const globalPath = join(homeDir, ".sf", "agent", "prompt-history.jsonl");
expect(existsSync(globalPath)).toBe(false);
expect(readPromptHistory()).toEqual([]);
});
});