@mrciphersmith/keryx 0.2.69 → 0.2.70
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/dist/cli.js +951 -359
- package/package.json +1 -1
- package/src/gdgraph/build-lang.test.ts +10 -3
- package/src/gdgraph/build.ts +54 -9
- package/src/gdgraph/import-kind.test.ts +205 -0
- package/src/gdgraph/query.ts +6 -1
- package/src/gdgraph/types.ts +34 -0
- package/src/gdskills/bundled/rules/core/subagent-status-protocol.md +27 -1
- package/src/gdskills/bundled/skills/orchestration/flow-orchestrator/SKILL.md +88 -8
- package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.md +1 -1
- package/src/gdskills/bundled/skills/review/review-orchestrator/SKILL.md +69 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrciphersmith/keryx",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.70",
|
|
4
4
|
"description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
@@ -188,6 +188,13 @@ test("buildGraph resolves Python absolute, relative, and __init__.py imports", a
|
|
|
188
188
|
// !!! NEVER regenerate these goldens to make a diff disappear. If this test
|
|
189
189
|
// !!! fails, the TS/JS code path changed and AC4 is violated — fix the code,
|
|
190
190
|
// !!! not the golden.
|
|
191
|
+
//
|
|
192
|
+
// EXCEPTION, recorded rather than silently applied: flow 140 (P1 — dynamic
|
|
193
|
+
// imports counted as load-order cycles) intentionally adds an `importKind`
|
|
194
|
+
// field to every edge record, TS/JS included — the whole point of that flow
|
|
195
|
+
// is that the field was missing. That is a deliberate schema change, not a
|
|
196
|
+
// TS/JS behavior drift from Java/Python work, so the AC4 invariant above
|
|
197
|
+
// still holds; only the pinned literal needed the new field appended.
|
|
191
198
|
// ---------------------------------------------------------------------------
|
|
192
199
|
|
|
193
200
|
const GOLDEN_NODES_JSONL =
|
|
@@ -197,9 +204,9 @@ const GOLDEN_NODES_JSONL =
|
|
|
197
204
|
`{"id":"src/feature/value.ts","kind":"file","path":"src/feature/value.ts","language":"typescript"}\n`;
|
|
198
205
|
|
|
199
206
|
const GOLDEN_EDGES_JSONL =
|
|
200
|
-
`{"id":"edge:1","from":"src/feature/helper.js","to":"src/feature/value.ts","kind":"imports","specifier":"./value"}\n` +
|
|
201
|
-
`{"id":"edge:2","from":"src/feature/index.ts","to":"src/feature/style.css","kind":"asset","specifier":"./style.css"}\n` +
|
|
202
|
-
`{"id":"edge:3","from":"src/feature/index.ts","to":"src/feature/value.ts","kind":"imports","specifier":"./value"}\n`;
|
|
207
|
+
`{"id":"edge:1","from":"src/feature/helper.js","to":"src/feature/value.ts","kind":"imports","specifier":"./value","importKind":"import-statement"}\n` +
|
|
208
|
+
`{"id":"edge:2","from":"src/feature/index.ts","to":"src/feature/style.css","kind":"asset","specifier":"./style.css","importKind":"import-statement"}\n` +
|
|
209
|
+
`{"id":"edge:3","from":"src/feature/index.ts","to":"src/feature/value.ts","kind":"imports","specifier":"./value","importKind":"import-statement"}\n`;
|
|
203
210
|
|
|
204
211
|
test("buildGraph output is byte-identical for a TS/JS-only project (AC4 guard)", async () => {
|
|
205
212
|
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-lang-regression");
|
package/src/gdgraph/build.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import type { GraphData, GraphEdge, GraphNode } from "./types";
|
|
4
|
+
import type { GraphData, GraphEdge, GraphNode, ImportKind, TranspilerImportKind } from "./types";
|
|
5
|
+
import { UNKNOWN_IMPORT_KIND } from "./types";
|
|
5
6
|
|
|
6
7
|
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".java", ".py"];
|
|
7
8
|
const SOURCE_RESOLUTION_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".java", ".py"];
|
|
@@ -111,9 +112,9 @@ export async function buildGraph(projectRoot: string): Promise<BuildResult> {
|
|
|
111
112
|
// dropped), so the resolution metric is honest. TS/JS keeps the exact
|
|
112
113
|
// original guard (relative + tsconfig alias only) ⇒ byte-identical output.
|
|
113
114
|
const isLanguageAware = language === "java" || language === "python";
|
|
114
|
-
const
|
|
115
|
+
const records = extractImportRecords(content, language);
|
|
115
116
|
|
|
116
|
-
for (const specifier of
|
|
117
|
+
for (const { specifier, kind: importKind } of records) {
|
|
117
118
|
const resolved = resolveImport(projectRoot, file, specifier, fileSet, resolver);
|
|
118
119
|
const asset = resolved ? null : resolveAssetImport(projectRoot, file, specifier, resolver);
|
|
119
120
|
const shouldTrackUnresolved =
|
|
@@ -136,6 +137,7 @@ export async function buildGraph(projectRoot: string): Promise<BuildResult> {
|
|
|
136
137
|
to: resolved ?? asset ?? specifier,
|
|
137
138
|
kind: resolved ? "imports" : asset ? "asset" : "unresolved",
|
|
138
139
|
specifier,
|
|
140
|
+
importKind,
|
|
139
141
|
});
|
|
140
142
|
}
|
|
141
143
|
}
|
|
@@ -200,14 +202,27 @@ async function collectSourceFiles(projectRoot: string): Promise<SourceCollection
|
|
|
200
202
|
return { files: result.sort(), skippedDirectories: skippedDirectories.sort() };
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
|
|
205
|
+
type ImportRecord = { specifier: string; kind: ImportKind };
|
|
206
|
+
|
|
207
|
+
// P1 remediation (flow 140): union the transpiler and fallback extractors as
|
|
208
|
+
// before, but keep the transpiler's per-specifier kind alive instead of
|
|
209
|
+
// collapsing everything to a bare specifier string. `build()`'s edge-writing
|
|
210
|
+
// loop reads `record.kind` straight onto the edge, so cycle detection can
|
|
211
|
+
// later tell a load-order `import-statement` from a call-time `dynamic-import`
|
|
212
|
+
// instead of the previous single "imports" bucket.
|
|
213
|
+
function extractImportRecords(content: string, language: string): ImportRecord[] {
|
|
204
214
|
// Java/Python are not TS/JS syntax — the tsx transpiler cannot scan them
|
|
205
215
|
// (it throws today, which is why they already reach the fallback). Route them
|
|
206
216
|
// explicitly to the regex fallback that carries the java/python patterns,
|
|
207
217
|
// rather than depending on the transpiler always throwing. TS/JS keep the
|
|
208
218
|
// exact original transpiler-then-fallback path ⇒ byte-identical output.
|
|
219
|
+
// The fallback is a plain regex with no notion of import kind, so every
|
|
220
|
+
// Java/Python edge is marked UNKNOWN_IMPORT_KIND — never guessed (AC4).
|
|
209
221
|
if (language === "java" || language === "python") {
|
|
210
|
-
return extractImportSpecifiersFallback(content)
|
|
222
|
+
return extractImportSpecifiersFallback(content).map((specifier) => ({
|
|
223
|
+
specifier,
|
|
224
|
+
kind: UNKNOWN_IMPORT_KIND,
|
|
225
|
+
}));
|
|
211
226
|
}
|
|
212
227
|
// `Bun.Transpiler#scanImports` ERASES type-only imports: `import type {X} from
|
|
213
228
|
// "./m"` and `export type {X} from "./m"` are compiled away, so the transpiler
|
|
@@ -219,16 +234,46 @@ function extractImportSpecifiers(content: string, language: string): string[] {
|
|
|
219
234
|
// odd formatting), the fallback contributes the type-only ones.
|
|
220
235
|
const scanned = scanImportsOrEmpty(content);
|
|
221
236
|
const fallback = extractImportSpecifiersFallback(content);
|
|
222
|
-
|
|
237
|
+
|
|
238
|
+
const kindBySpecifier = new Map<string, ImportKind>();
|
|
239
|
+
for (const { specifier, kind } of scanned) {
|
|
240
|
+
const existing = kindBySpecifier.get(specifier);
|
|
241
|
+
// The same specifier can appear more than once in a file (a static import
|
|
242
|
+
// plus a separate `await import()` of the same path). A real static edge
|
|
243
|
+
// makes it a load-order dependency regardless of what else also imports
|
|
244
|
+
// it dynamically elsewhere in the file, so a non-dynamic kind always wins
|
|
245
|
+
// over a dynamic one already recorded for the same specifier.
|
|
246
|
+
if (!existing || (existing === "dynamic-import" && kind !== "dynamic-import")) {
|
|
247
|
+
kindBySpecifier.set(specifier, kind);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Fallback-only specifiers (not seen by the transpiler at all — e.g.
|
|
251
|
+
// type-only imports) get the explicit unknown/static marker, never a
|
|
252
|
+
// guessed kind (AC4). A specifier the transpiler DID see keeps its real
|
|
253
|
+
// kind; the fallback never overrides it.
|
|
254
|
+
for (const specifier of fallback) {
|
|
255
|
+
if (!kindBySpecifier.has(specifier)) {
|
|
256
|
+
kindBySpecifier.set(specifier, UNKNOWN_IMPORT_KIND);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return [...kindBySpecifier.entries()]
|
|
261
|
+
.map(([specifier, kind]) => ({ specifier, kind }))
|
|
262
|
+
.sort((a, b) => a.specifier.localeCompare(b.specifier));
|
|
223
263
|
}
|
|
224
264
|
|
|
225
|
-
|
|
265
|
+
type ScannedImport = { specifier: string; kind: TranspilerImportKind };
|
|
266
|
+
|
|
267
|
+
function scanImportsOrEmpty(content: string): ScannedImport[] {
|
|
226
268
|
try {
|
|
227
269
|
const scanner = new Bun.Transpiler({ loader: "tsx" });
|
|
228
270
|
return scanner
|
|
229
271
|
.scanImports(content)
|
|
230
|
-
.
|
|
231
|
-
|
|
272
|
+
.filter(
|
|
273
|
+
(entry): entry is { path: string; kind: TranspilerImportKind } =>
|
|
274
|
+
typeof entry.path === "string" && entry.path.length > 0,
|
|
275
|
+
)
|
|
276
|
+
.map((entry) => ({ specifier: entry.path, kind: entry.kind }));
|
|
232
277
|
} catch {
|
|
233
278
|
// Unparseable source ⇒ the regex fallback alone still yields the imports.
|
|
234
279
|
return [];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// P1 remediation (flow 140) — `keryx gdgraph query cycles` was folding
|
|
2
|
+
// `await import()` edges into the load-order cycle count. `Bun.Transpiler
|
|
3
|
+
// #scanImports` already reports whether a specifier is a static
|
|
4
|
+
// import-statement, a dynamic-import, a require-call, etc.; `build.ts` threw
|
|
5
|
+
// that classification away one line after receiving it. These tests are
|
|
6
|
+
// written FIRST (TDD RED) against the frozen acceptance criteria in
|
|
7
|
+
// `.metaproject/flows/140-2026-08-07-gdgraph-dynamic-import-edges/acceptance-criteria.md`.
|
|
8
|
+
//
|
|
9
|
+
// Conventions mirror build.test.ts / build-lang.test.ts: uniqueTestRoot(),
|
|
10
|
+
// reset(root), buildGraph(root), loadGraph(root).
|
|
11
|
+
|
|
12
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { expect, test } from "bun:test";
|
|
16
|
+
import { buildGraph } from "./build";
|
|
17
|
+
import { getCycles, loadGraph } from "./query";
|
|
18
|
+
import { uniqueTestRoot } from "../lib/test-tmp";
|
|
19
|
+
|
|
20
|
+
async function reset(root: string): Promise<void> {
|
|
21
|
+
await rm(root, { recursive: true, force: true });
|
|
22
|
+
await mkdir(root, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// AC1 — every edge the transpiler produced carries the kind scanImports
|
|
27
|
+
// actually returned, and it survives to the written edge record.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
test("AC1 — the transpiler's import kind survives onto the written edge record", async () => {
|
|
31
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-ac1");
|
|
32
|
+
await reset(root);
|
|
33
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
34
|
+
await writeFile(
|
|
35
|
+
path.join(root, "src", "index.ts"),
|
|
36
|
+
[
|
|
37
|
+
"import { staticValue } from './static';",
|
|
38
|
+
"const loadDynamic = async () => { const { dynamicValue } = await import('./dynamic'); return dynamicValue; };",
|
|
39
|
+
"const required = require('./required');",
|
|
40
|
+
"export const result = { staticValue, loadDynamic, required };",
|
|
41
|
+
"",
|
|
42
|
+
].join("\n"),
|
|
43
|
+
);
|
|
44
|
+
await writeFile(path.join(root, "src", "static.ts"), "export const staticValue = 1;\n");
|
|
45
|
+
await writeFile(path.join(root, "src", "dynamic.ts"), "export const dynamicValue = 2;\n");
|
|
46
|
+
await writeFile(path.join(root, "src", "required.ts"), "export const requiredValue = 3;\n");
|
|
47
|
+
|
|
48
|
+
await buildGraph(root);
|
|
49
|
+
const graph = await loadGraph(root);
|
|
50
|
+
const edgesFromIndex = graph.edges.filter(
|
|
51
|
+
(edge) => edge.from === "src/index.ts" && edge.kind === "imports",
|
|
52
|
+
);
|
|
53
|
+
const importKindByTarget = new Map(edgesFromIndex.map((edge) => [edge.to, edge.importKind]));
|
|
54
|
+
|
|
55
|
+
expect(importKindByTarget.get("src/static.ts")).toBe("import-statement");
|
|
56
|
+
expect(importKindByTarget.get("src/dynamic.ts")).toBe("dynamic-import");
|
|
57
|
+
expect(importKindByTarget.get("src/required.ts")).toBe("require-call");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a specifier imported both statically and dynamically in the same file keeps the static classification", async () => {
|
|
61
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-mixed-specifier");
|
|
62
|
+
await reset(root);
|
|
63
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
64
|
+
await writeFile(
|
|
65
|
+
path.join(root, "src", "index.ts"),
|
|
66
|
+
[
|
|
67
|
+
"import './shared';",
|
|
68
|
+
"const loadAgain = async () => { await import('./shared'); };",
|
|
69
|
+
"export const result = loadAgain;",
|
|
70
|
+
"",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
);
|
|
73
|
+
await writeFile(path.join(root, "src", "shared.ts"), "export const shared = true;\n");
|
|
74
|
+
|
|
75
|
+
await buildGraph(root);
|
|
76
|
+
const graph = await loadGraph(root);
|
|
77
|
+
const edge = graph.edges.find((item) => item.from === "src/index.ts" && item.to === "src/shared.ts");
|
|
78
|
+
|
|
79
|
+
expect(edge?.importKind).toBe("import-statement");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// AC2 — a fixture reproducing the target's shape: one static edge, one
|
|
84
|
+
// dynamic-import edge back, no longer reported as a load-order cycle.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
test("AC2 — a cycle closed only through a dynamic-import edge is no longer reported as load-order", async () => {
|
|
88
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-cycle-mixed");
|
|
89
|
+
await reset(root);
|
|
90
|
+
await mkdir(path.join(root, "bot", "commands"), { recursive: true });
|
|
91
|
+
// Mirrors the target's shape: commands/menu.ts statically imports
|
|
92
|
+
// callbacks.ts; callbacks.ts reaches back into menu.ts only via `await
|
|
93
|
+
// import()`.
|
|
94
|
+
await writeFile(
|
|
95
|
+
path.join(root, "bot", "commands", "menu.ts"),
|
|
96
|
+
"import { registerCallback } from '../callbacks';\nexport const menu = () => registerCallback();\n",
|
|
97
|
+
);
|
|
98
|
+
await writeFile(
|
|
99
|
+
path.join(root, "bot", "callbacks.ts"),
|
|
100
|
+
[
|
|
101
|
+
"export const registerCallback = () => 1;",
|
|
102
|
+
"export const handleMenuCallback = async () => {",
|
|
103
|
+
" const { menu } = await import('./commands/menu');",
|
|
104
|
+
" return menu();",
|
|
105
|
+
"};",
|
|
106
|
+
"",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await buildGraph(root);
|
|
111
|
+
const graph = await loadGraph(root);
|
|
112
|
+
const cycles = getCycles(graph);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
cycles.some((cycle) => cycle.includes("bot/callbacks.ts") && cycle.includes("bot/commands/menu.ts")),
|
|
116
|
+
).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// AC3 — classification both ways, same two-file cycle shape.
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
test("AC3 — a two-file cycle formed by static imports IS reported", async () => {
|
|
124
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-cycle-static");
|
|
125
|
+
await reset(root);
|
|
126
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
127
|
+
await writeFile(path.join(root, "src", "a.ts"), "import { b } from './b';\nexport const a = () => b();\n");
|
|
128
|
+
await writeFile(path.join(root, "src", "b.ts"), "import { a } from './a';\nexport const b = () => a();\n");
|
|
129
|
+
|
|
130
|
+
await buildGraph(root);
|
|
131
|
+
const graph = await loadGraph(root);
|
|
132
|
+
const cycles = getCycles(graph);
|
|
133
|
+
|
|
134
|
+
expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("AC3 — the same two-file cycle formed by await import() is NOT reported as load-order", async () => {
|
|
138
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-cycle-dynamic");
|
|
139
|
+
await reset(root);
|
|
140
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
141
|
+
await writeFile(
|
|
142
|
+
path.join(root, "src", "a.ts"),
|
|
143
|
+
"export const a = async () => { const { b } = await import('./b'); return b(); };\n",
|
|
144
|
+
);
|
|
145
|
+
await writeFile(
|
|
146
|
+
path.join(root, "src", "b.ts"),
|
|
147
|
+
"export const b = async () => { const { a } = await import('./a'); return a(); };\n",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await buildGraph(root);
|
|
151
|
+
const graph = await loadGraph(root);
|
|
152
|
+
const cycles = getCycles(graph);
|
|
153
|
+
|
|
154
|
+
expect(cycles.some((cycle) => cycle.includes("src/a.ts") && cycle.includes("src/b.ts"))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// AC4 — an edge found only by the regex fallback (never seen by scanImports)
|
|
159
|
+
// is marked with an explicit unknown/static marker, never inferred dynamic.
|
|
160
|
+
// A type-only import is the real-world case: `Bun.Transpiler#scanImports`
|
|
161
|
+
// erases `import type {...}` entirely, so it reaches the graph only through
|
|
162
|
+
// `extractImportSpecifiersFallback`.
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
test("AC4 — a fallback-only edge (type-only import) is marked unknown-static, never inferred dynamic", async () => {
|
|
166
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-ac4");
|
|
167
|
+
await reset(root);
|
|
168
|
+
await mkdir(path.join(root, "src"), { recursive: true });
|
|
169
|
+
await writeFile(
|
|
170
|
+
path.join(root, "src", "consumer.ts"),
|
|
171
|
+
"import type { Shape } from './types';\nexport const consumer: Shape = { ok: true } as Shape;\n",
|
|
172
|
+
);
|
|
173
|
+
await writeFile(path.join(root, "src", "types.ts"), "export interface Shape { ok: boolean }\n");
|
|
174
|
+
|
|
175
|
+
await buildGraph(root);
|
|
176
|
+
const graph = await loadGraph(root);
|
|
177
|
+
const edge = graph.edges.find((item) => item.from === "src/consumer.ts" && item.to === "src/types.ts");
|
|
178
|
+
|
|
179
|
+
expect(edge).toBeDefined();
|
|
180
|
+
expect(edge?.importKind).toBe("unknown-static");
|
|
181
|
+
expect(edge?.importKind).not.toBe("dynamic-import");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("AC4 — Java imports (fallback-only language) are marked unknown-static, never dynamic", async () => {
|
|
185
|
+
const root = uniqueTestRoot(tmpdir(), "keryx-gdgraph-import-kind-java");
|
|
186
|
+
await reset(root);
|
|
187
|
+
const javaRoot = path.join(root, "src", "main", "java", "com", "example");
|
|
188
|
+
await mkdir(javaRoot, { recursive: true });
|
|
189
|
+
await writeFile(
|
|
190
|
+
path.join(javaRoot, "Consumer.java"),
|
|
191
|
+
"package com.example;\nimport com.example.Model;\npublic class Consumer {}\n",
|
|
192
|
+
);
|
|
193
|
+
await writeFile(path.join(javaRoot, "Model.java"), "package com.example;\npublic class Model {}\n");
|
|
194
|
+
|
|
195
|
+
await buildGraph(root);
|
|
196
|
+
const graph = await loadGraph(root);
|
|
197
|
+
const edge = graph.edges.find(
|
|
198
|
+
(item) =>
|
|
199
|
+
item.from === "src/main/java/com/example/Consumer.java" &&
|
|
200
|
+
item.to === "src/main/java/com/example/Model.java",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(edge).toBeDefined();
|
|
204
|
+
expect(edge?.importKind).toBe("unknown-static");
|
|
205
|
+
});
|
package/src/gdgraph/query.ts
CHANGED
|
@@ -61,7 +61,12 @@ export function getCycles(graph: GraphData): string[][] {
|
|
|
61
61
|
adjacency.set(node.path, []);
|
|
62
62
|
}
|
|
63
63
|
for (const edge of graph.edges) {
|
|
64
|
-
|
|
64
|
+
// A `dynamic-import` (`await import()`) resolves at call time, not
|
|
65
|
+
// module-load time, so a cycle closed only through one is not the
|
|
66
|
+
// load-order cycle this query answers (P1, flow 140). Excluding it here
|
|
67
|
+
// — rather than reclassifying `edge.kind` — leaves orphans/affected
|
|
68
|
+
// untouched (AC5): both still treat the edge as a normal import.
|
|
69
|
+
if (edge.kind !== "imports" || edge.importKind === "dynamic-import") {
|
|
65
70
|
continue;
|
|
66
71
|
}
|
|
67
72
|
adjacency.get(edge.from)?.push(edge.to);
|
package/src/gdgraph/types.ts
CHANGED
|
@@ -5,12 +5,46 @@ export type GraphNode = {
|
|
|
5
5
|
language: "typescript" | "javascript" | "java" | "python" | "asset";
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
// Import classification straight from `Bun.Transpiler#scanImports` (P1
|
|
9
|
+
// remediation, flow 140). A static `import`/`export ... from` statement is a
|
|
10
|
+
// load-order dependency; `dynamic-import` (`await import()`) resolves at call
|
|
11
|
+
// time and is not. The full union mirrors every literal `scanImports` can
|
|
12
|
+
// return (bun-types `ImportKind`), so a transpiler-found edge always carries
|
|
13
|
+
// the value the transpiler actually reported — never a guess.
|
|
14
|
+
export type TranspilerImportKind =
|
|
15
|
+
| "import-statement"
|
|
16
|
+
| "require-call"
|
|
17
|
+
| "require-resolve"
|
|
18
|
+
| "dynamic-import"
|
|
19
|
+
| "import-rule"
|
|
20
|
+
| "url-token"
|
|
21
|
+
| "internal"
|
|
22
|
+
| "entry-point-run"
|
|
23
|
+
| "entry-point-build";
|
|
24
|
+
|
|
25
|
+
// A specifier found ONLY by the regex fallback (`extractImportSpecifiersFallback`
|
|
26
|
+
// in build.ts) carries no real kind from the transpiler — the fallback is a
|
|
27
|
+
// plain regex with no notion of "static" vs "dynamic". Never infer one;
|
|
28
|
+
// `UNKNOWN_IMPORT_KIND` marks it explicitly and cycle detection treats it as
|
|
29
|
+
// load-order (the pre-fix behavior), so fallback-only edges are never
|
|
30
|
+
// silently excluded from a real cycle.
|
|
31
|
+
export const UNKNOWN_IMPORT_KIND = "unknown-static" as const;
|
|
32
|
+
|
|
33
|
+
export type ImportKind = TranspilerImportKind | typeof UNKNOWN_IMPORT_KIND;
|
|
34
|
+
|
|
8
35
|
export type GraphEdge = {
|
|
9
36
|
id: string;
|
|
10
37
|
from: string;
|
|
11
38
|
to: string;
|
|
12
39
|
kind: "imports" | "asset" | "unresolved";
|
|
13
40
|
specifier: string;
|
|
41
|
+
// Provenance/kind of the specifier that produced this edge (P1, flow 140).
|
|
42
|
+
// `buildGraph()` always sets this. Optional (not required) so edge literals
|
|
43
|
+
// constructed before this field existed — test fixtures elsewhere in the
|
|
44
|
+
// repo, or graphs persisted by an older `keryx gdgraph build` — stay valid;
|
|
45
|
+
// `getCycles` treats a missing value as load-order, matching pre-fix
|
|
46
|
+
// behavior rather than crashing or silently mis-classifying.
|
|
47
|
+
importKind?: ImportKind;
|
|
14
48
|
};
|
|
15
49
|
|
|
16
50
|
export type GraphData = {
|
|
@@ -32,7 +32,11 @@ If the first line is not `STATUS: <STATUS>`, the orchestrator MUST treat the res
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
-
## The
|
|
35
|
+
## The Five Statuses
|
|
36
|
+
|
|
37
|
+
Four of them are for **skill workers** — the subagents an orchestrator dispatches
|
|
38
|
+
from `.metaproject/skills/`. The fifth, `FAILED`, belongs to a different worker
|
|
39
|
+
family and is documented at the end of this section.
|
|
36
40
|
|
|
37
41
|
### `DONE`
|
|
38
42
|
Task fully complete. All acceptance criteria met. Orchestrator can continue the pipeline.
|
|
@@ -66,6 +70,28 @@ Use when:
|
|
|
66
70
|
- The task references files or components that don't exist and no context explains them
|
|
67
71
|
- Acceptance criteria use terms not defined anywhere in the provided context
|
|
68
72
|
|
|
73
|
+
### `FAILED` — harness child workers only
|
|
74
|
+
|
|
75
|
+
**Do not emit `FAILED` as a skill worker.** A skill worker that cannot finish
|
|
76
|
+
reports `BLOCKED`; `task-implementer` maps its own internal `failed` to
|
|
77
|
+
`STATUS: BLOCKED` for exactly this reason.
|
|
78
|
+
|
|
79
|
+
`FAILED` exists in `subagent-result.schema.json` because a **different** worker
|
|
80
|
+
family uses it: external child processes launched through the harness. Their
|
|
81
|
+
`STATUS:` line is parsed by `parseChildResult` in `src/harness/child/contract.ts`
|
|
82
|
+
— which mirrors this enum as `CanonicalSubagentStatus` — and is wired into
|
|
83
|
+
production at `src/harness/extension/execute.ts`. `spawn.test.ts` pins the
|
|
84
|
+
behaviour it guarantees: a `FAILED` child disposition must reach the parent's
|
|
85
|
+
gate as a *failed* completion, **never as a false `completed`**.
|
|
86
|
+
|
|
87
|
+
So the enum has five values and this document previously described four, which
|
|
88
|
+
made `FAILED` look unreachable. It is not. It is unreachable *from a skill
|
|
89
|
+
worker*, and load-bearing for the child-process layer.
|
|
90
|
+
|
|
91
|
+
Orchestrators dispatching skill workers may therefore treat a `FAILED` reply as
|
|
92
|
+
a protocol violation and re-request. Orchestrators reading harness child results
|
|
93
|
+
must handle it as a real terminal failure.
|
|
94
|
+
|
|
69
95
|
---
|
|
70
96
|
|
|
71
97
|
## Exact Response Formats
|
|
@@ -12,7 +12,7 @@ triggers:
|
|
|
12
12
|
- "managed implementation"
|
|
13
13
|
metadata:
|
|
14
14
|
author: "MrCipherSmith"
|
|
15
|
-
version: "1.
|
|
15
|
+
version: "1.3.0"
|
|
16
16
|
category: "orchestration"
|
|
17
17
|
license: "MIT"
|
|
18
18
|
compatibility: "cursor,codex,zed,opencode,claude"
|
|
@@ -54,9 +54,10 @@ Flow state lives in `.metaproject/flows/<flow-id>/`.
|
|
|
54
54
|
|
|
55
55
|
CLI-owned files:
|
|
56
56
|
|
|
57
|
-
- `flow.json` - never edit by hand.
|
|
57
|
+
- `flow.json` - never edit by hand (read it freely; write only via the CLI).
|
|
58
58
|
- status transitions - only through `keryx flow ...`.
|
|
59
59
|
- task status - only through `keryx flow task done ...`.
|
|
60
|
+
- task attempt counts - only through `keryx flow task attempt ...`.
|
|
60
61
|
- frozen acceptance criteria changes - only through
|
|
61
62
|
`keryx flow ac update <id> --reason "<why>"`.
|
|
62
63
|
|
|
@@ -96,7 +97,57 @@ flowchart TD
|
|
|
96
97
|
|
|
97
98
|
## Phase 0: Route And Resume
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
### 0.0 State Resumption Check
|
|
101
|
+
|
|
102
|
+
The input contract accepts `mode: "resume"`; this is the procedure behind it.
|
|
103
|
+
Run it before asking the user anything, on **every** invocation — not only when
|
|
104
|
+
`mode` is `resume`. A session that restarts mid-flow remembers nothing of what
|
|
105
|
+
it already tried. The flow package does.
|
|
106
|
+
|
|
107
|
+
1. Run `keryx flow list`. Any flow whose status is `in-progress`,
|
|
108
|
+
`implemented`, `completing`, or `blocked` is an interrupted flow.
|
|
109
|
+
2. If one exists, ASK the user, with the concrete numbers, once:
|
|
110
|
+
"Found an in-flight flow `<id>` '<title>' (status `<status>`, tasks
|
|
111
|
+
`<done>/<total>`). Resume it, or start a new flow?" Never guess.
|
|
112
|
+
3. If resume:
|
|
113
|
+
1. Run `keryx flow status <id>` and read the flow package —
|
|
114
|
+
`description.md`, `plan.md`, `context.md`, `journal.md`, and the frozen
|
|
115
|
+
`acceptance-criteria.md`.
|
|
116
|
+
2. Read `.metaproject/flows/<dir>/flow.json` (read-only; it stays CLI-owned)
|
|
117
|
+
and take `tasks[].attempts.count` and `tasks[].attempts.log` for every
|
|
118
|
+
task that is not `done`. **That is the attempt count. Never count
|
|
119
|
+
attempts from your own context** — a resumed session's context starts at
|
|
120
|
+
zero while the real count does not, and a loop bound computed from zero
|
|
121
|
+
is not a bound.
|
|
122
|
+
3. Resume at the first task whose `status` is not `done`, respecting
|
|
123
|
+
`dependsOn` order.
|
|
124
|
+
4. Before dispatching a worker for that task, record the attempt:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
keryx flow task attempt <id> <Tn> --outcome started --detail "resumed after session restart"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
5. Apply the Phase 4 attempt budget against the **persisted** count. If
|
|
131
|
+
`attempts.count` for the task has already reached six, do not re-dispatch
|
|
132
|
+
the same approach: go to the re-planning step (Phase 4, PR review/fix
|
|
133
|
+
loop, step 4) and record the decision in `journal.md`.
|
|
134
|
+
6. If the flow is `blocked`, read the blocking reason from `journal.md`,
|
|
135
|
+
resolve or escalate it, then `keryx flow unblock <id>`.
|
|
136
|
+
4. If the user wants a new flow, continue at 0.1.
|
|
137
|
+
|
|
138
|
+
Record attempts as they happen, not only on resume:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
keryx flow task attempt <id> <Tn> --outcome started|failed|blocked [--detail "<what happened>"]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`attempts.count` is append-only and lives in `flow.json`. A counter that lives
|
|
145
|
+
only in the orchestrator's context resets to zero exactly when the loop bound
|
|
146
|
+
matters most, which makes it not a counter.
|
|
147
|
+
|
|
148
|
+
### 0.1 Route
|
|
149
|
+
|
|
150
|
+
1. Reuse the `keryx flow list` output from 0.0.
|
|
100
151
|
2. If an active flow obviously matches the user request, use it.
|
|
101
152
|
3. If multiple active flows could match, ask one concise question.
|
|
102
153
|
4. If no flow exists and the request is multi-step, create one:
|
|
@@ -169,10 +220,28 @@ agree at all - the change could not work in production. The check had been
|
|
|
169
220
|
identified correctly and then skipped, because nothing made skipping it
|
|
170
221
|
visible.
|
|
171
222
|
|
|
172
|
-
Tasks are the mechanism
|
|
173
|
-
|
|
223
|
+
Tasks are the mechanism for this. `keryx flow complete` runs a `tasks` gate
|
|
224
|
+
over them, so an unrun verification step keeps the flow open instead of being
|
|
174
225
|
quietly dropped.
|
|
175
226
|
|
|
227
|
+
Know the gate's exact scope, because for years this file claimed a gate that
|
|
228
|
+
did not exist and 24 completed flows shipped with an open task:
|
|
229
|
+
|
|
230
|
+
- the gate is **opt-in per flow package**, keyed on `gates.tasks` in
|
|
231
|
+
`flow.json`, which `keryx flow init` writes for every flow it creates. A
|
|
232
|
+
package created before the gate landed does not carry the flag, and for it
|
|
233
|
+
the gate reports `skipped` and blocks nothing;
|
|
234
|
+
- a task fails the gate when its status is not `done`, when its disposition is
|
|
235
|
+
`failed`, or when its disposition is `skipped` with no recorded reason;
|
|
236
|
+
- to close a task as deliberately not needed, record why:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
keryx flow task done <id> <Tn> --disposition skipped --reason "<why it was not needed>"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Read the `tasks` line in the `flow complete` output. If it says `skipped`, the
|
|
243
|
+
gate did not run and the task list is yours to verify by hand.
|
|
244
|
+
|
|
176
245
|
Then freeze and start:
|
|
177
246
|
|
|
178
247
|
```bash
|
|
@@ -258,9 +327,17 @@ properly formatted `subagent-result`.
|
|
|
258
327
|
| `DONE_WITH_CONCERNS` | Accept, record every concern in `journal.md`, decide continue vs. add a fix task, then `flow task done`. Never silently drop concerns. |
|
|
259
328
|
| `NEEDS_CONTEXT` | Do not fail. Enrich `context_refs`/`files_to_read` from gdgraph/gdctx/wiki/memory, then re-dispatch the same `dispatch_id`. |
|
|
260
329
|
| `BLOCKED` | `keryx flow block <id> --reason "<worker reason>"`; resolve or escalate one concise question, then `flow unblock` and re-dispatch. |
|
|
261
|
-
| `FAILED` | Retry once with the same dispatch. If it fails again, block the flow and surface the error to the user. |
|
|
330
|
+
| `FAILED` | Emitted by harness **child** workers (`src/harness/child/contract.ts`), never by skill workers — `task-implementer` maps its own `failed` onto `BLOCKED`. Retry once with the same dispatch. If it fails again, block the flow and surface the error to the user. |
|
|
262
331
|
|
|
263
|
-
Carry `run_id`/`dispatch_id` across retries so the flow journal stays traceable
|
|
332
|
+
Carry `run_id`/`dispatch_id` across retries so the flow journal stays traceable,
|
|
333
|
+
and record every dispatch against the task's persisted counter so a session
|
|
334
|
+
restart does not reset the budget:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
keryx flow task attempt <id> <Tn> --outcome started --detail "<dispatch_id>"
|
|
338
|
+
# on a BLOCKED or unusable reply, before re-dispatching:
|
|
339
|
+
keryx flow task attempt <id> <Tn> --outcome blocked --detail "<worker reason>"
|
|
340
|
+
```
|
|
264
341
|
|
|
265
342
|
## Phase 3: Verification And Review
|
|
266
343
|
|
|
@@ -316,7 +393,10 @@ How should this flow end?
|
|
|
316
393
|
2. If findings or required check failures remain, create or update a flow fix
|
|
317
394
|
task, dispatch `task-implementer`, push the fix, and run review again.
|
|
318
395
|
3. Allow at most six review/fix attempts for the current approach. Count an
|
|
319
|
-
attempt when review/check results are available, including a clean result
|
|
396
|
+
attempt when review/check results are available, including a clean result,
|
|
397
|
+
and record it with `keryx flow task attempt <id> <Tn> --outcome ...` so the
|
|
398
|
+
count survives a session restart. Read the budget from that task's
|
|
399
|
+
`attempts.count` in `flow.json`, never from this session's memory.
|
|
320
400
|
4. If attempt six is not clean, do not blindly repeat the same loop. Enrich
|
|
321
401
|
context from the findings, affected graph, relevant wiki, and
|
|
322
402
|
health/testing artifacts; identify the likely cycle cause; choose a
|
|
@@ -531,7 +531,7 @@ Run task-1 (which creates pipeline.ts) before re-dispatching this task, or provi
|
|
|
531
531
|
- (nothing — blocked before implementation could start)
|
|
532
532
|
```
|
|
533
533
|
|
|
534
|
-
See `rules/core/subagent-status-protocol.md` for full format specification and
|
|
534
|
+
See `rules/core/subagent-status-protocol.md` for the full format specification. Four statuses are yours as a skill worker; the fifth, `FAILED`, belongs to harness child workers and you must never emit it — report `BLOCKED` instead.
|
|
535
535
|
|
|
536
536
|
---
|
|
537
537
|
|