@andares/pi-toolkits 0.9.8 → 0.11.0
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/README.md +117 -5
- package/README.zh-CN.md +102 -5
- package/package.json +8 -3
- package/src/features/init/analyze.ts +162 -0
- package/src/features/init/apply.test.ts +264 -0
- package/src/features/init/apply.ts +693 -0
- package/src/features/init/audit.test.ts +292 -0
- package/src/features/init/audit.ts +586 -0
- package/src/features/init/coverage.test.ts +93 -0
- package/src/features/init/coverage.ts +163 -0
- package/src/features/init/detect.test.ts +237 -0
- package/src/features/init/detect.ts +808 -0
- package/src/features/init/dispatch.ts +135 -0
- package/src/features/init/index.test.ts +331 -0
- package/src/features/init/index.ts +569 -0
- package/src/features/init/plan.test.ts +261 -0
- package/src/features/init/plan.ts +867 -0
- package/src/features/init/render.ts +131 -0
- package/src/features/init/spec.ts +429 -0
- package/src/features/init/templates/agents-skeleton.md +44 -0
- package/src/features/init/templates/memory-readme.md +99 -0
- package/src/features/init/templates/signpost.md +15 -0
- package/src/features/init/templates/state-gitignore +6 -0
- package/src/features/init/templates.test.ts +58 -0
- package/src/features/init/templates.ts +79 -0
- package/src/features/init/test-helpers.ts +89 -0
- package/src/features/init/tool.ts +72 -0
- package/src/features/init/types.ts +284 -0
- package/src/features/init/ui.ts +160 -0
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/index.ts +9 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +291 -10
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init feature — work order handed to the model after the deterministic half.
|
|
3
|
+
*
|
|
4
|
+
* The work order is deliberately small: the tasks, their acceptance criteria and
|
|
5
|
+
* the invariants that keep a migration from losing content. Project detail is
|
|
6
|
+
* *not* inlined here — the model reads the project. This keeps the prompt cheap
|
|
7
|
+
* and keeps the contract in `memory/README.md` (one home, one fact).
|
|
8
|
+
*/
|
|
9
|
+
import type { ApplyResult, Plan } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface WorkOrderInput {
|
|
12
|
+
readonly plan: Plan;
|
|
13
|
+
readonly applied?: ApplyResult;
|
|
14
|
+
/** Absolute path to the plan JSON the model should read. */
|
|
15
|
+
readonly planPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MODE_LABEL: Record<Plan["mode"], string> = {
|
|
19
|
+
fresh: "fresh install",
|
|
20
|
+
adopt: "adoption (existing project)",
|
|
21
|
+
reconcile: "reconcile (layout drift)",
|
|
22
|
+
upgrade: "framework upgrade",
|
|
23
|
+
"audit-only": "audit only",
|
|
24
|
+
unsupported: "refused",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function buildWorkOrder(input: WorkOrderInput): string {
|
|
28
|
+
const { plan, applied } = input;
|
|
29
|
+
const language = plan.language === "zh" ? "Chinese" : "English";
|
|
30
|
+
|
|
31
|
+
const done: string[] = [];
|
|
32
|
+
if (applied) {
|
|
33
|
+
for (const move of applied.moved) {
|
|
34
|
+
done.push(`moved ${move.from}/ → ${move.to}/ (${move.files} files)`);
|
|
35
|
+
}
|
|
36
|
+
if (applied.rewrites) {
|
|
37
|
+
done.push(
|
|
38
|
+
`rewrote ${applied.rewrites.hits} path references in ${applied.rewrites.files} files (report: ${applied.rewrites.report})`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (applied.created.length > 0) {
|
|
42
|
+
done.push(`created ${applied.created.length} files (${applied.created.join(", ")})`);
|
|
43
|
+
}
|
|
44
|
+
if (applied.snapshot) {
|
|
45
|
+
done.push(`snapshotted ${applied.snapshot.files} files to ${applied.snapshot.dir}`);
|
|
46
|
+
}
|
|
47
|
+
for (const skipped of applied.skipped) {
|
|
48
|
+
done.push(`skipped ${skipped.path} (${skipped.reason})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tasks = plan.tasks
|
|
53
|
+
.map((task, index) => {
|
|
54
|
+
const targets = task.targets && task.targets.length > 0 ? ` [${task.targets.join(", ")}]` : "";
|
|
55
|
+
return [
|
|
56
|
+
`### ${index + 1}. ${task.id}${targets}`,
|
|
57
|
+
task.detail,
|
|
58
|
+
"Acceptance:",
|
|
59
|
+
...task.acceptance.map((item) => `- ${item}`),
|
|
60
|
+
].join("\n");
|
|
61
|
+
})
|
|
62
|
+
.join("\n\n");
|
|
63
|
+
|
|
64
|
+
const lines = [
|
|
65
|
+
`AGENT FRAMEWORK v${plan.frameworkVersion} — ${MODE_LABEL[plan.mode]}`,
|
|
66
|
+
"",
|
|
67
|
+
`Repository: ${plan.root}`,
|
|
68
|
+
`Prose language for anything you write: ${language}.`,
|
|
69
|
+
`Plan (machine-readable): ${input.planPath ?? plan.artifacts.plan ?? "(see the plan task)"}`,
|
|
70
|
+
"",
|
|
71
|
+
"The deterministic half has already run. Do not repeat it, and do not move, rename or scaffold directories yourself unless a task says so.",
|
|
72
|
+
...(done.length > 0 ? ["Already done:", ...done.map((line) => `- ${line}`)] : []),
|
|
73
|
+
"",
|
|
74
|
+
"Read first, in this order:",
|
|
75
|
+
`1. .agents/memory/README.md — the contract (roles, write rules, budgets, compaction).`,
|
|
76
|
+
`2. ${input.planPath ?? plan.artifacts.plan ?? "the plan"}`,
|
|
77
|
+
"3. AGENTS.md and the memory files it indexes (they exist now; read before editing).",
|
|
78
|
+
"",
|
|
79
|
+
"Rules that decide whether this run is acceptable:",
|
|
80
|
+
"- Evidence only. Every command, version and number comes from a file or a command you ran.",
|
|
81
|
+
" Write `TBD` for what the repository does not say. Never present an inference as a fact.",
|
|
82
|
+
"- One fact, one home. When you move a fact, leave a pointer at the old place — do not",
|
|
83
|
+
" keep two copies.",
|
|
84
|
+
"- Conservative pass: fix mechanical drift, install the index and the contract, produce the",
|
|
85
|
+
" coverage table. Do not rewrite prose that is not part of a task; budget overruns are",
|
|
86
|
+
" recorded as debt in specs/PLAN.md, not paid in this pass.",
|
|
87
|
+
"- Change records are immutable except for path strings, and every such edit is listed in",
|
|
88
|
+
" the rewrite report.",
|
|
89
|
+
"- Never delete a source file or section before its coverage row says where the content went.",
|
|
90
|
+
"- Anything outside the repository (added roots) is written with normal tools and is",
|
|
91
|
+
" therefore subject to the permission gate; keep such writes to the scope cards.",
|
|
92
|
+
"- If a task cannot be completed, say so in the report and record it in specs/PLAN.md;",
|
|
93
|
+
" never leave a silent gap.",
|
|
94
|
+
"",
|
|
95
|
+
"Tasks, in order:",
|
|
96
|
+
"",
|
|
97
|
+
tasks.length > 0 ? tasks : "_No model tasks for this run._",
|
|
98
|
+
"",
|
|
99
|
+
"Finish:",
|
|
100
|
+
`1. Run the \`framework_audit\` tool. Fix every \`E-*\` finding and every \`OPEN\` coverage row.`,
|
|
101
|
+
" Warnings you intentionally defer go into specs/PLAN.md with a one-line reason.",
|
|
102
|
+
"2. Report in at most 10 lines: what changed, what is deferred, what needs the operator.",
|
|
103
|
+
];
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Machine-readable companion to the prompt (written next to plans/reports). */
|
|
108
|
+
export function serializeWorkOrder(plan: Plan, applied?: ApplyResult): string {
|
|
109
|
+
return `${JSON.stringify(
|
|
110
|
+
{
|
|
111
|
+
framework: "agent-framework",
|
|
112
|
+
version: plan.frameworkVersion,
|
|
113
|
+
mode: plan.mode,
|
|
114
|
+
root: plan.root,
|
|
115
|
+
language: plan.language,
|
|
116
|
+
createdAt: plan.createdAt,
|
|
117
|
+
tool: plan.toolVersion,
|
|
118
|
+
artifacts: plan.artifacts,
|
|
119
|
+
applied: applied
|
|
120
|
+
? {
|
|
121
|
+
moved: applied.moved,
|
|
122
|
+
rewrites: applied.rewrites,
|
|
123
|
+
created: applied.created,
|
|
124
|
+
skipped: applied.skipped,
|
|
125
|
+
snapshot: applied.snapshot,
|
|
126
|
+
}
|
|
127
|
+
: undefined,
|
|
128
|
+
tasks: plan.tasks,
|
|
129
|
+
summary: plan.summary,
|
|
130
|
+
warnings: plan.warnings,
|
|
131
|
+
},
|
|
132
|
+
null,
|
|
133
|
+
2,
|
|
134
|
+
)}\n`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { addedRootsFromBranch, parseInitArgs, registerInit } from "./index.js";
|
|
6
|
+
import { installedProject, project, remove } from "./test-helpers.js";
|
|
7
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
interface HarnessOptions {
|
|
10
|
+
readonly tools?: string[];
|
|
11
|
+
readonly hasUI?: boolean;
|
|
12
|
+
readonly idle?: boolean;
|
|
13
|
+
readonly branch?: unknown[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function harness(root: string, options: HarnessOptions = {}) {
|
|
17
|
+
const sent: string[] = [];
|
|
18
|
+
const notifications: Array<{ message: string; type?: string }> = [];
|
|
19
|
+
const entries: Array<{ type: string; data?: unknown }> = [];
|
|
20
|
+
const handlers = new Map<string, (event?: unknown, ctx?: unknown) => unknown>();
|
|
21
|
+
const tools = [...(options.tools ?? ["read", "write", "edit", "bash"])];
|
|
22
|
+
let registered: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
|
|
23
|
+
const pi = {
|
|
24
|
+
on(name: string, fn: (event?: unknown, ctx?: unknown) => unknown) {
|
|
25
|
+
handlers.set(name, fn);
|
|
26
|
+
},
|
|
27
|
+
registerTool() {},
|
|
28
|
+
registerEntryRenderer() {},
|
|
29
|
+
registerCommand(name: string, spec: { handler: typeof registered }) {
|
|
30
|
+
if (name === "ask-init") registered = spec.handler;
|
|
31
|
+
},
|
|
32
|
+
getActiveTools: () => [...tools],
|
|
33
|
+
setActiveTools: (names: string[]) => {
|
|
34
|
+
tools.length = 0;
|
|
35
|
+
tools.push(...names);
|
|
36
|
+
},
|
|
37
|
+
sendUserMessage: (text: string) => {
|
|
38
|
+
sent.push(text);
|
|
39
|
+
},
|
|
40
|
+
appendEntry: (type: string, data?: unknown) => {
|
|
41
|
+
entries.push({ type, data });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const ctx = {
|
|
45
|
+
cwd: root,
|
|
46
|
+
mode: options.hasUI ? "tui" : "print",
|
|
47
|
+
hasUI: options.hasUI ?? false,
|
|
48
|
+
isIdle: () => options.idle ?? true,
|
|
49
|
+
ui: {
|
|
50
|
+
notify: (message: string, type?: string) => notifications.push({ message, type }),
|
|
51
|
+
select: async () => "Cancel",
|
|
52
|
+
custom: async () => undefined,
|
|
53
|
+
},
|
|
54
|
+
sessionManager: { getBranch: () => options.branch ?? [] },
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
pi: pi as unknown as ExtensionAPI,
|
|
58
|
+
ctx: ctx as unknown as ExtensionCommandContext,
|
|
59
|
+
messages: notifications,
|
|
60
|
+
sent,
|
|
61
|
+
entries,
|
|
62
|
+
tools,
|
|
63
|
+
handlers,
|
|
64
|
+
run: (args: string) => {
|
|
65
|
+
if (!registered) throw new Error("ask-init was not registered");
|
|
66
|
+
return registered(args, ctx as unknown as ExtensionCommandContext);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const LEGACY: Record<string, string> = {
|
|
72
|
+
"AGENTS.md": "# demo\n\n细节记忆放在 `memory/`。\n\n## 8. 问题清单\n\n已迁至 `memory/PLAN.md` §5。\n",
|
|
73
|
+
"memory/PLAN.md": "# 计划\n\n## 路线\n\n- 项目一\n",
|
|
74
|
+
"memory/FINISHED.md": "# 已完成\n\n## 摘要\n\n- 见 `implementations/2026-01-01_a.md`\n",
|
|
75
|
+
"implementations/2026-01-01_a.md": "# a\n\n见 `memory/ARCHITECTURE.md`\n",
|
|
76
|
+
"src/a.ts": "// 见 `memory/ARCHITECTURE.md`\n",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const NOW = new Date("2026-10-26T10:00:00+08:00");
|
|
80
|
+
|
|
81
|
+
describe("parseInitArgs", () => {
|
|
82
|
+
it("defaults to a run and accepts subcommands", () => {
|
|
83
|
+
expect(parseInitArgs("").sub).toBe("run");
|
|
84
|
+
expect(parseInitArgs("check").sub).toBe("check");
|
|
85
|
+
expect(parseInitArgs("roots").sub).toBe("roots");
|
|
86
|
+
expect(parseInitArgs("plan --deep").sub).toBe("plan");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats --dry-run as the plan subcommand", () => {
|
|
90
|
+
expect(parseInitArgs("--dry-run").sub).toBe("plan");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("parses flags and language", () => {
|
|
94
|
+
const parsed = parseInitArgs("--yes --deep --lang zh");
|
|
95
|
+
expect(parsed.yes).toBe(true);
|
|
96
|
+
expect(parsed.deep).toBe(true);
|
|
97
|
+
expect(parsed.lang).toBe("zh");
|
|
98
|
+
expect(parseInitArgs("--lang=fr").lang).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("addedRootsFromBranch", () => {
|
|
103
|
+
it("reads the last add-dir entry", () => {
|
|
104
|
+
const roots = addedRootsFromBranch({
|
|
105
|
+
sessionManager: {
|
|
106
|
+
getBranch: () => [
|
|
107
|
+
{
|
|
108
|
+
type: "custom",
|
|
109
|
+
customType: "add-dir:state",
|
|
110
|
+
data: { dirs: [{ label: "api", absolutePath: "/tmp/api" }] },
|
|
111
|
+
},
|
|
112
|
+
{ type: "custom", customType: "other", data: {} },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
} as unknown as ExtensionCommandContext);
|
|
116
|
+
expect(roots).toEqual([{ label: "api", absolute: "/tmp/api" }]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("/ask-init", () => {
|
|
121
|
+
it("writes nothing for a dry run", async () => {
|
|
122
|
+
const root = project({ "README.md": "# demo\n" });
|
|
123
|
+
try {
|
|
124
|
+
const h = harness(root);
|
|
125
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
126
|
+
await h.run("--dry-run");
|
|
127
|
+
expect(existsSync(join(root, ".agents"))).toBe(false);
|
|
128
|
+
expect(h.messages.some((item) => /nothing was written/.test(item.message))).toBe(true);
|
|
129
|
+
} finally {
|
|
130
|
+
remove(root);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("installs the framework and dispatches a work order", async () => {
|
|
135
|
+
const root = project({ "README.md": "# demo\n\nA demo.\n" });
|
|
136
|
+
try {
|
|
137
|
+
const h = harness(root);
|
|
138
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
139
|
+
await h.run("--yes");
|
|
140
|
+
|
|
141
|
+
expect(existsSync(join(root, "AGENTS.md"))).toBe(true);
|
|
142
|
+
expect(existsSync(join(root, ".agents/memory/README.md"))).toBe(true);
|
|
143
|
+
expect(existsSync(join(root, ".agents/memory/ARCHITECTURE.md"))).toBe(true);
|
|
144
|
+
expect(existsSync(join(root, "specs/PLAN.md"))).toBe(true);
|
|
145
|
+
expect(existsSync(join(root, ".agents/framework/state.toml"))).toBe(true);
|
|
146
|
+
expect(existsSync(join(root, ".agents/framework/.gitignore"))).toBe(true);
|
|
147
|
+
|
|
148
|
+
expect(h.sent).toHaveLength(1);
|
|
149
|
+
expect(h.sent[0]).toContain("AGENT FRAMEWORK v1");
|
|
150
|
+
expect(h.sent[0]).toContain("framework_audit");
|
|
151
|
+
expect(h.tools).toContain("framework_audit");
|
|
152
|
+
expect(h.entries.some((entry) => entry.type === "ask-init:run")).toBe(true);
|
|
153
|
+
|
|
154
|
+
const state = readFileSync(join(root, ".agents/framework/state.toml"), "utf8");
|
|
155
|
+
expect(state).toContain("version = 1");
|
|
156
|
+
} finally {
|
|
157
|
+
remove(root);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("adopts a legacy layout and reports the move in the work order", async () => {
|
|
162
|
+
const root = project(LEGACY);
|
|
163
|
+
try {
|
|
164
|
+
const h = harness(root);
|
|
165
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
166
|
+
await h.run("--yes");
|
|
167
|
+
|
|
168
|
+
expect(existsSync(join(root, "memory"))).toBe(false);
|
|
169
|
+
expect(existsSync(join(root, ".agents/memory/FINISHED.md"))).toBe(true);
|
|
170
|
+
expect(existsSync(join(root, ".agents/implementations/2026-01-01_a.md"))).toBe(true);
|
|
171
|
+
expect(readFileSync(join(root, "src/a.ts"), "utf8")).toContain(".agents/memory/ARCHITECTURE.md");
|
|
172
|
+
expect(h.sent[0]).toContain("moved memory/ → .agents/memory/");
|
|
173
|
+
} finally {
|
|
174
|
+
remove(root);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("re-asserts the audit tool when pi rebuilds the loadout", async () => {
|
|
179
|
+
const root = project({ "README.md": "# demo\n" });
|
|
180
|
+
try {
|
|
181
|
+
const h = harness(root);
|
|
182
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
183
|
+
await h.run("--yes");
|
|
184
|
+
h.tools.length = 0;
|
|
185
|
+
h.tools.push("read", "write", "edit");
|
|
186
|
+
h.handlers.get("before_agent_start")?.({}, h.ctx);
|
|
187
|
+
expect(h.tools).toContain("framework_audit");
|
|
188
|
+
h.handlers.get("agent_end")?.({}, h.ctx);
|
|
189
|
+
expect(h.tools).not.toContain("framework_audit");
|
|
190
|
+
} finally {
|
|
191
|
+
remove(root);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("audits an installed framework and writes a runtime report", async () => {
|
|
196
|
+
const root = installedProject();
|
|
197
|
+
try {
|
|
198
|
+
const h = harness(root);
|
|
199
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
200
|
+
await h.run("check");
|
|
201
|
+
expect(h.messages.some((item) => /ask-init check: \d+ errors/.test(item.message))).toBe(true);
|
|
202
|
+
expect(existsSync(join(root, ".agents/framework/reports"))).toBe(true);
|
|
203
|
+
expect(h.sent).toHaveLength(0);
|
|
204
|
+
} finally {
|
|
205
|
+
remove(root);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("switches to audit-only when the framework is already installed", async () => {
|
|
210
|
+
const root = installedProject();
|
|
211
|
+
try {
|
|
212
|
+
const h = harness(root);
|
|
213
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
214
|
+
await h.run("--yes");
|
|
215
|
+
expect(h.sent).toHaveLength(0);
|
|
216
|
+
expect(h.messages.some((item) => /ask-init check/.test(item.message))).toBe(true);
|
|
217
|
+
} finally {
|
|
218
|
+
remove(root);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("syncs add-dir roots into the state", async () => {
|
|
223
|
+
const root = project({ "README.md": "# demo\n" });
|
|
224
|
+
const sibling = mkdtempSync(join(tmpdir(), "pi-init-root-"));
|
|
225
|
+
try {
|
|
226
|
+
const install = harness(root);
|
|
227
|
+
registerInit(install.pi, { now: NOW, toolVersion: "test" });
|
|
228
|
+
await install.run("--yes");
|
|
229
|
+
|
|
230
|
+
const sync = harness(root);
|
|
231
|
+
registerInit(sync.pi, {
|
|
232
|
+
now: NOW,
|
|
233
|
+
toolVersion: "test",
|
|
234
|
+
addedRoots: [{ label: "api", absolute: sibling }],
|
|
235
|
+
});
|
|
236
|
+
await sync.run("roots");
|
|
237
|
+
const state = readFileSync(join(root, ".agents/framework/state.toml"), "utf8");
|
|
238
|
+
expect(state).toContain('label = "api"');
|
|
239
|
+
expect(state).toContain(sibling);
|
|
240
|
+
expect(sync.messages.some((item) => /roots written/.test(item.message))).toBe(true);
|
|
241
|
+
expect(existsSync(join(root, ".agents/framework/snapshots"))).toBe(true);
|
|
242
|
+
} finally {
|
|
243
|
+
remove(root);
|
|
244
|
+
remove(sibling);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("refuses the home directory and a non-idle session", async () => {
|
|
249
|
+
const weird = harness(homedir());
|
|
250
|
+
registerInit(weird.pi, { now: NOW, toolVersion: "test" });
|
|
251
|
+
await weird.run("--yes");
|
|
252
|
+
expect(weird.messages.some((item) => /refusing to install/.test(item.message))).toBe(true);
|
|
253
|
+
|
|
254
|
+
const root = project({ "README.md": "# demo\n" });
|
|
255
|
+
try {
|
|
256
|
+
const busy = harness(root, { idle: false });
|
|
257
|
+
registerInit(busy.pi, { now: NOW, toolVersion: "test" });
|
|
258
|
+
await busy.run("--yes");
|
|
259
|
+
expect(busy.messages.some((item) => /still streaming/.test(item.message))).toBe(true);
|
|
260
|
+
expect(existsSync(join(root, ".agents"))).toBe(false);
|
|
261
|
+
} finally {
|
|
262
|
+
remove(root);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("refuses when write tools are not active (ask mode)", async () => {
|
|
267
|
+
const root = project({ "README.md": "# demo\n" });
|
|
268
|
+
try {
|
|
269
|
+
const h = harness(root, { tools: ["read", "bash"] });
|
|
270
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
271
|
+
await h.run("--yes");
|
|
272
|
+
expect(h.messages.some((item) => /not active in this session/.test(item.message))).toBe(true);
|
|
273
|
+
expect(existsSync(join(root, ".agents"))).toBe(false);
|
|
274
|
+
} finally {
|
|
275
|
+
remove(root);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("refuses a framework newer than the tool", async () => {
|
|
280
|
+
const root = installedProject({
|
|
281
|
+
".agents/framework/state.toml": 'version = 99\ntool = "future"\n',
|
|
282
|
+
});
|
|
283
|
+
try {
|
|
284
|
+
const before = readFileSync(join(root, ".agents/framework/state.toml"), "utf8");
|
|
285
|
+
const h = harness(root);
|
|
286
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
287
|
+
await h.run("--yes");
|
|
288
|
+
expect(h.sent).toHaveLength(0);
|
|
289
|
+
expect(readFileSync(join(root, ".agents/framework/state.toml"), "utf8")).toBe(before);
|
|
290
|
+
expect(h.messages.some((item) => item.type === "error")).toBe(true);
|
|
291
|
+
} finally {
|
|
292
|
+
remove(root);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
describe("review regressions", () => {
|
|
299
|
+
it("rejects unknown arguments instead of running by accident", async () => {
|
|
300
|
+
expect(parseInitArgs("--deap").error).toMatch(/unknown argument/);
|
|
301
|
+
expect(parseInitArgs("--lang fr").error).toMatch(/unknown argument/);
|
|
302
|
+
const root = project({ "README.md": "# demo\n" });
|
|
303
|
+
try {
|
|
304
|
+
const h = harness(root);
|
|
305
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
306
|
+
await h.run("--deap --yes");
|
|
307
|
+
expect(existsSync(join(root, ".agents"))).toBe(false);
|
|
308
|
+
expect(h.messages.some((item) => item.type === "error")).toBe(true);
|
|
309
|
+
expect(h.sent).toHaveLength(0);
|
|
310
|
+
} finally {
|
|
311
|
+
remove(root);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("does not write anything when the state needs an upgrade recipe", async () => {
|
|
316
|
+
const root = installedProject({
|
|
317
|
+
".agents/framework/state.toml": 'version = 0\ntool = "old"\ncreated = "2026-01-01T00:00:00+08:00"\n',
|
|
318
|
+
});
|
|
319
|
+
try {
|
|
320
|
+
const before = readFileSync(join(root, ".agents/framework/state.toml"), "utf8");
|
|
321
|
+
const h = harness(root);
|
|
322
|
+
registerInit(h.pi, { now: NOW, toolVersion: "test" });
|
|
323
|
+
await h.run("--yes");
|
|
324
|
+
expect(h.sent).toHaveLength(0);
|
|
325
|
+
expect(readFileSync(join(root, ".agents/framework/state.toml"), "utf8")).toBe(before);
|
|
326
|
+
expect(h.messages.some((item) => /no automatic upgrade recipe/.test(item.message))).toBe(true);
|
|
327
|
+
} finally {
|
|
328
|
+
remove(root);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|