feat(omo-claude): port ultragoal to per-session file-based goals
Goal content under ./.omo/ultragoal/sessions/claude-<id>/ keyed by Claude Code session_id (claude: prefix); UltragoalScope struct threaded; plan version 2 + index.json registry with read-only v1 forward-migration (never deletes v1); create_goal/get_goal/update_goal dependency dropped (file/steering-based); the PreToolUse create_goal budget guard kept compiled+unit-tested but its hooks.json registration removed (inert in CC). 26 tests pass. ultragoal removed from sync-components HANDLED set — it is a hand-fork, not patch-synced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
applyPreToolUseGoalBudgetGuard,
|
||||
type PreToolUsePayload,
|
||||
parsePreToolUsePayload,
|
||||
runUltragoalHookCli,
|
||||
type UserPromptSubmitPayload,
|
||||
} from "../src/claude-hook.js";
|
||||
import { makeUltragoalScope } from "../src/session-scope.js";
|
||||
import { createUltragoalPlan } from "../src/plan-crud.js";
|
||||
|
||||
function captureStdout(): { readonly stdout: Writable; readonly read: () => string } {
|
||||
let captured = "";
|
||||
const stdout = new Writable({
|
||||
write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
captured += chunk instanceof Buffer ? chunk.toString() : String(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
return { stdout, read: () => captured };
|
||||
}
|
||||
|
||||
function upsPayload(prompt: string, cwd: string, sessionId: string): UserPromptSubmitPayload {
|
||||
return { cwd, hook_event_name: "UserPromptSubmit", prompt, session_id: sessionId };
|
||||
}
|
||||
|
||||
const STEER =
|
||||
'OMO_ULTRAGOAL_STEER: {"kind":"annotate_ledger","source":"user_prompt_submit","evidence":"x","rationale":"y"}';
|
||||
|
||||
describe("UserPromptSubmit hook derives scope from session_id", () => {
|
||||
it("two UserPromptSubmit payloads with distinct session_ids create two sessions/claude-* dirs", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-hookscope-"));
|
||||
// Seed plans for two distinct sessions so steering has something to mutate.
|
||||
await createUltragoalPlan(makeUltragoalScope(repoRoot, "hook-a"), {
|
||||
brief: "- objective alpha for hook test\n",
|
||||
});
|
||||
await createUltragoalPlan(makeUltragoalScope(repoRoot, "hook-b"), {
|
||||
brief: "- objective beta for hook test\n",
|
||||
});
|
||||
|
||||
for (const sid of ["hook-a", "hook-b"]) {
|
||||
const stdin = Readable.from([JSON.stringify(upsPayload(STEER, repoRoot, sid))]);
|
||||
const cap = captureStdout();
|
||||
await runUltragoalHookCli(stdin, cap.stdout);
|
||||
}
|
||||
|
||||
const sessionsRoot = join(repoRoot, ".omo", "ultragoal", "sessions");
|
||||
expect(existsSync(sessionsRoot)).toBe(true);
|
||||
const dirs = (await readdir(sessionsRoot)).filter((d) => d.startsWith("claude-"));
|
||||
expect(dirs.length).toBe(2);
|
||||
});
|
||||
|
||||
it("steering is a no-op (returns empty) when no plan exists for the session", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-hooknoplan-"));
|
||||
const stdin = Readable.from([JSON.stringify(upsPayload(STEER, repoRoot, "no-plan-session"))]);
|
||||
const cap = captureStdout();
|
||||
await runUltragoalHookCli(stdin, cap.stdout);
|
||||
expect(cap.read()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
function preToolPayload(toolName: string, toolInput: unknown): PreToolUsePayload {
|
||||
return {
|
||||
cwd: "/repo",
|
||||
hook_event_name: "PreToolUse",
|
||||
session_id: "s1",
|
||||
tool_input: toolInput,
|
||||
tool_name: toolName,
|
||||
tool_use_id: "call-1",
|
||||
transcript_path: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inert create_goal guard (D4: code kept, registration removed)", () => {
|
||||
it("parses a PreToolUse payload WITHOUT model/turn_id (relaxed validator)", () => {
|
||||
const raw = JSON.stringify(preToolPayload("create_goal", { objective: "Ship", token_budget: 5 }));
|
||||
const parsed = parsePreToolUsePayload(raw);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.tool_name).toBe("create_goal");
|
||||
});
|
||||
|
||||
it("guard still blocks a budgeted create_goal when invoked directly (code remains unit-testable)", () => {
|
||||
const out = applyPreToolUseGoalBudgetGuard(
|
||||
preToolPayload("create_goal", { objective: "Ship", token_budget: 5 }),
|
||||
);
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.hookSpecificOutput.permissionDecision).toBe("deny");
|
||||
});
|
||||
|
||||
it("guard returns empty for create_goal without a budget", () => {
|
||||
expect(applyPreToolUseGoalBudgetGuard(preToolPayload("create_goal", { objective: "Ship" }))).toBe("");
|
||||
});
|
||||
|
||||
it("hooks.json does NOT register a PreToolUse create_goal block", async () => {
|
||||
const hooks = JSON.parse(await readFile(join(import.meta.dir, "..", "hooks", "hooks.json"), "utf8"));
|
||||
expect(hooks.hooks.PreToolUse).toBeUndefined();
|
||||
// Only UserPromptSubmit is registered.
|
||||
expect(Object.keys(hooks.hooks)).toEqual(["UserPromptSubmit"]);
|
||||
const raw = await readFile(join(import.meta.dir, "..", "hooks", "hooks.json"), "utf8");
|
||||
expect(raw).not.toContain("create_goal");
|
||||
expect(raw).not.toContain("pre-tool-use");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import { resolveUltragoalScope } from "../src/cli-commands.js";
|
||||
import { makeUltragoalScope } from "../src/session-scope.js";
|
||||
import { createUltragoalPlan } from "../src/plan-crud.js";
|
||||
import { UltragoalError } from "../src/types.js";
|
||||
|
||||
const BRIEF = "- a goal objective for scope resolution\n";
|
||||
|
||||
let savedEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = process.env["CLAUDE_SESSION_ID"];
|
||||
delete process.env["CLAUDE_SESSION_ID"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) delete process.env["CLAUDE_SESSION_ID"];
|
||||
else process.env["CLAUDE_SESSION_ID"] = savedEnv;
|
||||
});
|
||||
|
||||
describe("resolveUltragoalScope precedence", () => {
|
||||
it("1. --session-id flag wins", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-flag-"));
|
||||
process.env["CLAUDE_SESSION_ID"] = "env-session";
|
||||
const scope = await resolveUltragoalScope(repoRoot, ["--session-id", "flag-session"]);
|
||||
expect(scope.sessionId).toBe("claude:flag-session");
|
||||
});
|
||||
|
||||
it("2. $CLAUDE_SESSION_ID is used when no flag", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-env-"));
|
||||
process.env["CLAUDE_SESSION_ID"] = "env-session";
|
||||
const scope = await resolveUltragoalScope(repoRoot, []);
|
||||
expect(scope.sessionId).toBe("claude:env-session");
|
||||
});
|
||||
|
||||
it("3. newest-active session in index.json when no flag/env", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-idx-"));
|
||||
await createUltragoalPlan(makeUltragoalScope(repoRoot, "first"), { brief: BRIEF });
|
||||
await createUltragoalPlan(makeUltragoalScope(repoRoot, "second"), { brief: BRIEF });
|
||||
const scope = await resolveUltragoalScope(repoRoot, []);
|
||||
// "second" was created last -> newest active.
|
||||
expect(scope.sessionId).toBe("claude:second");
|
||||
});
|
||||
|
||||
it("4. errors with ULTRAGOAL_SESSION_REQUIRED when nothing resolves", async () => {
|
||||
const repoRoot = await mkdtemp(join(tmpdir(), "ug-cli-none-"));
|
||||
let caught: unknown;
|
||||
try {
|
||||
await resolveUltragoalScope(repoRoot, []);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UltragoalError);
|
||||
expect((caught as UltragoalError).code).toBe("ULTRAGOAL_SESSION_REQUIRED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { makeUltragoalScope } from "../src/session-scope.js";
|
||||
import { createUltragoalPlan, startNextUltragoal } from "../src/plan-crud.js";
|
||||
import { checkpointUltragoal } from "../src/checkpoint.js";
|
||||
import { recordEvidence } from "../src/evidence.js";
|
||||
import { steerUltragoal } from "../src/steering.js";
|
||||
import { buildGoalInstruction } from "../src/goal-instruction.js";
|
||||
import { readUltragoalPlan } from "../src/plan-io.js";
|
||||
import { ultragoalLedgerPath } from "../src/paths.js";
|
||||
|
||||
const BRIEF = "- Implement the alpha feature\n- Implement the beta feature\n";
|
||||
|
||||
async function tmpRepo(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "ug-life-"));
|
||||
}
|
||||
|
||||
describe("scope-threaded lifecycle still works end-to-end", () => {
|
||||
it("create -> complete-goals handoff -> record-evidence -> checkpoint (per_story)", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scope = makeUltragoalScope(repoRoot, "life-1");
|
||||
const plan = await createUltragoalPlan(scope, { brief: BRIEF, goalMode: "per_story" });
|
||||
expect(plan.goals.length).toBe(2);
|
||||
|
||||
const started = await startNextUltragoal(scope, {});
|
||||
expect("goal" in started).toBe(true);
|
||||
if (!("goal" in started)) throw new Error("expected a goal");
|
||||
const goal = started.goal;
|
||||
|
||||
// handoff text is file/steering based, no create_goal/get_goal language.
|
||||
const instruction = buildGoalInstruction({ plan: started.plan, goal });
|
||||
expect(instruction.text).not.toContain("create_goal");
|
||||
expect(instruction.text).not.toContain("get_goal");
|
||||
expect(instruction.text).toContain(started.plan.goalsPath);
|
||||
|
||||
// pass all seeded criteria
|
||||
for (const c of goal.successCriteria) {
|
||||
await recordEvidence(scope, { goalId: goal.id, criterionId: c.id, status: "pass", evidence: "verified ok" });
|
||||
}
|
||||
// checkpoint complete WITHOUT a goal snapshot (snapshot is optional now)
|
||||
const result = await checkpointUltragoal(scope, {
|
||||
goalId: goal.id,
|
||||
status: "complete",
|
||||
evidence: "alpha done; tests pass; review clean",
|
||||
});
|
||||
expect(result.goal.status).toBe("complete");
|
||||
|
||||
const reread = await readUltragoalPlan(scope);
|
||||
expect(reread.goals.find((g) => g.id === goal.id)?.status).toBe("complete");
|
||||
});
|
||||
|
||||
it("steering adds a subgoal and is written under the session scope", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scope = makeUltragoalScope(repoRoot, "life-2");
|
||||
await createUltragoalPlan(scope, { brief: BRIEF });
|
||||
const result = await steerUltragoal(scope, {
|
||||
kind: "add_subgoal",
|
||||
source: "cli",
|
||||
title: "Gamma feature",
|
||||
objective: "Implement the gamma feature with care",
|
||||
evidence: "user asked for gamma",
|
||||
rationale: "newly discovered requirement",
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
const reread = await readUltragoalPlan(scope);
|
||||
expect(reread.goals.some((g) => g.title === "Gamma feature")).toBe(true);
|
||||
// ledger lives in the session scope
|
||||
const ledger = await readFile(ultragoalLedgerPath(scope), "utf8");
|
||||
expect(ledger).toContain("steering_accepted");
|
||||
});
|
||||
|
||||
it("steering is a no-op when the plan is missing for the scope", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scope = makeUltragoalScope(repoRoot, "life-noplan");
|
||||
await expect(
|
||||
steerUltragoal(scope, {
|
||||
kind: "annotate_ledger",
|
||||
source: "cli",
|
||||
evidence: "x",
|
||||
rationale: "y",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { makeUltragoalScope } from "../src/session-scope.js";
|
||||
import { createUltragoalPlan } from "../src/plan-crud.js";
|
||||
import { readUltragoalPlan, writePlan, readUltragoalIndex } from "../src/plan-io.js";
|
||||
import { ultragoalGoalsPath, ultragoalSessionDir, ultragoalIndexPath, legacyUltragoalGoalsPath } from "../src/paths.js";
|
||||
|
||||
const BRIEF = "- First goal objective for testing\n- Second goal objective for testing\n";
|
||||
|
||||
async function tmpRepo(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "ug-session-"));
|
||||
}
|
||||
|
||||
describe("per-session isolation", () => {
|
||||
it("two distinct session ids produce two isolated plans under sessions/claude-*/", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scopeA = makeUltragoalScope(repoRoot, "session-aaa");
|
||||
const scopeB = makeUltragoalScope(repoRoot, "session-bbb");
|
||||
|
||||
await createUltragoalPlan(scopeA, { brief: BRIEF });
|
||||
await createUltragoalPlan(scopeB, { brief: BRIEF });
|
||||
|
||||
expect(existsSync(ultragoalGoalsPath(scopeA))).toBe(true);
|
||||
expect(existsSync(ultragoalGoalsPath(scopeB))).toBe(true);
|
||||
expect(ultragoalSessionDir(scopeA)).toContain("claude-session-aaa");
|
||||
expect(ultragoalSessionDir(scopeB)).toContain("claude-session-bbb");
|
||||
expect(ultragoalGoalsPath(scopeA)).not.toBe(ultragoalGoalsPath(scopeB));
|
||||
|
||||
// goal content lives under ./.omo/ultragoal/sessions/
|
||||
expect(ultragoalSessionDir(scopeA)).toContain(join(".omo", "ultragoal", "sessions"));
|
||||
});
|
||||
|
||||
it("registers both sessions in the index.json registry", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scopeA = makeUltragoalScope(repoRoot, "idx-aaa");
|
||||
const scopeB = makeUltragoalScope(repoRoot, "idx-bbb");
|
||||
await createUltragoalPlan(scopeA, { brief: BRIEF });
|
||||
await createUltragoalPlan(scopeB, { brief: BRIEF });
|
||||
|
||||
expect(existsSync(ultragoalIndexPath(repoRoot))).toBe(true);
|
||||
const index = await readUltragoalIndex(repoRoot);
|
||||
const ids = index.sessions.map((s) => s.sessionId);
|
||||
expect(ids).toContain("claude:idx-aaa");
|
||||
expect(ids).toContain("claude:idx-bbb");
|
||||
});
|
||||
|
||||
it("writes version:2 plans with platform/sessionId/sessionScope", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scope = makeUltragoalScope(repoRoot, "v2-check");
|
||||
const plan = await createUltragoalPlan(scope, { brief: BRIEF });
|
||||
expect(plan.version).toBe(2);
|
||||
expect(plan.platform).toBe("claude");
|
||||
expect(plan.sessionId).toBe("claude:v2-check");
|
||||
const reread = await readUltragoalPlan(scope);
|
||||
expect(reread.version).toBe(2);
|
||||
expect(reread.goals.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1 -> v2 migration", () => {
|
||||
it("reads a legacy v1 plan, migrates it forward, and does NOT delete the v1 file", async () => {
|
||||
const repoRoot = await tmpRepo();
|
||||
const scope = makeUltragoalScope(repoRoot, "legacy-1");
|
||||
|
||||
// Author a legacy v1 plan at the OLD repo-level path.
|
||||
const legacyPath = legacyUltragoalGoalsPath(repoRoot);
|
||||
await mkdir(join(repoRoot, ".omo", "ultragoal"), { recursive: true });
|
||||
const v1Plan = {
|
||||
version: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
briefPath: ".omo/ultragoal/brief.md",
|
||||
goalsPath: ".omo/ultragoal/goals.json",
|
||||
ledgerPath: ".omo/ultragoal/ledger.jsonl",
|
||||
codexGoalMode: "aggregate",
|
||||
goals: [
|
||||
{
|
||||
id: "G001",
|
||||
title: "Legacy goal",
|
||||
objective: "Do the legacy thing.",
|
||||
status: "pending",
|
||||
successCriteria: [],
|
||||
attempt: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeFile(legacyPath, `${JSON.stringify(v1Plan, null, 2)}\n`, "utf8");
|
||||
|
||||
const migrated = await readUltragoalPlan(scope);
|
||||
expect(migrated.version).toBe(2);
|
||||
expect(migrated.goals[0]?.id).toBe("G001");
|
||||
expect(migrated.goalMode).toBe("aggregate");
|
||||
|
||||
// The legacy v1 file must still exist (read-only migration).
|
||||
expect(existsSync(legacyPath)).toBe(true);
|
||||
const stillV1 = JSON.parse(await readFile(legacyPath, "utf8"));
|
||||
expect(stillV1.version).toBe(1);
|
||||
|
||||
// And the migrated copy lives in the session scope dir.
|
||||
expect(existsSync(ultragoalGoalsPath(scope))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
CLAUDE_SESSION_PREFIX,
|
||||
makeUltragoalScope,
|
||||
normalizeClaudeSessionId,
|
||||
PREFIX_RE,
|
||||
sessionScopeDir,
|
||||
} from "../src/session-scope.js";
|
||||
|
||||
describe("normalizeClaudeSessionId", () => {
|
||||
it("applies the claude: prefix to a bare session id", () => {
|
||||
expect(normalizeClaudeSessionId("abc-123")).toBe("claude:abc-123");
|
||||
});
|
||||
|
||||
it("leaves an already-claude-prefixed id untouched", () => {
|
||||
expect(normalizeClaudeSessionId("claude:abc-123")).toBe("claude:abc-123");
|
||||
});
|
||||
|
||||
it("leaves a sibling-platform prefix untouched", () => {
|
||||
expect(normalizeClaudeSessionId("codex:xyz")).toBe("codex:xyz");
|
||||
expect(normalizeClaudeSessionId("opencode:xyz")).toBe("opencode:xyz");
|
||||
});
|
||||
|
||||
it("throws on an empty session id", () => {
|
||||
expect(() => normalizeClaudeSessionId(" ")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionScopeDir", () => {
|
||||
it("converts the prefix colon to a dash", () => {
|
||||
expect(sessionScopeDir("abc-123")).toBe("claude-abc-123");
|
||||
});
|
||||
|
||||
it("sanitizes path-hostile characters", () => {
|
||||
expect(sessionScopeDir("claude:abc/def")).toBe("claude-abc-def");
|
||||
});
|
||||
|
||||
it("is deterministic for the same id", () => {
|
||||
expect(sessionScopeDir("s1")).toBe(sessionScopeDir("claude:s1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeUltragoalScope", () => {
|
||||
it("builds a struct with repoRoot, prefixed sessionId, and scope dir", () => {
|
||||
const scope = makeUltragoalScope("/repo", "s1");
|
||||
expect(scope.repoRoot).toBe("/repo");
|
||||
expect(scope.sessionId).toBe("claude:s1");
|
||||
expect(scope.sessionScope).toBe("claude-s1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PREFIX_RE / constants", () => {
|
||||
it("recognizes the claude prefix", () => {
|
||||
expect(PREFIX_RE.test("claude:x")).toBe(true);
|
||||
expect(PREFIX_RE.test("nope:x")).toBe(false);
|
||||
expect(CLAUDE_SESSION_PREFIX).toBe("claude:");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user