oh-my-opencode 4.16.0 → 4.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/codex-qa/SKILL.md +9 -7
- package/.agents/skills/codex-qa/references/logging-debug.md +6 -4
- package/.agents/skills/opencode-qa/SKILL.md +9 -9
- package/.agents/skills/opencode-qa/references/tui-tmux.md +10 -8
- package/dist/cli/codex-ulw-loop.d.ts +8 -0
- package/dist/cli/get-local-version/types.d.ts +1 -1
- package/dist/cli/index.js +260 -207
- package/dist/cli-node/index.js +260 -207
- package/dist/features/builtin-commands/templates/start-work.d.ts +1 -1
- package/dist/features/monitor/process.d.ts +17 -1
- package/dist/index.js +1048 -1001
- package/dist/skills/frontend/SKILL.md +9 -9
- package/dist/skills/frontend/references/design/README.md +7 -3
- package/dist/skills/frontend/references/design/_INDEX.md +1 -1
- package/dist/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/dist/skills/frontend/references/designpowers/README.md +2 -2
- package/dist/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/dist/skills/start-work/SKILL.md +2 -2
- package/dist/skills/visual-qa/SKILL.md +13 -17
- package/docs/reference/web-terminal-visual-qa.md +39 -45
- package/package.json +31 -25
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
- package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
- package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +13 -13
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
- package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
- package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
- package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
- package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
- package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
- package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
- package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
- package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
- package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
- package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
- package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
- package/packages/omo-codex/scripts/install-lazycodex-version-stamp.test.mjs +2 -2
- package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
- package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
- package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
- package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
- package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
- package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
- package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
- package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
- package/script/qa/strip-ansi.mjs +10 -0
- package/script/qa/web-terminal-visual-qa.mjs +112 -195
- package/script/qa/xterm-live-terminal.mjs +180 -0
- package/script/qa/web-terminal-renderer.mjs +0 -218
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { UlwLoopItem, UlwLoopPlan } from "./domain-types.js";
|
|
2
|
+
import type { UlwLoopSteeringPlanSnapshot } from "./steering-types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compact before/after snapshots for steering ledger entries.
|
|
6
|
+
*
|
|
7
|
+
* Ledger entries used to embed the FULL plan (every goal, criterion, and
|
|
8
|
+
* evidence string) twice per audit and twice more at the entry top level.
|
|
9
|
+
* That made each accepted steer O(plan size) on disk, so the ledger grew
|
|
10
|
+
* quadratically over a run, and every dedup scan re-hydrated all of it into
|
|
11
|
+
* memory. A snapshot instead records plan-level counters plus only the goals
|
|
12
|
+
* the mutation actually touched, keeping each entry O(changed goals).
|
|
13
|
+
*/
|
|
14
|
+
export function buildSteeringPlanSnapshot(
|
|
15
|
+
plan: UlwLoopPlan,
|
|
16
|
+
changedGoalIds: ReadonlySet<string>,
|
|
17
|
+
): UlwLoopSteeringPlanSnapshot {
|
|
18
|
+
const snapshot: UlwLoopSteeringPlanSnapshot = {
|
|
19
|
+
updatedAt: plan.updatedAt,
|
|
20
|
+
goalCount: plan.goals.length,
|
|
21
|
+
goalIds: plan.goals.map((goal) => goal.id),
|
|
22
|
+
goals: plan.goals.filter((goal) => changedGoalIds.has(goal.id)),
|
|
23
|
+
};
|
|
24
|
+
return plan.activeGoalId === undefined ? snapshot : { ...snapshot, activeGoalId: plan.activeGoalId };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Ids of goals that differ between two plans, including added or removed goals. */
|
|
28
|
+
export function changedGoalIdsBetween(before: UlwLoopPlan, after: UlwLoopPlan): Set<string> {
|
|
29
|
+
const beforeById = new Map<string, UlwLoopItem>(before.goals.map((goal) => [goal.id, goal]));
|
|
30
|
+
const changed = new Set<string>();
|
|
31
|
+
for (const goal of after.goals) {
|
|
32
|
+
const prior = beforeById.get(goal.id);
|
|
33
|
+
if (prior === undefined || JSON.stringify(prior) !== JSON.stringify(goal)) changed.add(goal.id);
|
|
34
|
+
beforeById.delete(goal.id);
|
|
35
|
+
}
|
|
36
|
+
for (const id of beforeById.keys()) changed.add(id);
|
|
37
|
+
return changed;
|
|
38
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UlwLoopSteeringMutationKind, UlwLoopSteeringSource } from "./constants.js";
|
|
2
|
-
import type { UlwLoopPlan } from "./domain-types.js";
|
|
2
|
+
import type { UlwLoopItem, UlwLoopPlan } from "./domain-types.js";
|
|
3
3
|
|
|
4
4
|
export interface UlwLoopSteeringInvariantResult {
|
|
5
5
|
accepted: boolean;
|
|
@@ -44,13 +44,21 @@ export interface UlwLoopSteeringProposal {
|
|
|
44
44
|
now?: Date;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export interface UlwLoopSteeringPlanSnapshot {
|
|
48
|
+
readonly updatedAt: string;
|
|
49
|
+
readonly activeGoalId?: string;
|
|
50
|
+
readonly goalCount: number;
|
|
51
|
+
readonly goalIds: readonly string[];
|
|
52
|
+
readonly goals: readonly UlwLoopItem[];
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
export interface UlwLoopSteeringAudit {
|
|
48
56
|
kind: UlwLoopSteeringMutationKind;
|
|
49
57
|
source: UlwLoopSteeringSource;
|
|
50
58
|
targetGoalIds: string[];
|
|
51
59
|
criterionId?: string;
|
|
52
|
-
before?:
|
|
53
|
-
after?:
|
|
60
|
+
before?: UlwLoopSteeringPlanSnapshot;
|
|
61
|
+
after?: UlwLoopSteeringPlanSnapshot;
|
|
54
62
|
evidence: string;
|
|
55
63
|
rationale: string;
|
|
56
64
|
invariant: UlwLoopSteeringInvariantResult;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { isUlwLoopDone } from "./goal-status.js";
|
|
3
3
|
import type { UlwLoopScope } from "./paths.js";
|
|
4
4
|
import { seedDefaultSuccessCriteria } from "./plan-crud.js";
|
|
5
|
-
import { appendLedger,
|
|
5
|
+
import { appendLedger, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
|
|
6
|
+
import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
|
|
6
7
|
import type {
|
|
7
8
|
SteerUlwLoopResult,
|
|
8
9
|
UlwLoopItem,
|
|
@@ -245,13 +246,22 @@ export async function steerUlwLoop(repoRoot: string, proposal: UlwLoopSteeringPr
|
|
|
245
246
|
return withUlwLoopMutationLock(repoRoot, scope, async () => {
|
|
246
247
|
const plan = await readUlwLoopPlan(repoRoot, scope);
|
|
247
248
|
const key = proposal.idempotencyKey ?? proposal.promptSignature;
|
|
248
|
-
const prior = key === undefined ? undefined :
|
|
249
|
-
if (prior?.steering !== undefined)
|
|
249
|
+
const prior = key === undefined ? undefined : await findAcceptedSteeringLedgerEntry(repoRoot, key, scope);
|
|
250
|
+
if (prior?.steering !== undefined) {
|
|
251
|
+
// Legacy entries embedded full-plan before/after snapshots; never
|
|
252
|
+
// re-surface those multi-MB payloads on the dedup path.
|
|
253
|
+
const { before: _before, after: _after, ...compactPrior } = prior.steering;
|
|
254
|
+
return { plan, accepted: true, audit: { ...compactPrior, deduped: true }, rejectedReasons: [], deduped: true };
|
|
255
|
+
}
|
|
250
256
|
const audit = validateUlwLoopSteeringProposal(plan, proposal);
|
|
251
257
|
const accepted = audit.invariant.accepted;
|
|
252
258
|
const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan;
|
|
253
|
-
const finalAudit: UlwLoopSteeringAudit = { ...audit
|
|
254
|
-
if (accepted)
|
|
259
|
+
const finalAudit: UlwLoopSteeringAudit = { ...audit };
|
|
260
|
+
if (accepted) {
|
|
261
|
+
const changed = changedGoalIdsBetween(plan, next);
|
|
262
|
+
finalAudit.before = buildSteeringPlanSnapshot(plan, changed);
|
|
263
|
+
finalAudit.after = buildSteeringPlanSnapshot(next, changed);
|
|
264
|
+
}
|
|
255
265
|
if (accepted) await writePlan(repoRoot, next, scope);
|
|
256
266
|
await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope);
|
|
257
267
|
return { plan: next, accepted, audit: finalAudit, rejectedReasons: audit.invariant.rejectedReasons, deduped: false };
|
|
@@ -264,7 +274,5 @@ function ledgerEntry(proposal: UlwLoopSteeringProposal, audit: UlwLoopSteeringAu
|
|
|
264
274
|
if (goalId !== undefined) entry.goalId = goalId;
|
|
265
275
|
if (proposal.criterionId !== undefined) entry.criterionId = proposal.criterionId;
|
|
266
276
|
if (proposal.idempotencyKey !== undefined) entry.idempotencyKey = proposal.idempotencyKey;
|
|
267
|
-
if (audit.before !== undefined) entry.before = audit.before;
|
|
268
|
-
if (audit.after !== undefined) entry.after = audit.after;
|
|
269
277
|
return entry;
|
|
270
278
|
}
|
|
@@ -169,4 +169,20 @@ describe("ulwLoopCommand create-goals", () => {
|
|
|
169
169
|
|
|
170
170
|
expect(await readFile(join(testDir, ".omo/ulw-loop/manual-456/goals.json"), "utf8")).toContain("Manual scoped");
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
it("#given a valueless --session-id #when creating goals #then it fails and writes no plan", async () => {
|
|
174
|
+
const code = await ulwLoopCommand(["create-goals", "--session-id", "--brief", "- Alpha"]);
|
|
175
|
+
|
|
176
|
+
expect(code).toBe(1);
|
|
177
|
+
expect(err.join("")).toContain("--session-id requires a non-empty value");
|
|
178
|
+
await expect(readFile(join(testDir, ".omo/ulw-loop/goals.json"), "utf8")).rejects.toThrow();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("#given an empty --session-id= #when creating goals #then it fails and writes no plan", async () => {
|
|
182
|
+
const code = await ulwLoopCommand(["create-goals", "--session-id=", "--brief", "- Alpha"]);
|
|
183
|
+
|
|
184
|
+
expect(code).toBe(1);
|
|
185
|
+
expect(err.join("")).toContain("--session-id requires a non-empty value");
|
|
186
|
+
await expect(readFile(join(testDir, ".omo/ulw-loop/goals.json"), "utf8")).rejects.toThrow();
|
|
187
|
+
});
|
|
172
188
|
});
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { copyFile, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, copyFile, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { setImmediate as tick } from "node:timers/promises";
|
|
4
5
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
5
6
|
import { ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath } from "../src/paths.js";
|
|
6
7
|
import {
|
|
7
8
|
appendLedger,
|
|
9
|
+
findAcceptedSteeringLedgerEntry,
|
|
8
10
|
readSteeringLedgerEntries,
|
|
9
11
|
readUlwLoopPlan,
|
|
10
12
|
withUlwLoopMutationLock,
|
|
11
13
|
writePlan,
|
|
12
14
|
} from "../src/plan-io.js";
|
|
13
|
-
import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan } from "../src/types.js";
|
|
15
|
+
import type { UlwLoopItem, UlwLoopLedgerEntry, UlwLoopPlan, UlwLoopSteeringAudit } from "../src/types.js";
|
|
14
16
|
import { UlwLoopError } from "../src/types.js";
|
|
15
17
|
|
|
16
18
|
const NOW = "2026-05-23T00:00:00.000Z";
|
|
@@ -51,6 +53,45 @@ function entry(kind: UlwLoopLedgerEntry["kind"], goalId = "G001"): UlwLoopLedger
|
|
|
51
53
|
return { at: NOW, kind, goalId };
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
function steeringAudit(overrides: Partial<UlwLoopSteeringAudit> = {}): UlwLoopSteeringAudit {
|
|
57
|
+
return {
|
|
58
|
+
kind: "revise_pending_wording",
|
|
59
|
+
source: "cli",
|
|
60
|
+
targetGoalIds: ["G001"],
|
|
61
|
+
evidence: "observed evidence",
|
|
62
|
+
rationale: "needed change",
|
|
63
|
+
invariant: {
|
|
64
|
+
accepted: true,
|
|
65
|
+
structuralInvariantAccepted: true,
|
|
66
|
+
evidenceBackedNecessity: true,
|
|
67
|
+
noEasierCompletion: true,
|
|
68
|
+
rejectedReasons: [],
|
|
69
|
+
},
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rejectedSteeringAudit(overrides: Partial<UlwLoopSteeringAudit> = {}): UlwLoopSteeringAudit {
|
|
75
|
+
return steeringAudit({
|
|
76
|
+
invariant: {
|
|
77
|
+
accepted: false,
|
|
78
|
+
structuralInvariantAccepted: false,
|
|
79
|
+
evidenceBackedNecessity: false,
|
|
80
|
+
noEasierCompletion: true,
|
|
81
|
+
rejectedReasons: ["missing evidence"],
|
|
82
|
+
},
|
|
83
|
+
...overrides,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function steeringEntry(
|
|
88
|
+
kind: UlwLoopLedgerEntry["kind"],
|
|
89
|
+
audit: UlwLoopSteeringAudit,
|
|
90
|
+
overrides: Partial<UlwLoopLedgerEntry> = {},
|
|
91
|
+
): UlwLoopLedgerEntry {
|
|
92
|
+
return { at: NOW, kind, goalId: "G001", steering: audit, ...overrides };
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
async function makeRepo(): Promise<string> {
|
|
55
96
|
return mkdtemp(join(tmpdir(), "ug-io-"));
|
|
56
97
|
}
|
|
@@ -209,6 +250,112 @@ describe("readSteeringLedgerEntries", () => {
|
|
|
209
250
|
});
|
|
210
251
|
});
|
|
211
252
|
|
|
253
|
+
describe("findAcceptedSteeringLedgerEntry", () => {
|
|
254
|
+
it.each([
|
|
255
|
+
[
|
|
256
|
+
"top-level idempotencyKey",
|
|
257
|
+
steeringEntry("steering_accepted", steeringAudit(), { idempotencyKey: "needle-key" }),
|
|
258
|
+
],
|
|
259
|
+
["steering.idempotencyKey", steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "needle-key" }))],
|
|
260
|
+
[
|
|
261
|
+
"steering.promptSignature",
|
|
262
|
+
steeringEntry("steering_accepted", steeringAudit({ promptSignature: "needle-key" })),
|
|
263
|
+
],
|
|
264
|
+
] as const)("finds an accepted entry by %s", async (_name, target) => {
|
|
265
|
+
// given
|
|
266
|
+
const repoRoot = await makeRepo();
|
|
267
|
+
await appendLedger(repoRoot, steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "other-key" })));
|
|
268
|
+
await appendLedger(repoRoot, target);
|
|
269
|
+
|
|
270
|
+
// when
|
|
271
|
+
const found = await findAcceptedSteeringLedgerEntry(repoRoot, "needle-key");
|
|
272
|
+
|
|
273
|
+
// then
|
|
274
|
+
expect(found).toEqual(target);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("skips a rejected entry carrying the same key", async () => {
|
|
278
|
+
// given
|
|
279
|
+
const repoRoot = await makeRepo();
|
|
280
|
+
await appendLedger(
|
|
281
|
+
repoRoot,
|
|
282
|
+
steeringEntry("steering_rejected", rejectedSteeringAudit({ idempotencyKey: "needle-key" })),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// when/then
|
|
286
|
+
await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns the accepted entry even when a rejected one with the same key precedes it", async () => {
|
|
290
|
+
// given
|
|
291
|
+
const repoRoot = await makeRepo();
|
|
292
|
+
const accepted = steeringEntry("steering_accepted", steeringAudit({ idempotencyKey: "needle-key" }));
|
|
293
|
+
await appendLedger(
|
|
294
|
+
repoRoot,
|
|
295
|
+
steeringEntry("steering_rejected", rejectedSteeringAudit({ idempotencyKey: "needle-key" })),
|
|
296
|
+
);
|
|
297
|
+
await appendLedger(repoRoot, accepted);
|
|
298
|
+
|
|
299
|
+
// when/then
|
|
300
|
+
await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toEqual(accepted);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("returns undefined when the ledger file is missing", async () => {
|
|
304
|
+
// given
|
|
305
|
+
const repoRoot = await makeRepo();
|
|
306
|
+
|
|
307
|
+
// when/then
|
|
308
|
+
await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("ignores the key when it only appears in non-steering entries", async () => {
|
|
312
|
+
// given
|
|
313
|
+
const repoRoot = await makeRepo();
|
|
314
|
+
await appendLedger(repoRoot, { at: NOW, kind: "goal_completed", goalId: "G001", idempotencyKey: "needle-key" });
|
|
315
|
+
await appendLedger(repoRoot, { at: NOW, kind: "evidence_captured", goalId: "G001", evidence: "needle-key" });
|
|
316
|
+
|
|
317
|
+
// when/then
|
|
318
|
+
await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("ignores an accepted entry whose key only appears in evidence text", async () => {
|
|
322
|
+
// given
|
|
323
|
+
const repoRoot = await makeRepo();
|
|
324
|
+
await appendLedger(repoRoot, steeringEntry("steering_accepted", steeringAudit({ evidence: "needle-key" })));
|
|
325
|
+
|
|
326
|
+
// when/then
|
|
327
|
+
await expect(findAcceptedSteeringLedgerEntry(repoRoot, "needle-key")).resolves.toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("finds the key inside a legacy entry embedding full before/after plans", async () => {
|
|
331
|
+
// given
|
|
332
|
+
const repoRoot = await makeRepo();
|
|
333
|
+
const bulkyGoals = Array.from({ length: 20 }, (_, index) =>
|
|
334
|
+
makeGoal({ id: `G${String(index + 1).padStart(3, "0")}`, objective: `evidence detail ${index} `.repeat(200) }),
|
|
335
|
+
);
|
|
336
|
+
const bulkyPlan = makePlan({ goals: bulkyGoals });
|
|
337
|
+
const legacyLine = JSON.stringify({
|
|
338
|
+
at: NOW,
|
|
339
|
+
kind: "steering_accepted",
|
|
340
|
+
goalId: "G001",
|
|
341
|
+
idempotencyKey: "needle-key",
|
|
342
|
+
before: bulkyPlan,
|
|
343
|
+
after: bulkyPlan,
|
|
344
|
+
steering: { ...steeringAudit({ idempotencyKey: "needle-key" }), before: bulkyPlan, after: bulkyPlan },
|
|
345
|
+
});
|
|
346
|
+
await mkdir(ulwLoopDir(repoRoot), { recursive: true });
|
|
347
|
+
await appendFile(ulwLoopLedgerPath(repoRoot), `${legacyLine}\n`, "utf8");
|
|
348
|
+
|
|
349
|
+
// when
|
|
350
|
+
const found = await findAcceptedSteeringLedgerEntry(repoRoot, "needle-key");
|
|
351
|
+
|
|
352
|
+
// then
|
|
353
|
+
expect(legacyLine.length).toBeGreaterThan(100_000);
|
|
354
|
+
expect(found?.kind).toBe("steering_accepted");
|
|
355
|
+
expect(found?.idempotencyKey).toBe("needle-key");
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
212
359
|
describe("withUlwLoopMutationLock", () => {
|
|
213
360
|
it("serializes concurrent invocations", async () => {
|
|
214
361
|
// given
|
|
@@ -236,4 +383,115 @@ describe("withUlwLoopMutationLock", () => {
|
|
|
236
383
|
expect(maxActive).toBe(1);
|
|
237
384
|
expect(await readFile(counterPath, "utf8")).toBe("3");
|
|
238
385
|
});
|
|
386
|
+
|
|
387
|
+
it("propagates the body rejection and keeps later calls working", async () => {
|
|
388
|
+
// given
|
|
389
|
+
const repoRoot = await makeRepo();
|
|
390
|
+
|
|
391
|
+
// when
|
|
392
|
+
const failure = withUlwLoopMutationLock(repoRoot, async () => {
|
|
393
|
+
throw new Error("body exploded");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// then
|
|
397
|
+
await expect(failure).rejects.toThrow("body exploded");
|
|
398
|
+
await expect(withUlwLoopMutationLock(repoRoot, async () => "recovered")).resolves.toBe("recovered");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("still serializes a second batch after the first batch's gate settles", async () => {
|
|
402
|
+
// given
|
|
403
|
+
const repoRoot = await makeRepo();
|
|
404
|
+
let active = 0;
|
|
405
|
+
let maxActive = 0;
|
|
406
|
+
const body = async (): Promise<void> => {
|
|
407
|
+
active += 1;
|
|
408
|
+
maxActive = Math.max(maxActive, active);
|
|
409
|
+
await tick();
|
|
410
|
+
active -= 1;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// when
|
|
414
|
+
await Promise.all([1, 2, 3].map(() => withUlwLoopMutationLock(repoRoot, body)));
|
|
415
|
+
await tick();
|
|
416
|
+
await Promise.all([1, 2].map(() => withUlwLoopMutationLock(repoRoot, body)));
|
|
417
|
+
|
|
418
|
+
// then
|
|
419
|
+
expect(maxActive).toBe(1);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("keeps an in-flight successor locked when an earlier gate settles", async () => {
|
|
423
|
+
// given
|
|
424
|
+
const repoRoot = await makeRepo();
|
|
425
|
+
const events: string[] = [];
|
|
426
|
+
let releaseSecond = false;
|
|
427
|
+
|
|
428
|
+
// when
|
|
429
|
+
const first = withUlwLoopMutationLock(repoRoot, async () => {
|
|
430
|
+
events.push("first");
|
|
431
|
+
});
|
|
432
|
+
const second = withUlwLoopMutationLock(repoRoot, async () => {
|
|
433
|
+
events.push("second:start");
|
|
434
|
+
while (!releaseSecond) await tick();
|
|
435
|
+
events.push("second:end");
|
|
436
|
+
});
|
|
437
|
+
await first;
|
|
438
|
+
await tick();
|
|
439
|
+
const third = withUlwLoopMutationLock(repoRoot, async () => {
|
|
440
|
+
events.push("third");
|
|
441
|
+
});
|
|
442
|
+
releaseSecond = true;
|
|
443
|
+
await Promise.all([second, third]);
|
|
444
|
+
|
|
445
|
+
// then
|
|
446
|
+
expect(events).toEqual(["first", "second:start", "second:end", "third"]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("does not serialize distinct repo roots against each other", async () => {
|
|
450
|
+
// given
|
|
451
|
+
const rootA = await makeRepo();
|
|
452
|
+
const rootB = await makeRepo();
|
|
453
|
+
const events: string[] = [];
|
|
454
|
+
let releaseA = false;
|
|
455
|
+
|
|
456
|
+
// when
|
|
457
|
+
const held = withUlwLoopMutationLock(rootA, async () => {
|
|
458
|
+
events.push("a:start");
|
|
459
|
+
while (!releaseA) await tick();
|
|
460
|
+
events.push("a:end");
|
|
461
|
+
});
|
|
462
|
+
await withUlwLoopMutationLock(rootB, async () => {
|
|
463
|
+
events.push("b");
|
|
464
|
+
});
|
|
465
|
+
releaseA = true;
|
|
466
|
+
await held;
|
|
467
|
+
|
|
468
|
+
// then
|
|
469
|
+
expect(events).toEqual(["a:start", "b", "a:end"]);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("serializes per scope while distinct scopes stay independent", async () => {
|
|
473
|
+
// given
|
|
474
|
+
const repoRoot = await makeRepo();
|
|
475
|
+
const scoped = { sessionId: "session-a" };
|
|
476
|
+
const events: string[] = [];
|
|
477
|
+
let releaseScoped = false;
|
|
478
|
+
|
|
479
|
+
// when
|
|
480
|
+
const heldScoped = withUlwLoopMutationLock(repoRoot, scoped, async () => {
|
|
481
|
+
events.push("scoped-1:start");
|
|
482
|
+
while (!releaseScoped) await tick();
|
|
483
|
+
events.push("scoped-1:end");
|
|
484
|
+
});
|
|
485
|
+
const queuedScoped = withUlwLoopMutationLock(repoRoot, scoped, async () => {
|
|
486
|
+
events.push("scoped-2");
|
|
487
|
+
});
|
|
488
|
+
await withUlwLoopMutationLock(repoRoot, async () => {
|
|
489
|
+
events.push("root-scope");
|
|
490
|
+
});
|
|
491
|
+
releaseScoped = true;
|
|
492
|
+
await Promise.all([heldScoped, queuedScoped]);
|
|
493
|
+
|
|
494
|
+
// then
|
|
495
|
+
expect(events).toEqual(["scoped-1:start", "root-scope", "scoped-1:end", "scoped-2"]);
|
|
496
|
+
});
|
|
239
497
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "../src/steering-snapshot.js";
|
|
3
|
+
import type { UlwLoopItem, UlwLoopPlan } from "../src/types.js";
|
|
4
|
+
|
|
5
|
+
const NOW = "2026-05-23T00:00:00.000Z";
|
|
6
|
+
|
|
7
|
+
function goal(id: string, overrides: Partial<UlwLoopItem> = {}): UlwLoopItem {
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
title: `Goal ${id}`,
|
|
11
|
+
objective: `Objective for ${id}`,
|
|
12
|
+
status: "pending",
|
|
13
|
+
successCriteria: [],
|
|
14
|
+
attempt: 0,
|
|
15
|
+
createdAt: NOW,
|
|
16
|
+
updatedAt: NOW,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function plan(goals: UlwLoopItem[], overrides: Partial<UlwLoopPlan> = {}): UlwLoopPlan {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
createdAt: NOW,
|
|
25
|
+
updatedAt: NOW,
|
|
26
|
+
briefPath: ".omo/ulw-loop/brief.md",
|
|
27
|
+
goalsPath: ".omo/ulw-loop/goals.json",
|
|
28
|
+
ledgerPath: ".omo/ulw-loop/ledger.jsonl",
|
|
29
|
+
goals,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("changedGoalIdsBetween", () => {
|
|
35
|
+
it.each([
|
|
36
|
+
{
|
|
37
|
+
name: "added goal",
|
|
38
|
+
before: [goal("G001"), goal("G002")],
|
|
39
|
+
after: [goal("G001"), goal("G002"), goal("G003")],
|
|
40
|
+
expected: ["G003"],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "removed goal",
|
|
44
|
+
before: [goal("G001"), goal("G002")],
|
|
45
|
+
after: [goal("G001")],
|
|
46
|
+
expected: ["G002"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "mutated goal",
|
|
50
|
+
before: [goal("G001"), goal("G002")],
|
|
51
|
+
after: [goal("G001"), goal("G002", { title: "Rewritten" })],
|
|
52
|
+
expected: ["G002"],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "reordered-only goals",
|
|
56
|
+
before: [goal("G001"), goal("G002"), goal("G003")],
|
|
57
|
+
after: [goal("G003"), goal("G001"), goal("G002")],
|
|
58
|
+
expected: [],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "identical plans",
|
|
62
|
+
before: [goal("G001"), goal("G002")],
|
|
63
|
+
after: [goal("G001"), goal("G002")],
|
|
64
|
+
expected: [],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "add + remove + mutate at once",
|
|
68
|
+
before: [goal("G001"), goal("G002"), goal("G003")],
|
|
69
|
+
after: [goal("G001", { status: "blocked" }), goal("G003"), goal("G004")],
|
|
70
|
+
expected: ["G001", "G002", "G004"],
|
|
71
|
+
},
|
|
72
|
+
])("detects $name", ({ before, after, expected }) => {
|
|
73
|
+
// when
|
|
74
|
+
const changed = changedGoalIdsBetween(plan(before), plan(after));
|
|
75
|
+
|
|
76
|
+
// then
|
|
77
|
+
expect([...changed].sort()).toEqual(expected);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("buildSteeringPlanSnapshot", () => {
|
|
82
|
+
it("includes only changed goals while keeping full goalIds order and goalCount", () => {
|
|
83
|
+
// given
|
|
84
|
+
const goals = [goal("G001"), goal("G002"), goal("G003"), goal("G004")];
|
|
85
|
+
const source = plan(goals);
|
|
86
|
+
|
|
87
|
+
// when
|
|
88
|
+
const snapshot = buildSteeringPlanSnapshot(source, new Set(["G003"]));
|
|
89
|
+
|
|
90
|
+
// then
|
|
91
|
+
expect(snapshot.goals.map((item) => item.id)).toEqual(["G003"]);
|
|
92
|
+
expect(snapshot.goals[0]).toEqual(goals[2]);
|
|
93
|
+
expect(snapshot.goalIds).toEqual(["G001", "G002", "G003", "G004"]);
|
|
94
|
+
expect(snapshot.goalCount).toBe(4);
|
|
95
|
+
expect(snapshot.updatedAt).toBe(NOW);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("omits the activeGoalId key entirely when the plan has none", () => {
|
|
99
|
+
// when
|
|
100
|
+
const snapshot = buildSteeringPlanSnapshot(plan([goal("G001")]), new Set(["G001"]));
|
|
101
|
+
|
|
102
|
+
// then
|
|
103
|
+
expect("activeGoalId" in snapshot).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("carries activeGoalId when the plan has one", () => {
|
|
107
|
+
// when
|
|
108
|
+
const snapshot = buildSteeringPlanSnapshot(plan([goal("G001")], { activeGoalId: "G001" }), new Set());
|
|
109
|
+
|
|
110
|
+
// then
|
|
111
|
+
expect(snapshot.activeGoalId).toBe("G001");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps plan order for changed goals regardless of set insertion order", () => {
|
|
115
|
+
// given
|
|
116
|
+
const source = plan([goal("G001"), goal("G002"), goal("G003")]);
|
|
117
|
+
|
|
118
|
+
// when
|
|
119
|
+
const snapshot = buildSteeringPlanSnapshot(source, new Set(["G003", "G001"]));
|
|
120
|
+
|
|
121
|
+
// then
|
|
122
|
+
expect(snapshot.goals.map((item) => item.id)).toEqual(["G001", "G003"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { mkdtemp, readFile } from "node:fs/promises";
|
|
1
|
+
import { appendFile, mkdtemp, readFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { ulwLoopGoalsPath } from "../src/paths.js";
|
|
5
|
+
import { ulwLoopGoalsPath, ulwLoopLedgerPath } from "../src/paths.js";
|
|
6
6
|
import { readSteeringLedgerEntries, readUlwLoopPlan, writePlan } from "../src/plan-io.js";
|
|
7
7
|
import {
|
|
8
8
|
applySteeringMutation,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../src/steering.js";
|
|
13
13
|
import type {
|
|
14
14
|
UlwLoopItem,
|
|
15
|
+
UlwLoopLedgerEntry,
|
|
15
16
|
UlwLoopPlan,
|
|
16
17
|
UlwLoopSteeringProposal,
|
|
17
18
|
UlwLoopSuccessCriterion,
|
|
@@ -320,6 +321,104 @@ describe("steerUlwLoop", () => {
|
|
|
320
321
|
expect(second.deduped).toBe(true);
|
|
321
322
|
expect((await readUlwLoopPlan(repoRoot)).goals).toHaveLength(4);
|
|
322
323
|
});
|
|
324
|
+
|
|
325
|
+
describe("compact steering ledger entries", () => {
|
|
326
|
+
const DETAIL = "evidence detail ".repeat(200);
|
|
327
|
+
|
|
328
|
+
function fatGoal(id: string): UlwLoopItem {
|
|
329
|
+
return goal({
|
|
330
|
+
id,
|
|
331
|
+
title: `Fat goal ${id}`,
|
|
332
|
+
objective: `Objective ${id}: ${DETAIL}`,
|
|
333
|
+
evidence: DETAIL,
|
|
334
|
+
successCriteria: [
|
|
335
|
+
criterion({ id: "C001", scenario: DETAIL, expectedEvidence: DETAIL }),
|
|
336
|
+
criterion({ id: "C002", scenario: DETAIL, expectedEvidence: DETAIL }),
|
|
337
|
+
],
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function fatPlan(): UlwLoopPlan {
|
|
342
|
+
return plan({
|
|
343
|
+
goals: Array.from({ length: 20 }, (_, index) => fatGoal(`G${String(index + 1).padStart(3, "0")}`)),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
it("writes an O(changed goals) ledger entry instead of embedding the full plan", async () => {
|
|
348
|
+
// given
|
|
349
|
+
const seed = fatPlan();
|
|
350
|
+
const planBytes = JSON.stringify(seed).length;
|
|
351
|
+
const repoRoot = await repoWithPlan(seed);
|
|
352
|
+
|
|
353
|
+
// when
|
|
354
|
+
await steerUlwLoop(
|
|
355
|
+
repoRoot,
|
|
356
|
+
steering({ kind: "revise_pending_wording", targetGoalId: "G010", revisedTitle: "Sharper goal ten" }),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// then
|
|
360
|
+
const lines = (await readFile(ulwLoopLedgerPath(repoRoot), "utf8")).split("\n").filter(Boolean);
|
|
361
|
+
expect(lines).toHaveLength(1);
|
|
362
|
+
const line = lines[0] ?? "";
|
|
363
|
+
expect(line.length).toBeLessThan(planBytes / 4);
|
|
364
|
+
const entry: UlwLoopLedgerEntry = JSON.parse(line);
|
|
365
|
+
expect("before" in entry).toBe(false);
|
|
366
|
+
expect("after" in entry).toBe(false);
|
|
367
|
+
expect(entry.steering?.before?.goals.map((item) => item.id)).toEqual(["G010"]);
|
|
368
|
+
expect(entry.steering?.after?.goals.map((item) => item.id)).toEqual(["G010"]);
|
|
369
|
+
expect(entry.steering?.after?.goals[0]?.title).toBe("Sharper goal ten");
|
|
370
|
+
expect(entry.steering?.before?.goalCount).toBe(20);
|
|
371
|
+
expect(entry.steering?.before?.goalIds).toEqual(seed.goals.map((item) => item.id));
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("dedup against a legacy full-plan entry strips before/after from the returned audit", async () => {
|
|
376
|
+
// given
|
|
377
|
+
const seed = plan();
|
|
378
|
+
const repoRoot = await repoWithPlan(seed);
|
|
379
|
+
const legacyLine = JSON.stringify({
|
|
380
|
+
at: NOW,
|
|
381
|
+
kind: "steering_accepted",
|
|
382
|
+
goalId: "G001",
|
|
383
|
+
idempotencyKey: "legacy-key",
|
|
384
|
+
evidence: "observable blocker evidence",
|
|
385
|
+
message: "the plan must change to stay safe",
|
|
386
|
+
mutationKind: "revise_pending_wording",
|
|
387
|
+
steering: {
|
|
388
|
+
kind: "revise_pending_wording",
|
|
389
|
+
source: "cli",
|
|
390
|
+
targetGoalIds: ["G001"],
|
|
391
|
+
evidence: "observable blocker evidence",
|
|
392
|
+
rationale: "the plan must change to stay safe",
|
|
393
|
+
idempotencyKey: "legacy-key",
|
|
394
|
+
invariant: {
|
|
395
|
+
accepted: true,
|
|
396
|
+
structuralInvariantAccepted: true,
|
|
397
|
+
evidenceBackedNecessity: true,
|
|
398
|
+
noEasierCompletion: true,
|
|
399
|
+
rejectedReasons: [],
|
|
400
|
+
},
|
|
401
|
+
before: seed,
|
|
402
|
+
after: seed,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
await appendFile(ulwLoopLedgerPath(repoRoot), `${legacyLine}\n`, "utf8");
|
|
406
|
+
|
|
407
|
+
// when
|
|
408
|
+
const result = await steerUlwLoop(repoRoot, steering({ idempotencyKey: "legacy-key" }));
|
|
409
|
+
|
|
410
|
+
// then
|
|
411
|
+
expect(result.deduped).toBe(true);
|
|
412
|
+
expect(result.accepted).toBe(true);
|
|
413
|
+
expect("before" in result.audit).toBe(false);
|
|
414
|
+
expect("after" in result.audit).toBe(false);
|
|
415
|
+
expect(result.audit).toMatchObject({
|
|
416
|
+
kind: "revise_pending_wording",
|
|
417
|
+
idempotencyKey: "legacy-key",
|
|
418
|
+
deduped: true,
|
|
419
|
+
});
|
|
420
|
+
expect((await readUlwLoopPlan(repoRoot)).goals).toEqual(seed.goals);
|
|
421
|
+
});
|
|
323
422
|
});
|
|
324
423
|
|
|
325
424
|
describe("parseUlwLoopSteeringDirective", () => {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"type": "command",
|
|
8
8
|
"command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook post-compact",
|
|
9
9
|
"timeout": 5,
|
|
10
|
-
"statusMessage": "(OmO) Resetting Git Bash MCP Reminder",
|
|
10
|
+
"statusMessage": "(OmO 4.16.1) Resetting Git Bash MCP Reminder",
|
|
11
11
|
"commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\components\\bootstrap\\scripts\\node-dispatch.ps1\" \"${PLUGIN_ROOT}\\components\\git-bash\\dist\\cli.js\" hook post-compact"
|
|
12
12
|
}
|
|
13
13
|
],
|