@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init feature — coverage accounting.
|
|
3
|
+
*
|
|
4
|
+
* "Nothing was lost" is only checkable if the migration *declares* where every
|
|
5
|
+
* source section went. This module extracts source sections, looks for a
|
|
6
|
+
* destination that already contains the same heading or its opening paragraph,
|
|
7
|
+
* and renders the remaining rows as a table the model must fill in.
|
|
8
|
+
*
|
|
9
|
+
* What it proves: every source section has a destination or a written reason.
|
|
10
|
+
* What it cannot prove: that the destination kept the fact intact. That is why
|
|
11
|
+
* the default migration pass is conservative and why the diff is reviewed.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
normalizeHeading,
|
|
15
|
+
normalizeProse,
|
|
16
|
+
sectionAnchor,
|
|
17
|
+
topSections,
|
|
18
|
+
} from "./analyze.js";
|
|
19
|
+
import type {
|
|
20
|
+
CoverageReport,
|
|
21
|
+
CoverageRow,
|
|
22
|
+
DocInfo,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
|
|
25
|
+
/** Destination docs searched for a section's heading or anchor. */
|
|
26
|
+
export function buildCoverage(
|
|
27
|
+
sources: readonly DocInfo[],
|
|
28
|
+
targets: readonly DocInfo[],
|
|
29
|
+
mapping: ReadonlyMap<string, { destination: string; reason?: string }> = new Map(),
|
|
30
|
+
): CoverageReport {
|
|
31
|
+
const rows: CoverageRow[] = [];
|
|
32
|
+
const targetText = targets.map((doc) => ({
|
|
33
|
+
rel: doc.rel,
|
|
34
|
+
headings: new Map(topSections(doc).map((heading) => [normalizeHeading(heading.text), heading])),
|
|
35
|
+
prose: normalizeProse(doc.text),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
for (const source of sources) {
|
|
39
|
+
for (const heading of topSections(source)) {
|
|
40
|
+
const key = normalizeHeading(heading.text);
|
|
41
|
+
if (key.length < 2) continue;
|
|
42
|
+
const anchor = normalizeProse(sectionAnchor(source, heading)).slice(0, 60);
|
|
43
|
+
|
|
44
|
+
let hit: string | undefined;
|
|
45
|
+
for (const target of targetText) {
|
|
46
|
+
if (target.headings.has(key)) {
|
|
47
|
+
hit = `${target.rel} §${target.headings.get(key)?.text ?? heading.text}`;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (anchor.length >= 8 && target.prose.includes(anchor)) {
|
|
51
|
+
hit = `${target.rel} (opening paragraph)`;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const declared = mapping.get(`${source.rel}#${heading.text}`);
|
|
57
|
+
if (hit) {
|
|
58
|
+
rows.push({ source: source.rel, heading: heading.text, status: "hit", destination: hit });
|
|
59
|
+
} else if (declared) {
|
|
60
|
+
rows.push({
|
|
61
|
+
source: source.rel,
|
|
62
|
+
heading: heading.text,
|
|
63
|
+
status: "mapped",
|
|
64
|
+
destination: declared.destination,
|
|
65
|
+
reason: declared.reason,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
rows.push({ source: source.rel, heading: heading.text, status: "open" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
rows,
|
|
75
|
+
hit: rows.filter((row) => row.status === "hit").length,
|
|
76
|
+
mapped: rows.filter((row) => row.status === "mapped").length,
|
|
77
|
+
open: rows.filter((row) => row.status === "open").length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATUS_LABEL = {
|
|
82
|
+
hit: "found",
|
|
83
|
+
mapped: "declared",
|
|
84
|
+
open: "OPEN",
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/** Render the table the model fills in (and the audit reads back). */
|
|
88
|
+
export function renderCoverage(
|
|
89
|
+
report: CoverageReport,
|
|
90
|
+
context: { createdAt: string; sources: string[]; targets: string[] },
|
|
91
|
+
): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
"# Migration coverage",
|
|
94
|
+
"",
|
|
95
|
+
`- created: ${context.createdAt}`,
|
|
96
|
+
`- sources: ${context.sources.join(", ")}`,
|
|
97
|
+
`- targets: ${context.targets.join(", ")}`,
|
|
98
|
+
"",
|
|
99
|
+
"> Every section of every source must end up either found in a destination,",
|
|
100
|
+
"> declared with a destination, or declared as dropped with a reason. A row",
|
|
101
|
+
"> left `OPEN` blocks the migration from being called complete.",
|
|
102
|
+
"",
|
|
103
|
+
"| Source | Section | Status | Destination | Reason |",
|
|
104
|
+
"| --- | --- | --- | --- | --- |",
|
|
105
|
+
];
|
|
106
|
+
for (const row of report.rows) {
|
|
107
|
+
lines.push(
|
|
108
|
+
`| ${row.source} | ${escapeCell(row.heading)} | ${STATUS_LABEL[row.status]} | ${
|
|
109
|
+
row.destination ? escapeCell(row.destination) : ""
|
|
110
|
+
} | ${row.reason ? escapeCell(row.reason) : ""} |`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
lines.push(
|
|
114
|
+
"",
|
|
115
|
+
`Summary: ${report.hit} found, ${report.mapped} declared, ${report.open} open.`,
|
|
116
|
+
"",
|
|
117
|
+
);
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeCell(text: string): string {
|
|
122
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read a filled coverage table back. Tolerant on purpose: the model edits this
|
|
127
|
+
* file, and a formatting wobble should not make the audit blind.
|
|
128
|
+
*/
|
|
129
|
+
export function parseCoverage(text: string): CoverageReport {
|
|
130
|
+
const rows: CoverageRow[] = [];
|
|
131
|
+
for (const line of text.split("\n")) {
|
|
132
|
+
if (!line.startsWith("|")) continue;
|
|
133
|
+
const cells = line
|
|
134
|
+
.split("|")
|
|
135
|
+
.slice(1, -1)
|
|
136
|
+
.map((cell) => cell.trim());
|
|
137
|
+
if (cells.length < 3) continue;
|
|
138
|
+
if (/^-+$/.test(cells[0]) || cells[0] === "Source") continue;
|
|
139
|
+
const status = cells[2].toLowerCase();
|
|
140
|
+
// A declared/found row without a destination is not accounted for: the
|
|
141
|
+
// table is the evidence, so an empty cell must not make it pass.
|
|
142
|
+
const hasDestination = Boolean(cells[3]);
|
|
143
|
+
const parsed =
|
|
144
|
+
(status === "found" || status === "hit") && hasDestination
|
|
145
|
+
? "hit"
|
|
146
|
+
: (status === "declared" || status === "mapped") && hasDestination
|
|
147
|
+
? "mapped"
|
|
148
|
+
: "open";
|
|
149
|
+
rows.push({
|
|
150
|
+
source: cells[0],
|
|
151
|
+
heading: cells[1],
|
|
152
|
+
status: parsed,
|
|
153
|
+
destination: cells[3] ? cells[3] : undefined,
|
|
154
|
+
reason: cells[4] ? cells[4] : undefined,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
rows,
|
|
159
|
+
hit: rows.filter((row) => row.status === "hit").length,
|
|
160
|
+
mapped: rows.filter((row) => row.status === "mapped").length,
|
|
161
|
+
open: rows.filter((row) => row.status === "open").length,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
countBareRefs,
|
|
6
|
+
detect,
|
|
7
|
+
isBareRef,
|
|
8
|
+
parseHeadings,
|
|
9
|
+
parseState,
|
|
10
|
+
readDoc,
|
|
11
|
+
scanRefs,
|
|
12
|
+
} from "./detect.js";
|
|
13
|
+
import { project, remove } from "./test-helpers.js";
|
|
14
|
+
|
|
15
|
+
describe("isBareRef", () => {
|
|
16
|
+
const text = "see memory/DESIGN.md, .agents/memory/X.md, my-memory/y, ~/.pi/agent/memory/z";
|
|
17
|
+
|
|
18
|
+
it("counts a reference that starts a path token", () => {
|
|
19
|
+
const index = text.indexOf("memory/");
|
|
20
|
+
expect(isBareRef(text, index, "memory/")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores the framework path, compound names and other path tails", () => {
|
|
24
|
+
expect(countBareRefs(text, "memory/")).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("counts references inside backticks and parentheses", () => {
|
|
28
|
+
expect(countBareRefs("(`memory/A.md`)", "memory/")).toBe(1);
|
|
29
|
+
expect(countBareRefs("(memory/A.md)", "memory/")).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("parseHeadings", () => {
|
|
34
|
+
it("skips headings inside fenced code blocks", () => {
|
|
35
|
+
const text = ["# Title", "```md", "## not a heading", "```", "## Real", "### Nested"].join("\n");
|
|
36
|
+
expect(parseHeadings(text).map((heading) => heading.text)).toEqual(["Title", "Real", "Nested"]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("detect", () => {
|
|
41
|
+
it("reads context files, headings and language", () => {
|
|
42
|
+
const root = project({
|
|
43
|
+
"AGENTS.md": "# 项目指南\n\n这是给代理看的说明,内容以中文为主。\n\n## 定位\n\n相片管理软件。\n\n## 硬规则\n\n不要猜。\n",
|
|
44
|
+
"src/app.ts": "// 见 memory/ARCHITECTURE.md\n",
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
const facts = detect(root);
|
|
48
|
+
expect(facts.language).toBe("zh");
|
|
49
|
+
expect(facts.context.agents?.headings.map((heading) => heading.text)).toContain("硬规则");
|
|
50
|
+
expect(facts.refs.hits).toBe(1);
|
|
51
|
+
expect(facts.isGit).toBe(false);
|
|
52
|
+
} finally {
|
|
53
|
+
remove(root);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("detects legacy directories with real file counts", () => {
|
|
58
|
+
const root = project({
|
|
59
|
+
"memory/ARCHITECTURE.md": "# Architecture\n",
|
|
60
|
+
"memory/PLAN.md": "# Plan\n\n## Route\n\n- item\n",
|
|
61
|
+
"implementations/2026-01-01_a.md": "# a\n",
|
|
62
|
+
"implementations/2026-01-02_b.md": "# b\n",
|
|
63
|
+
"todos/2026-01-03-x.md": "# x\n",
|
|
64
|
+
"specs/a.md": "# a\n",
|
|
65
|
+
"AGENTS.md": "# demo\n\nSee `implementations/2026-01-01_a.md` and `memory/PLAN.md`.\n",
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const facts = detect(root);
|
|
69
|
+
expect(facts.deprecated.memory).toBe(true);
|
|
70
|
+
expect(facts.deprecated.implementations).toBe(true);
|
|
71
|
+
expect(facts.deprecatedFiles.implementations).toBe(2);
|
|
72
|
+
expect(facts.retiredTodos).toBe(true);
|
|
73
|
+
expect(facts.legacyMemory.map((doc) => doc.rel).sort()).toEqual([
|
|
74
|
+
"memory/ARCHITECTURE.md",
|
|
75
|
+
"memory/PLAN.md",
|
|
76
|
+
]);
|
|
77
|
+
expect(facts.refs.hits).toBe(2);
|
|
78
|
+
expect(facts.refs.byArea["(root)"]?.hits).toBe(2);
|
|
79
|
+
} finally {
|
|
80
|
+
remove(root);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("detects a CLAUDE-only project and nested scope cards", () => {
|
|
85
|
+
const root = project({
|
|
86
|
+
"CLAUDE.md": "# rules\n",
|
|
87
|
+
"website/AGENTS.md": "# website\n\nScope card.\n",
|
|
88
|
+
".pi/AGENTS.md": "# pi\n",
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const facts = detect(root);
|
|
92
|
+
expect(facts.context.claude?.rel).toBe("CLAUDE.md");
|
|
93
|
+
expect(facts.context.agents).toBeUndefined();
|
|
94
|
+
expect(facts.context.piAgents?.rel).toBe(".pi/AGENTS.md");
|
|
95
|
+
expect(facts.nestedAgents.map((file) => file.rel)).toContain("website/AGENTS.md");
|
|
96
|
+
expect(facts.warnings.join("\n")).toMatch(/only CLAUDE\.md exists/);
|
|
97
|
+
} finally {
|
|
98
|
+
remove(root);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("warns when AGENTS.override.md shadows the entry", () => {
|
|
103
|
+
const root = project({
|
|
104
|
+
"AGENTS.md": "# a\n",
|
|
105
|
+
"AGENTS.override.md": "# b\n",
|
|
106
|
+
});
|
|
107
|
+
try {
|
|
108
|
+
const facts = detect(root);
|
|
109
|
+
expect(facts.warnings.join("\n")).toMatch(/AGENTS\.override\.md/);
|
|
110
|
+
} finally {
|
|
111
|
+
remove(root);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("round-trips the state file", () => {
|
|
116
|
+
const root = project({
|
|
117
|
+
".agents/framework/state.toml": [
|
|
118
|
+
"version = 1",
|
|
119
|
+
'tool = "1.2.3"',
|
|
120
|
+
'created = "2026-01-01T00:00:00+08:00"',
|
|
121
|
+
'updated = "2026-01-02T00:00:00+08:00"',
|
|
122
|
+
'profile = "core"',
|
|
123
|
+
'modules = ["design"]',
|
|
124
|
+
'lang = "zh"',
|
|
125
|
+
"",
|
|
126
|
+
"[[roots]]",
|
|
127
|
+
'label = "api"',
|
|
128
|
+
'path = "../api"',
|
|
129
|
+
'absolute = "/tmp/api"',
|
|
130
|
+
'role = "backend"',
|
|
131
|
+
'agents_md = "none"',
|
|
132
|
+
'source = "add-dir"',
|
|
133
|
+
"",
|
|
134
|
+
].join("\n"),
|
|
135
|
+
"AGENTS.md": "# 中文\n\n说明。\n",
|
|
136
|
+
});
|
|
137
|
+
try {
|
|
138
|
+
const facts = detect(root);
|
|
139
|
+
expect(facts.state?.version).toBe(1);
|
|
140
|
+
expect(facts.state?.tool).toBe("1.2.3");
|
|
141
|
+
expect(facts.state?.modules).toEqual(["design"]);
|
|
142
|
+
expect(facts.state?.roots[0]).toMatchObject({ label: "api", agentsMd: "none" });
|
|
143
|
+
expect(facts.language).toBe("zh");
|
|
144
|
+
} finally {
|
|
145
|
+
remove(root);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("flags a state version that is newer than the tool", () => {
|
|
150
|
+
const root = project({
|
|
151
|
+
".agents/framework/state.toml": 'version = 99\ntool = "x"\n',
|
|
152
|
+
"AGENTS.md": "# a\n",
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const facts = detect(root);
|
|
156
|
+
expect(facts.warnings.join("\n")).toMatch(/newer than this tool/);
|
|
157
|
+
} finally {
|
|
158
|
+
remove(root);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("scans references but never inside the framework runtime directory", () => {
|
|
163
|
+
const root = project({
|
|
164
|
+
"src/a.ts": "// memory/X.md\n",
|
|
165
|
+
".agents/framework/reports/old.md": "memory/X.md\n",
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
const scan = scanRefs(root);
|
|
169
|
+
expect(scan.paths).toEqual(["src/a.ts"]);
|
|
170
|
+
expect(scan.hits).toBe(1);
|
|
171
|
+
} finally {
|
|
172
|
+
remove(root);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("reads a zero-byte root file and a document heading map", () => {
|
|
177
|
+
const root = project({ "crash-dump": "", "AGENTS.md": "# A\n\n## B\n" });
|
|
178
|
+
try {
|
|
179
|
+
writeFileSync(join(root, "crash-dump"), "", "utf8");
|
|
180
|
+
const facts = detect(root);
|
|
181
|
+
expect(facts.rootEmptyFiles).toContain("crash-dump");
|
|
182
|
+
expect(readDoc(root, "AGENTS.md")?.headings).toHaveLength(2);
|
|
183
|
+
} finally {
|
|
184
|
+
remove(root);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("parses a deliberately partial state file", () => {
|
|
189
|
+
const state = parseState('version = 1\nroots = []\n');
|
|
190
|
+
expect(state.version).toBe(1);
|
|
191
|
+
expect(state.lang).toBe("en");
|
|
192
|
+
expect(state.profile).toBe("core");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
describe("review regressions", () => {
|
|
198
|
+
it("treats ./ and ../ prefixes as references but not other path tails", () => {
|
|
199
|
+
const text = [
|
|
200
|
+
"`../memory/A.md`",
|
|
201
|
+
"`./memory/B.md`",
|
|
202
|
+
"`../../implementations/C.md`",
|
|
203
|
+
"~/.pi/agent/memory/D.md",
|
|
204
|
+
"/memory/E.md",
|
|
205
|
+
].join("\n");
|
|
206
|
+
expect(countBareRefs(text, "memory/")).toBe(2);
|
|
207
|
+
expect(countBareRefs(text, "implementations/")).toBe(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("round-trips custom budgets through the state TOML keys", async () => {
|
|
211
|
+
const { stateToToml } = await import("./apply.js");
|
|
212
|
+
const parsed = parseState(
|
|
213
|
+
stateToToml({
|
|
214
|
+
version: 1,
|
|
215
|
+
tool: "test",
|
|
216
|
+
created: "2026-01-01T00:00:00+08:00",
|
|
217
|
+
updated: "2026-01-01T00:00:00+08:00",
|
|
218
|
+
profile: "core",
|
|
219
|
+
modules: [],
|
|
220
|
+
lang: "en",
|
|
221
|
+
budgets: {
|
|
222
|
+
agentsMdSoftChars: 9000,
|
|
223
|
+
agentsMdHardChars: 16000,
|
|
224
|
+
memoryFileChars: 50000,
|
|
225
|
+
},
|
|
226
|
+
roots: [],
|
|
227
|
+
managed: [],
|
|
228
|
+
migrations: [],
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
expect(parsed.budgets).toEqual({
|
|
232
|
+
agentsMdSoftChars: 9000,
|
|
233
|
+
agentsMdHardChars: 16000,
|
|
234
|
+
memoryFileChars: 50000,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|