@akanjs/devkit 3.1.1-rc.16 → 3.1.1-rc.17
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/agentAssets.test.ts +65 -0
- package/agentAssets.ts +166 -0
- package/agentsIndex.ts +4 -4
- package/akanContext.ts +44 -2
- package/applicationBuildRunner.ts +1 -0
- package/codeAgent/agent/akanSystemPrompt.ts +1 -1
- package/codeAgent/feedback/VerifyFeedback.ts +3 -4
- package/codeAgent/skills/akan-agent/SKILL.md +36 -0
- package/codeAgent/skills/akan-debug/SKILL.md +3 -3
- package/codeAgent/skills/akan-deps/SKILL.md +1 -2
- package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
- package/codeAgent/skills/akan-page/SKILL.md +44 -0
- package/codeAgent/skills/akan-query/SKILL.md +38 -0
- package/codeAgent/skills/akan-runtime/SKILL.md +40 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
- package/codeAgent/skills/akan-transport/SKILL.md +40 -0
- package/codeAgent/skills/akan-validate/SKILL.md +13 -11
- package/codeAgent/tools/AkanCli.ts +2 -1
- package/codeAgent/tools/AkanEditScope.ts +8 -3
- package/codeAgent/tools/AkanToolPack.ts +4 -10
- package/codeAgent/tools/AkanVerifier.ts +13 -31
- package/codeAgent/tools/akanCodePlugins.test.ts +12 -30
- package/conventionSet.test.ts +73 -0
- package/conventionSet.ts +67 -0
- package/executors.test.ts +0 -4
- package/maintainabilityReport.ts +11 -0
- package/package.json +2 -2
- package/prompter.ts +7 -0
- package/typeChecker.ts +32 -0
- package/typecheck/projectTypecheck.test.ts +18 -0
- package/typecheck/projectTypecheck.ts +29 -5
- package/typecheck/typecheck.proc.ts +6 -0
- package/typecheck/typecheckFailure.ts +13 -0
- package/workflow/artifacts.ts +10 -1
- package/workflow/render.ts +4 -3
- package/workflow/source.test.ts +55 -0
- package/workflow/source.ts +29 -10
- package/workflow/types.ts +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fsPromise from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AgentAssets } from "./agentAssets";
|
|
6
|
+
|
|
7
|
+
describe("AgentAssets", () => {
|
|
8
|
+
test("packs whole H2 sections into parts under the limit, never splitting at a fenced heading", () => {
|
|
9
|
+
const section = (name: string, size: number) => `## ${name}\n${"x".repeat(size)}`;
|
|
10
|
+
const fenced = "## C\n```md\n## not a heading\n```";
|
|
11
|
+
const parts = AgentAssets.split(
|
|
12
|
+
["# Title", section("A", 20_000), section("B", 7_000), fenced, section("D", 10_000)].join("\n"),
|
|
13
|
+
);
|
|
14
|
+
expect(parts.map((part) => part.headings)).toEqual([["A", "B", "C"], ["D"]]);
|
|
15
|
+
expect(parts.every((part) => part.text.length <= AgentAssets.referenceLimit)).toBe(true);
|
|
16
|
+
expect(parts[0]?.text).toStartWith("# Title\n## A");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("ships the public skills under both roots with their guidelines once, inside every size budget", async () => {
|
|
20
|
+
const assets = await AgentAssets.skills();
|
|
21
|
+
const names = Object.keys(AgentAssets.skillReferences);
|
|
22
|
+
const skills = assets.filter((asset) => asset.path.endsWith("/SKILL.md"));
|
|
23
|
+
expect(names.length).toBeLessThanOrEqual(16);
|
|
24
|
+
const byName = (a: string, b: string) => a.localeCompare(b);
|
|
25
|
+
expect(skills.map((asset) => asset.path).sort(byName)).toEqual(
|
|
26
|
+
AgentAssets.skillRoots.flatMap((root) => names.map((name) => `${root}/${name}/SKILL.md`)).sort(byName),
|
|
27
|
+
);
|
|
28
|
+
expect(assets.some((asset) => asset.path.includes("akan-code"))).toBe(false);
|
|
29
|
+
|
|
30
|
+
const descriptions = skills
|
|
31
|
+
.filter((asset) => asset.path.startsWith(".agents/"))
|
|
32
|
+
.map((asset) => {
|
|
33
|
+
const described: RegExpExecArray | null = /description:\s*"?(.+?)"?\n/.exec(asset.content);
|
|
34
|
+
return described?.[1] ?? "";
|
|
35
|
+
});
|
|
36
|
+
expect(descriptions.join("").length).toBeLessThanOrEqual(4_000);
|
|
37
|
+
for (const skill of skills)
|
|
38
|
+
expect([skill.path, skill.content.split("\n").length <= 500]).toEqual([skill.path, true]);
|
|
39
|
+
|
|
40
|
+
const references = assets.filter((asset) => asset.path.includes("/references/"));
|
|
41
|
+
expect(references.every((asset) => asset.path.startsWith(".agents/skills/"))).toBe(true);
|
|
42
|
+
for (const reference of references) {
|
|
43
|
+
expect(reference.content.length).toBeLessThanOrEqual(AgentAssets.referenceLimit + 100);
|
|
44
|
+
expect(skills.find((skill) => skill.content.includes(`\`${reference.path}\``))).toBeDefined();
|
|
45
|
+
}
|
|
46
|
+
expect(references.filter((asset) => asset.path.includes("/agentRule")).length).toBeGreaterThan(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("names no rule or skill file that a new workspace's .gitignore would keep out of git", async () => {
|
|
50
|
+
const assets = [...(await AgentAssets.rules()), ...(await AgentAssets.skills())];
|
|
51
|
+
const root = await fsPromise.mkdtemp(path.join(os.tmpdir(), "akan-agent-ignore-"));
|
|
52
|
+
const template = path.join(import.meta.dir, "../cli/templates/workspaceRoot/.gitignore.template");
|
|
53
|
+
await fsPromise.copyFile(template, path.join(root, ".gitignore"));
|
|
54
|
+
Bun.spawnSync(["git", "init", "-q"], { cwd: root });
|
|
55
|
+
const proc = Bun.spawn(["git", "check-ignore", "--no-index", "--stdin"], {
|
|
56
|
+
cwd: root,
|
|
57
|
+
stdin: new Blob([assets.map((asset) => asset.path).join("\n")]),
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
const [ignored, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
62
|
+
await fsPromise.rm(root, { recursive: true, force: true });
|
|
63
|
+
expect({ exitCode, ignored: ignored.split("\n").filter(Boolean) }).toEqual({ exitCode: 1, ignored: [] });
|
|
64
|
+
});
|
|
65
|
+
});
|
package/agentAssets.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import fsPromise from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { akanCodePaths } from "./codeAgent/agent/akanCodePaths";
|
|
4
|
+
import { type ConventionRule, ConventionSet } from "./conventionSet";
|
|
5
|
+
import { Prompter } from "./prompter";
|
|
6
|
+
|
|
7
|
+
export interface AgentAsset {
|
|
8
|
+
path: string;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ReferencePart {
|
|
13
|
+
text: string;
|
|
14
|
+
headings: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AgentAssets {
|
|
18
|
+
static readonly marker = "<!-- akan:generated -->";
|
|
19
|
+
static readonly ruleDir = ".claude/rules/akan";
|
|
20
|
+
static readonly skillRoots = [".claude/skills", ".agents/skills"] as const;
|
|
21
|
+
//? Claude Code cuts a shell command's output at 30,000 characters, and an agent without a file reader `cat`s a part.
|
|
22
|
+
static readonly referenceLimit = 28_000;
|
|
23
|
+
static readonly ownedGlobs = [
|
|
24
|
+
".claude/rules/akan/*.md",
|
|
25
|
+
".cursor/rules/akan-*.mdc",
|
|
26
|
+
".claude/skills/akan-*/**",
|
|
27
|
+
".agents/skills/akan-*/**",
|
|
28
|
+
] as const;
|
|
29
|
+
static readonly skillReferences = {
|
|
30
|
+
"akan-validate": [],
|
|
31
|
+
"akan-scaffold": ["workspaceRecipes"],
|
|
32
|
+
"akan-module": ["moduleOverview", "modelConstant", "modelDictionary", "fieldRule"],
|
|
33
|
+
"akan-scalar": ["scalarModule", "scalarConstant", "scalarDictionary"],
|
|
34
|
+
"akan-service": ["modelService", "modelSignal", "modelDocument"],
|
|
35
|
+
"akan-store": ["modelStore"],
|
|
36
|
+
"akan-ui": [
|
|
37
|
+
"ssrRule",
|
|
38
|
+
"cssRule",
|
|
39
|
+
"componentRule",
|
|
40
|
+
"recipeRule",
|
|
41
|
+
"modelTemplate",
|
|
42
|
+
"modelUnit",
|
|
43
|
+
"modelView",
|
|
44
|
+
"modelZone",
|
|
45
|
+
"modelUtil",
|
|
46
|
+
"framework",
|
|
47
|
+
],
|
|
48
|
+
"akan-err": [],
|
|
49
|
+
"akan-deps": [],
|
|
50
|
+
"akan-debug": [],
|
|
51
|
+
"akan-page": ["pageRule"],
|
|
52
|
+
"akan-query": ["queryRule"],
|
|
53
|
+
"akan-transport": ["transportRule"],
|
|
54
|
+
"akan-runtime": ["runtimeRule"],
|
|
55
|
+
"akan-mcp": ["mcpRule"],
|
|
56
|
+
"akan-agent": ["agentRule"],
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
static async rules({ appendix = {} }: { appendix?: Record<string, string> } = {}) {
|
|
60
|
+
const conventions = await Prompter.getConventionSet();
|
|
61
|
+
return conventions.rules.flatMap((rule) => AgentAssets.#ruleFiles(rule, appendix[rule.name]));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static rulePath(name: string) {
|
|
65
|
+
return `${AgentAssets.ruleDir}/${name}.md`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static #ruleFiles(rule: ConventionRule, appendix?: string): AgentAsset[] {
|
|
69
|
+
const globs = rule.paths.flatMap((glob) => ConventionSet.expand(glob));
|
|
70
|
+
const body = appendix ? `${rule.body}\n\n${appendix.trim()}` : rule.body;
|
|
71
|
+
const claude = `---
|
|
72
|
+
description: ${rule.description}
|
|
73
|
+
paths:
|
|
74
|
+
${globs.map((glob) => ` - "${glob}"`).join("\n")}
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
${AgentAssets.marker}
|
|
78
|
+
|
|
79
|
+
${body}
|
|
80
|
+
`;
|
|
81
|
+
const cursor = `---
|
|
82
|
+
description: ${rule.description}
|
|
83
|
+
globs: ${globs.join(",")}
|
|
84
|
+
alwaysApply: false
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
${AgentAssets.marker}
|
|
88
|
+
|
|
89
|
+
@${AgentAssets.rulePath(rule.name)}
|
|
90
|
+
`;
|
|
91
|
+
return [
|
|
92
|
+
{ path: AgentAssets.rulePath(rule.name), content: claude },
|
|
93
|
+
{ path: `.cursor/rules/akan-${rule.name}.mdc`, content: cursor },
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static async skills() {
|
|
98
|
+
const dir = akanCodePaths.builtinSkillsDir();
|
|
99
|
+
if (!dir) return [];
|
|
100
|
+
const assets: AgentAsset[] = [];
|
|
101
|
+
for (const [name, guidelines] of Object.entries(AgentAssets.skillReferences)) {
|
|
102
|
+
const source = await fsPromise.readFile(path.join(dir, name, "SKILL.md"), "utf-8");
|
|
103
|
+
const index: string[] = [];
|
|
104
|
+
for (const guideline of guidelines) {
|
|
105
|
+
const parts = AgentAssets.split(await Prompter.getInstruction(guideline));
|
|
106
|
+
parts.forEach((part, idx) => {
|
|
107
|
+
const file = parts.length === 1 ? `${guideline}.md` : `${guideline}-${idx + 1}.md`;
|
|
108
|
+
const referencePath = `.agents/skills/${name}/references/${file}`;
|
|
109
|
+
assets.push({ path: referencePath, content: `${AgentAssets.marker}\n\n${part.text.trim()}\n` });
|
|
110
|
+
index.push(`- \`${referencePath}\` — ${AgentAssets.#summarize(part.headings)}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const skill = AgentAssets.#skillFile(source, index);
|
|
114
|
+
for (const root of AgentAssets.skillRoots) assets.push({ path: `${root}/${name}/SKILL.md`, content: skill });
|
|
115
|
+
}
|
|
116
|
+
return assets;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static split(markdown: string): ReferencePart[] {
|
|
120
|
+
const sections: ReferencePart[] = [];
|
|
121
|
+
let fenced = false;
|
|
122
|
+
for (const line of markdown.trim().split("\n")) {
|
|
123
|
+
if (line.startsWith("```")) fenced = !fenced;
|
|
124
|
+
const heading = !fenced && line.startsWith("## ") ? line.slice(3).trim() : null;
|
|
125
|
+
const last = sections.at(-1);
|
|
126
|
+
if (heading !== null || !last) sections.push({ text: line, headings: heading === null ? [] : [heading] });
|
|
127
|
+
else last.text += `\n${line}`;
|
|
128
|
+
}
|
|
129
|
+
const parts: ReferencePart[] = [];
|
|
130
|
+
for (const section of sections) {
|
|
131
|
+
const last = parts.at(-1);
|
|
132
|
+
if (last && last.text.length + section.text.length + 1 <= AgentAssets.referenceLimit) {
|
|
133
|
+
last.text += `\n${section.text}`;
|
|
134
|
+
last.headings.push(...section.headings);
|
|
135
|
+
} else parts.push({ text: section.text, headings: [...section.headings] });
|
|
136
|
+
}
|
|
137
|
+
return parts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
static #summarize(headings: string[]) {
|
|
141
|
+
if (headings.length === 0) return "the whole guideline";
|
|
142
|
+
const shown = headings.slice(0, 5).join(" · ");
|
|
143
|
+
return headings.length > 5 ? `${shown} · …` : shown;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
static #skillFile(source: string, index: string[]) {
|
|
147
|
+
const match: RegExpExecArray | null = /^---\n[\s\S]*?\n---\n/.exec(source);
|
|
148
|
+
const front = match?.[0] ?? "";
|
|
149
|
+
const body = source.slice(front.length).trim();
|
|
150
|
+
const references = index.length
|
|
151
|
+
? `\n\n## References\n\nThe full guidelines, split where each part fits one read; \`akan guideline show <name>\` prints the same text.\n\n${index.join("\n")}`
|
|
152
|
+
: "";
|
|
153
|
+
return `${front}\n${AgentAssets.marker}\n\n${body}${references}\n`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
static async generatedFiles(workspaceRoot: string) {
|
|
157
|
+
const found: string[] = [];
|
|
158
|
+
for (const pattern of AgentAssets.ownedGlobs) {
|
|
159
|
+
for (const file of new Bun.Glob(pattern).scanSync({ cwd: workspaceRoot, dot: true, onlyFiles: true })) {
|
|
160
|
+
const content = await Bun.file(path.join(workspaceRoot, file)).text();
|
|
161
|
+
if (content.includes(AgentAssets.marker)) found.push(file.split(path.sep).join("/"));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return found.sort((a, b) => a.localeCompare(b));
|
|
165
|
+
}
|
|
166
|
+
}
|
package/agentsIndex.ts
CHANGED
|
@@ -119,16 +119,16 @@ export const renderScopeAgentBlock = (scope: AgentsIndexScope, recipes: RecipeIn
|
|
|
119
119
|
const scopePath = `${scope.type}s/${scope.name}`;
|
|
120
120
|
const intro = `## Recipes In Scope
|
|
121
121
|
|
|
122
|
-
UI recipes importable from \`${scopePath}\` code, **in addition to** the framework recipes indexed in
|
|
123
|
-
|
|
122
|
+
UI recipes importable from \`${scopePath}\` code, **in addition to** the framework recipes indexed in
|
|
123
|
+
\`.claude/rules/akan/style.md\`. Same contract: import by exact name, then \`<name>(variants?, className?)\` — the second
|
|
124
124
|
arg merges internally and takes an array too, so never wrap it in \`cn()\`. \`*\` marks the default, \`key?\` is a
|
|
125
125
|
boolean flag. Do not guess recipe names or import paths; this index is regenerated by \`akan sync ${scope.name}\`
|
|
126
126
|
and verified by \`akan lint ${scope.name}\`.`;
|
|
127
127
|
if (recipes.length === 0) {
|
|
128
128
|
return `${intro}
|
|
129
129
|
|
|
130
|
-
No scope recipes yet. Before inlining a repeated surface (card, box, tile, …), reuse a framework recipe from
|
|
131
|
-
|
|
130
|
+
No scope recipes yet. Before inlining a repeated surface (card, box, tile, …), reuse a framework recipe from
|
|
131
|
+
\`.claude/rules/akan/style.md\` or author one as \`${scopePath}/ui/Recipe/<name>.ts\` (one recipe per file, re-exported from that
|
|
132
132
|
folder's \`index.ts\`) — see the \`recipeRule\` guideline.`;
|
|
133
133
|
}
|
|
134
134
|
return `${intro}
|
package/akanContext.ts
CHANGED
|
@@ -280,6 +280,7 @@ const generatedFiles = [
|
|
|
280
280
|
];
|
|
281
281
|
|
|
282
282
|
const validationCommands = [
|
|
283
|
+
"akan verify [<app-or-lib-or-pkg>]",
|
|
283
284
|
"akan sync <app-or-lib>",
|
|
284
285
|
"akan lint <app-or-lib-or-pkg>",
|
|
285
286
|
"akan typecheck <app-or-lib>",
|
|
@@ -294,7 +295,7 @@ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
|
|
|
294
295
|
status: "unknown" as const,
|
|
295
296
|
message: "Run sync before validation so generated Akan files match the current source conventions.",
|
|
296
297
|
refreshCommand: "akan sync <app-or-lib>",
|
|
297
|
-
verifyingCommands: ["akan
|
|
298
|
+
verifyingCommands: ["akan verify <app-or-lib>"],
|
|
298
299
|
};
|
|
299
300
|
|
|
300
301
|
const repairAction = (
|
|
@@ -454,7 +455,7 @@ const generatedFreshnessFromStates = async (workspace: WorkspaceExecutor): Promi
|
|
|
454
455
|
? `Generated files were refreshed for ${lastFresh.target} at ${lastFresh.lastSyncedAt}.`
|
|
455
456
|
: "Generated sync state exists, but the last recorded repair did not pass.",
|
|
456
457
|
refreshCommand: "akan sync <app-or-lib>",
|
|
457
|
-
verifyingCommands: ["akan
|
|
458
|
+
verifyingCommands: ["akan verify <app-or-lib>"],
|
|
458
459
|
targets,
|
|
459
460
|
};
|
|
460
461
|
};
|
|
@@ -632,6 +633,42 @@ export class AkanContextAnalyzer {
|
|
|
632
633
|
};
|
|
633
634
|
}
|
|
634
635
|
|
|
636
|
+
//? Codex joins every AGENTS.md from the repo root down to its folder and drops what passes 32 KiB (`project_doc_max_bytes`).
|
|
637
|
+
static async #agentGuideOversize(workspace: WorkspaceExecutor, sysPaths: string[]) {
|
|
638
|
+
const sizeOf = async (file: string) => {
|
|
639
|
+
const content = await safeReadText(path.join(workspace.workspaceRoot, file));
|
|
640
|
+
return { file, bytes: content === null ? 0 : Buffer.byteLength(content) };
|
|
641
|
+
};
|
|
642
|
+
const root = await sizeOf("AGENTS.md");
|
|
643
|
+
if (root.bytes === 0) return null;
|
|
644
|
+
const scoped = await Promise.all(sysPaths.map((sysPath) => sizeOf(`${sysPath}/AGENTS.md`)));
|
|
645
|
+
const largest = scoped.reduce((max, entry) => (entry.bytes > max.bytes ? entry : max), { file: "", bytes: 0 });
|
|
646
|
+
const total = root.bytes + largest.bytes;
|
|
647
|
+
if (total <= 32 * 1024 * 0.8) return null;
|
|
648
|
+
const subject = largest.bytes ? `AGENTS.md and ${largest.file} add up to` : "AGENTS.md is";
|
|
649
|
+
return `${subject} ${Math.ceil(total / 1024)} KiB of the 32 KiB Codex reads — run \`akan agent install\`, then shorten the text outside the akan:agent markers or move it into a skill.`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
static async #agentGuideLeftovers(workspace: WorkspaceExecutor) {
|
|
653
|
+
const root = workspace.workspaceRoot;
|
|
654
|
+
const guide = (await safeReadText(path.join(root, "AGENTS.md"))) ?? "";
|
|
655
|
+
const leftovers: { path: string; message: string }[] = [];
|
|
656
|
+
for (const doc of ["docs/AI-DEVELOPMENT.md", "docs/GENERATED.md"]) {
|
|
657
|
+
const pointer = guide.includes(doc);
|
|
658
|
+
if ((await safeReadText(path.join(root, doc))) !== null)
|
|
659
|
+
leftovers.push({
|
|
660
|
+
path: doc,
|
|
661
|
+
message: `${doc} is an older template's copy of the agent guide, and nothing reads it now — delete it${pointer ? " and the AGENTS.md sentence that points to it" : ""}.`,
|
|
662
|
+
});
|
|
663
|
+
else if (pointer)
|
|
664
|
+
leftovers.push({
|
|
665
|
+
path: "AGENTS.md",
|
|
666
|
+
message: `AGENTS.md points to ${doc}, which is gone — delete that sentence.`,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return leftovers;
|
|
670
|
+
}
|
|
671
|
+
|
|
635
672
|
//? Older templates wrote these for Next.js: nothing installs or reads them, and editors warn about the plugin.
|
|
636
673
|
static async #configLeftovers(workspace: WorkspaceExecutor, sysPaths: string[]) {
|
|
637
674
|
const leftovers: { path: string; message: string }[] = [];
|
|
@@ -724,6 +761,11 @@ export class AkanContextAnalyzer {
|
|
|
724
761
|
repairAction("generated", "akan agent install agents-md", agentDrift.hint, true),
|
|
725
762
|
);
|
|
726
763
|
const sysPaths = [...context.apps, ...context.libs].map((sys) => sys.path);
|
|
764
|
+
const oversize = await AkanContextAnalyzer.#agentGuideOversize(workspace, sysPaths);
|
|
765
|
+
if (oversize)
|
|
766
|
+
diagnostics.push({ severity: "warning", code: "agent-guide-oversized", path: "AGENTS.md", message: oversize });
|
|
767
|
+
for (const leftover of await AkanContextAnalyzer.#agentGuideLeftovers(workspace))
|
|
768
|
+
diagnostics.push({ severity: "warning", code: "agent-guide-leftover", ...leftover });
|
|
727
769
|
for (const leftover of await AkanContextAnalyzer.#configLeftovers(workspace, sysPaths))
|
|
728
770
|
diagnostics.push({ severity: "warning", code: "config-leftover", ...leftover });
|
|
729
771
|
|
|
@@ -27,6 +27,7 @@ export interface TypecheckOptions {
|
|
|
27
27
|
incremental?: boolean;
|
|
28
28
|
/** The app's own tests, specs and `script/` files too (`akan typecheck`); a build leaves them out. */
|
|
29
29
|
tests?: boolean;
|
|
30
|
+
report?: "pretty" | "records";
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export type BuildPhaseId = "prepare" | "typecheck" | "backend" | "ssr" | "csr" | "assets" | "compress" | "metadata";
|
|
@@ -13,7 +13,7 @@ export const akanSystemPrompt = (profile: CodeAgentProfile) => {
|
|
|
13
13
|
];
|
|
14
14
|
if (profile.tools.builtin.includes("write"))
|
|
15
15
|
lines.push(
|
|
16
|
-
"- After editing, call `akan_verify
|
|
16
|
+
"- After editing, call `akan_verify` once. It runs sync, lint, typecheck, a fast build and the SSR check over what the working tree changed; never run `akan lint`, `akan typecheck` or `akan build` separately. A turn that edits and does not verify is not finished.",
|
|
17
17
|
);
|
|
18
18
|
else
|
|
19
19
|
lines.push(
|
|
@@ -26,11 +26,10 @@ export class VerifyFeedback implements TurnFeedbackSource {
|
|
|
26
26
|
|
|
27
27
|
async observe() {
|
|
28
28
|
const scope = await AkanEditScope.since(this.#options.cwd, this.#baseline);
|
|
29
|
-
if (!scope.apps.length && !scope.libs.length) return undefined;
|
|
29
|
+
if (!scope.apps.length && !scope.libs.length && !scope.pkgs.length) return undefined;
|
|
30
30
|
if (this.#options.readOnly) return VerifyFeedback.#readOnlyNotice(scope.needsSync, [...scope.apps, ...scope.libs]);
|
|
31
|
-
const report = await this.#verifier.verify(scope);
|
|
32
|
-
|
|
33
|
-
return AkanVerifier.summarize(report);
|
|
31
|
+
const report = await this.#verifier.verify(scope.paths);
|
|
32
|
+
return report.ok ? undefined : report.text;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
static #readOnlyNotice(needsSync: boolean, targets: string[]) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-agent
|
|
3
|
+
description: "Build the in-page agent: mounting Agent.Chat, setAgentAccess and the request policy, the LLM, publishing st.tool actions and cards, forms, readable state, zones, route guidance and slash commands."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan in-page agent
|
|
7
|
+
|
|
8
|
+
**The surface is what components declare, exactly.** `st.tool` publishes one action and `st.use` / `st.sel` /
|
|
9
|
+
`st.ref` make one store key readable while the reading component is mounted. A method on a store class gives an
|
|
10
|
+
agent nothing, and only the current page's declarations are live.
|
|
11
|
+
|
|
12
|
+
## Mount it
|
|
13
|
+
|
|
14
|
+
1. Mount `<Agent.Chat />` once, in a layout. It never executes tools: every tool runs in the caller's browser,
|
|
15
|
+
gated by guards and the approval card.
|
|
16
|
+
2. It refuses every call until `option.setAgentAccess(<guard>)` in `lib/option.ts` names who may use it
|
|
17
|
+
(`SignedIn` for a product with accounts). What a turn may ask is `option.setAgentRequestPolicy`.
|
|
18
|
+
3. The LLM is `option.setLlm({ apiKey, model, host })`, never env.
|
|
19
|
+
|
|
20
|
+
## Publish actions
|
|
21
|
+
|
|
22
|
+
- Declare the tool beside the control that already does it, and hand the result to the control:
|
|
23
|
+
`st.tool("x").desc("…").arg("id", ID).exec((id) => st.do.removeX(id))`. One handler serves the person and the
|
|
24
|
+
agent; `.desc()` is required and first.
|
|
25
|
+
- `.card(({ submit, cancel }) => <Form … />)` parks the call in the chat for an answer that is the user's to give.
|
|
26
|
+
- Store-driven forms publish themselves: `onChange={st.do.setFieldOnX}` by reference emits the `data-akan-action`
|
|
27
|
+
the agent drives. A pass-through arrow publishes nothing and fails lint.
|
|
28
|
+
- Every `st.tool(…).exec()` / `.card()` is a hook: never behind a condition.
|
|
29
|
+
|
|
30
|
+
## Reads and guidance
|
|
31
|
+
|
|
32
|
+
- `st.use.x({ agent: false })` hides a key; `hidden`, `secret` and `visual` fields are stripped from every read.
|
|
33
|
+
- A tool value is capped at 20,000 characters.
|
|
34
|
+
- Route guidance is `<Agent.Guide instructions="…" />`; model-facing text is English.
|
|
35
|
+
|
|
36
|
+
Full detail: `get_guideline` with `agentRule`.
|
|
@@ -22,7 +22,7 @@ what survives in the scrollback is bordered and truncated to the pane.
|
|
|
22
22
|
|
|
23
23
|
## Which loop you are in
|
|
24
24
|
|
|
25
|
-
- **A type or lint failure** is the `akan-validate` loop
|
|
25
|
+
- **A type or lint failure** is the `akan-validate` loop: `akan verify`.
|
|
26
26
|
- **A runtime or SSR failure** is the dev log. The page renders blank, an overlay appears, or a request
|
|
27
27
|
500s — none of that shows up in a typecheck.
|
|
28
28
|
|
|
@@ -38,5 +38,5 @@ reads like its own level is not one.
|
|
|
38
38
|
|
|
39
39
|
## Before you conclude
|
|
40
40
|
|
|
41
|
-
A change that renders is not a change that passed. Run `
|
|
42
|
-
|
|
41
|
+
A change that renders is not a change that passed. Run `akan verify`; for a `.tsx` change it also runs the
|
|
42
|
+
SSR check, since a UI edit that moves markup into a client bundle typechecks perfectly.
|
|
@@ -46,5 +46,4 @@ the package client path — `import { cnst, fetch, st, usePage } from "@apps/<ap
|
|
|
46
46
|
|
|
47
47
|
## Validate
|
|
48
48
|
|
|
49
|
-
`
|
|
50
|
-
generated barrels and reports false errors.
|
|
49
|
+
`akan verify`. Raw `tsc` misses the generated barrels and reports false errors.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-mcp
|
|
3
|
+
description: "Expose an app to MCP clients: what /mcp publishes from guards, mcp: false curation, option.setMcp settings, OAuth, refusals, page prompts and MCP Apps, and the exposure test."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan MCP
|
|
7
|
+
|
|
8
|
+
Every app serves its signals as an MCP server on `POST /mcp`, mounted by default. **Exposure follows the guards**:
|
|
9
|
+
an endpoint with a real guard is published, one with none is refused, and so is a mutation guarded only by
|
|
10
|
+
`Public`. There is no per-endpoint opt-in.
|
|
11
|
+
|
|
12
|
+
## Curation is not authorization
|
|
13
|
+
|
|
14
|
+
- `mcp: false` on an endpoint, or a `slice()` `mcp` map mirroring `guards`, keeps a perfectly guarded step (a UI
|
|
15
|
+
state-machine step a model would only call by mistake) off the shelf. It is an opt-out; everything else still
|
|
16
|
+
publishes.
|
|
17
|
+
- `guards: [Every, Person]` reserves an act for a person; `.with(AgentCall)` narrows what an agent's call may do.
|
|
18
|
+
- A guard's `static scope` decides whether a listing can hide the entry: `"account"` guards are evaluated per
|
|
19
|
+
caller, `"resource"` guards stay visible and stop the call.
|
|
20
|
+
|
|
21
|
+
## Settings
|
|
22
|
+
|
|
23
|
+
- `option.setMcp({ … })` in the app's `lib/option.ts`, never `main.ts`; `AKAN_MCP=false` takes the surface off.
|
|
24
|
+
- `JWT_SECRET` is required outside `local`.
|
|
25
|
+
- Every published entry needs a `.desc()`; model-facing text is English.
|
|
26
|
+
- Never make a refusal message more helpful.
|
|
27
|
+
|
|
28
|
+
## Pages for the host
|
|
29
|
+
|
|
30
|
+
- `page().prompt(name, description)` briefs a screen as an MCP prompt.
|
|
31
|
+
- `page().app(name, description, load)` publishes one component a host draws in the chat, as a read-only tool
|
|
32
|
+
whose `load` may only query.
|
|
33
|
+
|
|
34
|
+
## Verify
|
|
35
|
+
|
|
36
|
+
- One exposure test per app in `srvkit/mcpExposure.signal.test.ts`: list the tools with a user token, pin the names
|
|
37
|
+
with `toEqual`, and assert the curated and admin-only ones are absent. A guard change fails it first.
|
|
38
|
+
- Then `akan verify <app>` and the boot log.
|
|
39
|
+
|
|
40
|
+
Full detail: `get_guideline` with `mcpRule`, or `transportRule` for guards and rate limits.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-page
|
|
3
|
+
description: "Add or change a route under page/: the one page()/layout() chain, typed params and search, guards, heads, loading and errors, overrides, and lib pages."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan pages
|
|
7
|
+
|
|
8
|
+
A route file exports one chain and nothing else. Every setting is a stage of it.
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
export default page()
|
|
12
|
+
.param("orgId", ID)
|
|
13
|
+
.guard(() => signedIn())
|
|
14
|
+
.render(({ orgId }) => {
|
|
15
|
+
const { l } = usePage();
|
|
16
|
+
const { taskInitInOrg } = fetch.initTaskInOrg(orgId);
|
|
17
|
+
return <Task.Zone.Card init={taskInitInOrg} prefix={`/org/${orgId}`} />;
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
1. Place the file: `<route>.tsx` or `<dir>/_index.tsx` serves the path, `_layout.tsx` nests a layout (`layout()`;
|
|
24
|
+
the root one is `rootLayout()`), `[id]` is a dynamic segment, `(group)` groups without a segment. `page/` holds
|
|
25
|
+
route modules only — no helpers, no `.ts` files.
|
|
26
|
+
2. Name every `[x]` segment with `.param("x", Type)` and read a query key only with `.search("k", Type)`. Values
|
|
27
|
+
arrive typed; `lang` reaches every stage and is never declared.
|
|
28
|
+
3. Gate with `.guard(() => signedIn())` on the `_layout.tsx` (a real redirect before anything renders), and repeat
|
|
29
|
+
a stricter guard on a page that needs more. The endpoint guards are still the real gate.
|
|
30
|
+
4. In `.render()`: `usePage()`, then fetch, then return. Hand `fetch.init*` / `view*` / `edit*` fields to sections
|
|
31
|
+
unawaited; await only what the shell reads, in one `Promise.all`.
|
|
32
|
+
5. `.head()` takes JSX (or a function returning it), never a metadata object. A canonical link comes only from the
|
|
33
|
+
page's own head.
|
|
34
|
+
6. Re-skin an `akanjs/ui` component for a subtree with `_overrides.tsx`:
|
|
35
|
+
`export default override({ Slot: AppComponent })`, no logic.
|
|
36
|
+
|
|
37
|
+
## Never
|
|
38
|
+
|
|
39
|
+
- A `fetch.<mutation>()` while rendering — it throws `base.error.mutationInRender`, because opening a link would
|
|
40
|
+
perform it. Send writes from a user action.
|
|
41
|
+
- `useState`, `useEffect`, comments, a named export beside the chain, or the legacy `export default async function`.
|
|
42
|
+
- Editing a lib page through its link in `page/(libs)/` — edit the lib's own `page/` source.
|
|
43
|
+
|
|
44
|
+
Full detail: `get_guideline` with `pageRule`, or `ssrRule` for what may render on the server.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-query
|
|
3
|
+
description: "Write filters, slices and list loading: query descriptors, init paging and loadMoreOf, soft removal and hookless writes, q.search text search, and the cascade direction."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan queries and slices
|
|
7
|
+
|
|
8
|
+
## Filters and slices
|
|
9
|
+
|
|
10
|
+
- A filter in `<model>.document.ts` declares its args; a slice in `<model>.signal.ts` serves it to stores. A slice
|
|
11
|
+
`exec` returns a `query<Filter>(…)` descriptor — never a `list<Filter>` array — and you cannot chain sort or
|
|
12
|
+
limit on it.
|
|
13
|
+
- Page through the store: `initX(…, { sort, page, limit })`, then `st.do.loadMoreOf<Model>()` while
|
|
14
|
+
`hasMoreOf<Model>`. Never compare `count > page * limit`.
|
|
15
|
+
- Never ask for more than 500 rows: the server serves at most 500 per request and drops the rest silently.
|
|
16
|
+
A figure over the whole set belongs in an insight or an endpoint.
|
|
17
|
+
- The root slice takes a filter name and its args, never a raw query, and its `root:` guard is always `Admin`.
|
|
18
|
+
- No value is `q.empty`, never `q.missing`.
|
|
19
|
+
- A projection is bare on the facade (`pickById(id, { secret: true })`) and under `select` on a filter accessor —
|
|
20
|
+
the only way to read a `field.secret` value.
|
|
21
|
+
|
|
22
|
+
## Writes and removal
|
|
23
|
+
|
|
24
|
+
- Removal is soft: reads see live rows unless `{ withRemoved: true }`. The one hard delete is `purge<Filter>` /
|
|
25
|
+
`purgeMany`, for data past retention.
|
|
26
|
+
- A filter's `remove*` / `update*`, `updateById` and `removeById` run no hooks and no cascade. A model with a
|
|
27
|
+
cascade or `_postRemove` removes through `remove<Model>(id)`.
|
|
28
|
+
- Load a set once (`load<Model>Many`, one filter query) instead of one call per item in a loop — lint fails a
|
|
29
|
+
per-item service call an endpoint reaches.
|
|
30
|
+
|
|
31
|
+
## Search and cascade
|
|
32
|
+
|
|
33
|
+
- `q.search(text, { prefix: true })` sits in a filter at an AND position, over fields with the `text` role. Blank
|
|
34
|
+
input matches nothing — never a passthrough. A search slice only over data safe to enumerate.
|
|
35
|
+
- `cascade` names a direction, and the wrong one loses data: `removeRef` on the owner's relation, `removeWith` on
|
|
36
|
+
the child's reference to its owner.
|
|
37
|
+
|
|
38
|
+
Full detail: `get_guideline` with `queryRule`, and `workspaceRecipes` for a worked slice.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: akan-runtime
|
|
3
|
+
description: "Configure how an app runs: web surfaces, route prefixes, gateway vs solo, akan start and logs, the Docker image, native and desktop apps, database modes, ops, assets and the build pipeline."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Akan runtime
|
|
7
|
+
|
|
8
|
+
Runtime and build settings are declared in `akan.config.ts` and narrowed — never widened — by an env at boot.
|
|
9
|
+
Domain code reaches none of them.
|
|
10
|
+
|
|
11
|
+
## Where each setting lives
|
|
12
|
+
|
|
13
|
+
| Want | Setting |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| API only, or no CSR bundle for native apps | `web: false`, `web: { csr: false }` |
|
|
16
|
+
| Move the API or websocket mount | `new AkanApp({ prefix, websocketPrefix })` in `main.ts`, mirrored in `api: { … }`; read with `getApiPrefix()` / `getWsPrefix()` |
|
|
17
|
+
| The production image | `docker: string \| { image, preRuns, postRuns, command }` |
|
|
18
|
+
| iOS, Android, desktop | the `native` section; `desktop: { server: true }` carries the server |
|
|
19
|
+
| SQLite, several databases, a cluster | `database: { modes: [...] }`, picked by `AKAN_DATABASE_MODE` |
|
|
20
|
+
|
|
21
|
+
- `main.ts` imports `AkanApp` from `akanjs/server/akanApp`, never the barrel.
|
|
22
|
+
- Never write `/api` or `/ws` literally, and never pass a prefix as a prop.
|
|
23
|
+
- The generated image installs only `ca-certificates` and `tzdata`; add the rest in `preRuns`.
|
|
24
|
+
- An app running in two database modes avoids `q.raw`.
|
|
25
|
+
|
|
26
|
+
## Running and logs
|
|
27
|
+
|
|
28
|
+
- `akan start <app>` (`a,b` or `all` for several) runs the dev server; `akan build <app>` checks the production
|
|
29
|
+
build. A supervised session writes `local/apps/<app>/runtime/dev.log` — hand over that path, not a paste of the
|
|
30
|
+
live view. Tail with `akan logs <app>`.
|
|
31
|
+
- Give every log sink a floor (`Logger.addSink(sink, { minLevel })`) and never log once per delivered record.
|
|
32
|
+
|
|
33
|
+
## Native and desktop
|
|
34
|
+
|
|
35
|
+
- `akan start-desktop <app>`, `akan start-ios <app>`, `akan start-android <app>` load pages from a running
|
|
36
|
+
`akan start`; a desktop app carrying its server starts it itself.
|
|
37
|
+
- A desktop app's carried server listens on a loopback port any local program can call — guard it like a network
|
|
38
|
+
server. Read runtime files from `AKAN_APP_DIR`, and keep a user's secret with `NativeSecret`, never in a file.
|
|
39
|
+
|
|
40
|
+
Full detail: `get_guideline` with `runtimeRule`.
|
|
@@ -43,8 +43,8 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
43
43
|
and each workflow's inputs.)
|
|
44
44
|
2. `apply_workflow({ planPath })` — writes the scaffold and syncs the generated barrels. Returns
|
|
45
45
|
`validationTarget`.
|
|
46
|
-
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or
|
|
47
|
-
|
|
46
|
+
3. `run_validation({ runIdOrPlan: <validationTarget> })`, or run `akan verify` (`akan_verify` in akan code);
|
|
47
|
+
both are the whole chain in one process.
|
|
48
48
|
4. **Read each scaffolded file before editing it.** They are a few lines each and they extend base classes
|
|
49
49
|
that already generated the CRUD surface. Read the `akan-store` skill before touching a store or adding
|
|
50
50
|
signal endpoints, or you will redeclare `create<Model>` and hit an "already exists" typecheck error.
|
|
@@ -60,9 +60,19 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
60
60
|
- Hand-writing files is the fallback, used only when no workflow matches, or when apply reports
|
|
61
61
|
unsupported / no-op / failed diagnostics telling you to act manually. Say which case applied.
|
|
62
62
|
- Never hand-edit a generated file to "finish" a scaffold — use `repair_generated` or `akan sync <app>`.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
## No workflow tools? The CLI is the same chain
|
|
65
|
+
|
|
66
|
+
The tools come from `akan mcp` (`--mode plan` reads, `--mode apply` writes). When they are not connected, each
|
|
67
|
+
has a command that prints the same report:
|
|
68
|
+
|
|
69
|
+
| tool | command |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `list_workflows` / `explain_workflow` | `akan workflow list` / `akan workflow explain <name>` |
|
|
72
|
+
| `plan_workflow` | `akan workflow plan create-module --app <app> --module comment --format json --out <path>` |
|
|
73
|
+
| `apply_workflow` | `akan workflow apply <path> --format json` (`--dry-run` previews) |
|
|
74
|
+
| `run_validation` | `akan verify <app>` |
|
|
75
|
+
| `repair_generated` / `repair_imports` / `repair_module_shape` | `akan repair generated\|imports\|module-shape --app <app>` |
|
|
66
76
|
|
|
67
77
|
## Worked example — "an intro page, two detail pages, and a comment zone with a database"
|
|
68
78
|
|
|
@@ -73,4 +83,4 @@ Each result names the next call in `next.tool`. Follow it; do not improvise.
|
|
|
73
83
|
leave the store body empty unless you need extra state.
|
|
74
84
|
4. Write the pages and the comment UI against the generated actions — `st.do.createComment`,
|
|
75
85
|
`st.do.initCommentInPublic`, `st.use.commentListInPublic`.
|
|
76
|
-
5. `
|
|
86
|
+
5. `akan verify` until it prints `verify ok`.
|