@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.69",
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");
@@ -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 specifiers = extractImportSpecifiers(content, language);
115
+ const records = extractImportRecords(content, language);
115
116
 
116
- for (const specifier of specifiers) {
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
- function extractImportSpecifiers(content: string, language: string): string[] {
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
- return [...new Set([...scanned, ...fallback])].sort();
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
- function scanImportsOrEmpty(content: string): string[] {
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
- .map((entry) => entry.path)
231
- .filter((specifier): specifier is string => typeof specifier === "string" && specifier.length > 0);
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
+ });
@@ -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
- if (edge.kind !== "imports") {
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);
@@ -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 Four Statuses
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.2.0"
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
- 1. Run `keryx flow list`.
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 that already exists for this: `flow complete` gates
173
- on them, so an unrun verification step keeps the flow open instead of being
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 all four status types.
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