@mrciphersmith/keryx 0.2.163 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/cli.js +87904 -56917
  2. package/dist/core.js +28418 -18708
  3. package/package.json +2 -2
  4. package/src/gdgraph/affected-report.ts +141 -0
  5. package/src/gdgraph/build.ts +170 -23
  6. package/src/gdgraph/service.ts +6 -0
  7. package/src/gdgraph/staleness.ts +253 -45
  8. package/src/gdskills/bundled/agents/codebase-navigator.md +55 -0
  9. package/src/gdskills/bundled/agents/design-advisor.md +64 -0
  10. package/src/gdskills/bundled/agents/docs-maintainer.md +56 -0
  11. package/src/gdskills/bundled/agents/end-to-end-tester.md +56 -0
  12. package/src/gdskills/bundled/agents/error-path-auditor.md +57 -0
  13. package/src/gdskills/bundled/agents/go-build-fixer.md +52 -0
  14. package/src/gdskills/bundled/agents/go-code-auditor.md +49 -0
  15. package/src/gdskills/bundled/agents/performance-auditor.md +63 -0
  16. package/src/gdskills/bundled/agents/python-build-fixer.md +52 -0
  17. package/src/gdskills/bundled/agents/python-code-auditor.md +49 -0
  18. package/src/gdskills/bundled/agents/refactoring-steward.md +61 -0
  19. package/src/gdskills/bundled/agents/security-auditor.md +62 -0
  20. package/src/gdskills/bundled/agents/test-first-driver.md +61 -0
  21. package/src/gdskills/bundled/agents/work-planner.md +62 -0
  22. package/src/gdskills/bundled/install-manifest.json +530 -0
  23. package/src/gdskills/bundled/rules/core/skill-lifecycle.mdc +29 -1
  24. package/src/gdskills/bundled/rules/core/skills-storage-workflow.mdc +2 -2
  25. package/src/gdskills/bundled/skills/review/code-style-review/SKILL.md +1 -1
  26. package/src/gdskills/bundled/skills/review/review-orchestrator/SKILL.md +74 -246
  27. package/src/gdskills/bundled/skills/review/review-orchestrator/output-contract.schema.json +19 -0
  28. package/src/gdskills/bundled/skills/review/review-orchestrator/reviewer-finding.schema.json +10 -0
  29. package/src/gdskills/bundled/skills/review/review-orchestrator/reviewer-input.schema.json +5 -0
  30. package/src/gdskills/bundled/skills/review/review-orchestrator/templates/pr-comment-backend.md +50 -0
  31. package/src/gdskills/bundled/skills/review/review-orchestrator/templates/pr-comment-frontend.md +52 -0
  32. package/src/gdskills/bundled/skills/review/review-orchestrator/templates/review-report.md +143 -0
  33. package/src/gdskills/bundled/stacks/go/agent-refs.json +3 -0
  34. package/src/gdskills/bundled/stacks/go/governance/eval.json +1745 -0
  35. package/src/gdskills/bundled/stacks/go/governance/scout.json +31 -0
  36. package/src/gdskills/bundled/stacks/go/pack.json +41 -0
  37. package/src/gdskills/bundled/stacks/go/rules/coding-style.mdc +85 -0
  38. package/src/gdskills/bundled/stacks/go/rules/patterns.mdc +65 -0
  39. package/src/gdskills/bundled/stacks/go/rules/security.mdc +73 -0
  40. package/src/gdskills/bundled/stacks/go/rules/testing.mdc +68 -0
  41. package/src/gdskills/bundled/stacks/go/skills/go-build-fix/SKILL.md +138 -0
  42. package/src/gdskills/bundled/stacks/go/skills/go-build-fix/evals.json +75 -0
  43. package/src/gdskills/bundled/stacks/go/skills/go-code-review/SKILL.md +121 -0
  44. package/src/gdskills/bundled/stacks/go/skills/go-code-review/evals.json +72 -0
  45. package/src/gdskills/bundled/stacks/go/skills/go-implementation/SKILL.md +122 -0
  46. package/src/gdskills/bundled/stacks/go/skills/go-implementation/evals.json +76 -0
  47. package/src/gdskills/bundled/stacks/go/skills/go-testing/SKILL.md +126 -0
  48. package/src/gdskills/bundled/stacks/go/skills/go-testing/evals.json +73 -0
  49. package/src/gdskills/bundled/stacks/python/agent-refs.json +3 -0
  50. package/src/gdskills/bundled/stacks/python/governance/eval.json +1758 -0
  51. package/src/gdskills/bundled/stacks/python/governance/scout.json +34 -0
  52. package/src/gdskills/bundled/stacks/python/pack.json +41 -0
  53. package/src/gdskills/bundled/stacks/python/rules/coding-style.mdc +63 -0
  54. package/src/gdskills/bundled/stacks/python/rules/patterns.mdc +88 -0
  55. package/src/gdskills/bundled/stacks/python/rules/security.mdc +84 -0
  56. package/src/gdskills/bundled/stacks/python/rules/testing.mdc +77 -0
  57. package/src/gdskills/bundled/stacks/python/skills/python-build-fix/SKILL.md +144 -0
  58. package/src/gdskills/bundled/stacks/python/skills/python-build-fix/evals.json +74 -0
  59. package/src/gdskills/bundled/stacks/python/skills/python-code-review/SKILL.md +155 -0
  60. package/src/gdskills/bundled/stacks/python/skills/python-code-review/evals.json +72 -0
  61. package/src/gdskills/bundled/stacks/python/skills/python-implementation/SKILL.md +143 -0
  62. package/src/gdskills/bundled/stacks/python/skills/python-implementation/evals.json +78 -0
  63. package/src/gdskills/bundled/stacks/python/skills/python-testing/SKILL.md +132 -0
  64. package/src/gdskills/bundled/stacks/python/skills/python-testing/evals.json +73 -0
  65. package/src/gdskills/bundled/stacks/react/agent-refs.json +4 -0
  66. package/src/gdskills/bundled/stacks/react/governance/eval.json +2188 -0
  67. package/src/gdskills/bundled/stacks/react/governance/scout.json +40 -0
  68. package/src/gdskills/bundled/stacks/react/pack.json +42 -0
  69. package/src/gdskills/bundled/stacks/react/rules/coding-style.mdc +58 -0
  70. package/src/gdskills/bundled/stacks/react/rules/patterns.mdc +79 -0
  71. package/src/gdskills/bundled/stacks/react/rules/security.mdc +70 -0
  72. package/src/gdskills/bundled/stacks/react/rules/testing.mdc +60 -0
  73. package/src/gdskills/bundled/stacks/react/skills/react-build-fix/SKILL.md +139 -0
  74. package/src/gdskills/bundled/stacks/react/skills/react-build-fix/evals.json +72 -0
  75. package/src/gdskills/bundled/stacks/react/skills/react-code-review/SKILL.md +148 -0
  76. package/src/gdskills/bundled/stacks/react/skills/react-code-review/evals.json +74 -0
  77. package/src/gdskills/bundled/stacks/react/skills/react-implementation/SKILL.md +140 -0
  78. package/src/gdskills/bundled/stacks/react/skills/react-implementation/evals.json +74 -0
  79. package/src/gdskills/bundled/stacks/react/skills/react-testing/SKILL.md +142 -0
  80. package/src/gdskills/bundled/stacks/react/skills/react-testing/evals.json +83 -0
  81. package/src/gdskills/bundled/stacks/react/skills/react-upgrade-migration/SKILL.md +155 -0
  82. package/src/gdskills/bundled/stacks/react/skills/react-upgrade-migration/evals.json +74 -0
  83. package/src/gdskills/bundled/stacks/ts-js-node/agent-refs.json +4 -0
  84. package/src/gdskills/bundled/stacks/ts-js-node/governance/eval.json +2155 -0
  85. package/src/gdskills/bundled/stacks/ts-js-node/governance/scout.json +40 -0
  86. package/src/gdskills/bundled/stacks/ts-js-node/pack.json +41 -0
  87. package/src/gdskills/bundled/stacks/ts-js-node/rules/coding-style.mdc +73 -0
  88. package/src/gdskills/bundled/stacks/ts-js-node/rules/patterns.mdc +61 -0
  89. package/src/gdskills/bundled/stacks/ts-js-node/rules/security.mdc +71 -0
  90. package/src/gdskills/bundled/stacks/ts-js-node/rules/testing.mdc +63 -0
  91. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-build-fix/SKILL.md +137 -0
  92. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-build-fix/evals.json +73 -0
  93. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-code-review/SKILL.md +124 -0
  94. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-code-review/evals.json +74 -0
  95. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-esm-migration/SKILL.md +152 -0
  96. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-esm-migration/evals.json +71 -0
  97. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-implementation/SKILL.md +127 -0
  98. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-implementation/evals.json +72 -0
  99. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-testing/SKILL.md +134 -0
  100. package/src/gdskills/bundled/stacks/ts-js-node/skills/nodejs-testing/evals.json +70 -0
@@ -1,7 +1,61 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { gitCmd, gitHead, readProvenance } from "../sync/provenance";
4
5
 
6
+ // W7 AC4 follow-up (flow 304 T8) — a real, previously-latent defect found
7
+ // while wiring up the `modified` bucket below: `gitCmd`/`gitCmdResult`
8
+ // (`../sync/provenance.ts`) both call `.trim()` on the ENTIRE stdout blob,
9
+ // not per line. `git status --porcelain=v1`'s status code for an unstaged
10
+ // content-only edit is TWO CHARACTERS WIDE, and the first is a literal space
11
+ // (" M"); when that line is the first line of the output, the whole-string
12
+ // trim silently eats that leading space — shifting every following character
13
+ // left by one, corrupting BOTH the status-letter read (`line[0]`/`line[1]`)
14
+ // and the reported path (`line.slice(3)`) for whichever file happens to sort
15
+ // first. This was invisible before this task because nothing previously
16
+ // asserted the SPECIFIC path a trigger named (only "some reason fired" —
17
+ // `line[0]` after the shift often still happened to equal the status letter
18
+ // being tested for `added`/`deleted`/`renamed`, purely by coincidence of
19
+ // which letter shifted into position 0). `gitCmd` is shared by several other
20
+ // callers and is out of this task's lane (`src/gdgraph/**`,
21
+ // `src/commands/gdgraph*.ts` only) to change; this module instead spawns
22
+ // `git status` itself and strips only the single trailing newline git always
23
+ // terminates the last line with, never the leading byte of the first one.
24
+ // F9 (fix round 1): with `core.quotePath` at its (default, on) setting, `git
25
+ // status --porcelain` octal-escapes and double-quotes any path holding a
26
+ // non-ASCII or otherwise "unusual" byte — e.g. `src/café.ts` prints as
27
+ // `"src/caf\303\251.ts"`. The line-oriented parser below used to read that
28
+ // quoted, escaped text as the literal path, so every `stat()` against it
29
+ // missed (the real file on disk has no quotes or octal escapes in its name)
30
+ // and the edit went unreported. `-z` (NUL-separated) output is never quoted
31
+ // or escaped — git prints exact bytes — which sidesteps the whole unquoting
32
+ // problem instead of implementing octal-unescaping here. It also removes the
33
+ // newline-vs-content ambiguity `-z` output has no other use for line
34
+ // splitting: an ordinary entry is `XY PATH\0`, but a rename/copy (X or Y is
35
+ // `R`/`C`) is `XY PATH\0ORIG_PATH\0` — TWO NUL-terminated fields, not one —
36
+ // which `categorizeStatusEntries` below accounts for.
37
+ function gitStatusPorcelain(cwd: string): Promise<string | null> {
38
+ return new Promise((resolve) => {
39
+ try {
40
+ const child = spawn("git", ["status", "--porcelain=v1", "-z"], { cwd, stdio: ["ignore", "pipe", "ignore"] });
41
+ let out = "";
42
+ child.stdout?.on("data", (chunk) => {
43
+ out += String(chunk);
44
+ });
45
+ child.on("error", () => resolve(null));
46
+ child.on("close", (code) => {
47
+ if (code !== 0) {
48
+ resolve(null);
49
+ return;
50
+ }
51
+ resolve(out.endsWith("\0") ? out.slice(0, -1) : out);
52
+ });
53
+ } catch {
54
+ resolve(null);
55
+ }
56
+ });
57
+ }
58
+
5
59
  // AFC-10 (flow 234, phase 2, frozen AC3): "a new commit, untracked, delete,
6
60
  // rename and config change invalidate the snapshot; an unknown target differs
7
61
  // from indexed/no-edges; a git error never becomes fresh."
@@ -25,14 +79,13 @@ import { gitCmd, gitHead, readProvenance } from "../sync/provenance";
25
79
  // on every commit/checkout/branch-switch, unlike
26
80
  // `.git/HEAD`'s own — when no provenance was
27
81
  // recorded for this build).
28
- // - untracked/delete/rename: `git status --porcelain=v1`, categorized by
29
- // status letter. A plain in-place content edit
30
- // (` M`/`M `) is deliberately NOT a trigger here —
31
- // the module contract (`modules/gdgraph.md`,
32
- // "Freshness & Refresh") only promises the
33
- // file-level graph goes stale when the file SET
34
- // moves; an edit with an unchanged import set
35
- // leaves the file-level graph correct.
82
+ // - untracked/delete/rename/modify: `git status --porcelain=v1`, categorized
83
+ // by status letter. A plain in-place content edit
84
+ // (` M`/`M `) IS a trigger (W7 AC4 follow-up, flow
85
+ // 304 T8) — but only when the file's own mtime
86
+ // postdates the build, so an edit already on disk
87
+ // when `gdgraph build` ran (already reflected in
88
+ // the graph) does not false-stale it.
36
89
  // - config change: `.metaproject/gdgraph.config.json`'s mtime vs
37
90
  // `nodes.jsonl`'s. No separate baseline write is
38
91
  // needed for this: the config file only *has* a
@@ -62,7 +115,8 @@ function gdgraphConfigPath(cwd: string): string {
62
115
  // Categorize `git status --porcelain=v1` lines. Each line is exactly two
63
116
  // status characters (index status, worktree status) followed by a space and
64
117
  // the path (renames add " -> newPath"). A pure content modify (` M`/`M `/`MM`)
65
- // intentionally does not set any of these three — see the module doc above.
118
+ // goes into its own `modified` bucket — see the module doc above and the
119
+ // caller's mtime gate.
66
120
  //
67
121
  // Lines under `.metaproject/` are skipped entirely: that tree holds gdgraph's
68
122
  // OWN generated bookkeeping (`.provenance.json`, `artifacts/*`), which the
@@ -79,39 +133,118 @@ function gdgraphConfigPath(cwd: string): string {
79
133
  // project root IS the git root; `rootPrefix` (from `git rev-parse
80
134
  // --show-prefix`, empty at the git root) is prepended so the same exclusion
81
135
  // matches at both.
82
- function categorizeStatusLines(
83
- porcelain: string,
136
+ // W7 AC4 ("a reason naming the changed file"): the trigger buckets carry the
137
+ // actual paths that tripped them, not just a boolean — a reader gets "which
138
+ // file", not only "some file". `nameFiles` below turns a bucket into the
139
+ // reason string, bounded the same way this codebase bounds every other
140
+ // unbounded listing (a handful named, "+N more" beyond that).
141
+ //
142
+ // W7 AC4 follow-up (flow 304 T8): a pure content edit (` M`/`M `/`MM`) is now
143
+ // its OWN bucket (`modified`) instead of being dropped — see the caller,
144
+ // which promotes it to a trigger only when the file's mtime postdates the
145
+ // build (a content edit already present at build time is already reflected
146
+ // in the graph and must not false-stale it).
147
+ //
148
+ // F9 (fix round 1): entries come in NUL-separated (`-z`) from
149
+ // `gitStatusPorcelain`, never quoted/escaped, so there is no quote-stripping
150
+ // left to do here — the previous `.replace(/^"|"$/g, "")` existed only to
151
+ // undo the quoting `-z` never produces in the first place. A rename/copy
152
+ // entry (X or Y is `R`/`C`) is TWO consecutive NUL-terminated fields — the
153
+ // new path, then the original path — not one "old -> new" line; `entries[i +
154
+ // 1]` below consumes that second field so it is never misread as its own
155
+ // unrelated status entry.
156
+ function categorizeStatusEntries(
157
+ entries: string[],
84
158
  rootPrefix: string,
85
- ): { added: boolean; deleted: boolean; renamed: boolean } {
86
- const lines = porcelain.split("\n").filter((line) => line.length >= 2);
159
+ ): { added: string[]; deleted: string[]; renamed: string[]; modified: string[] } {
87
160
  const metaprojectPrefix = `${rootPrefix}.metaproject/`;
88
- let added = false;
89
- let deleted = false;
90
- let renamed = false;
91
- for (const line of lines) {
92
- const indexStatus = line[0];
93
- const worktreeStatus = line[1];
94
- const rawPath = line.slice(3);
95
- // For a rename, porcelain prints "old -> new"; check the side(s) that
96
- // matter for the metaproject exclusion (either is enough to skip a pure
97
- // internal-bookkeeping rename, which does not occur in practice anyway).
98
- const isMetaprojectPath = rawPath
99
- .split(" -> ")
100
- .some((candidate) => candidate.replace(/^"|"$/g, "").startsWith(metaprojectPrefix));
161
+ const added: string[] = [];
162
+ const deleted: string[] = [];
163
+ const renamed: string[] = [];
164
+ const modified: string[] = [];
165
+ for (let i = 0; i < entries.length; i += 1) {
166
+ const entry = entries[i] ?? "";
167
+ if (entry.length < 2) {
168
+ continue;
169
+ }
170
+ const indexStatus = entry[0];
171
+ const worktreeStatus = entry[1];
172
+ const rawPath = entry.slice(3);
173
+ // Renames AND copies (X or Y is `R`/`C`) both carry a second, orig-path
174
+ // field in `-z` output — it must be consumed here regardless of which
175
+ // bucket (if any) the entry itself lands in below, or the next loop
176
+ // iteration would misparse that orig-path field as its own status entry.
177
+ const hasOrigPathField =
178
+ indexStatus === "R" || worktreeStatus === "R" || indexStatus === "C" || worktreeStatus === "C";
179
+ const origPath = hasOrigPathField ? entries[i + 1] ?? null : null;
180
+ if (hasOrigPathField) {
181
+ i += 1;
182
+ }
183
+ const isMetaprojectPath = [rawPath, origPath]
184
+ .filter((candidate): candidate is string => candidate !== null)
185
+ .some((candidate) => candidate.startsWith(metaprojectPrefix));
101
186
  if (isMetaprojectPath) {
102
187
  continue;
103
188
  }
104
189
  if (indexStatus === "?" || worktreeStatus === "?" || indexStatus === "A") {
105
- added = true;
190
+ added.push(rawPath);
191
+ continue;
106
192
  }
107
193
  if (indexStatus === "D" || worktreeStatus === "D") {
108
- deleted = true;
194
+ deleted.push(rawPath);
195
+ continue;
109
196
  }
110
197
  if (indexStatus === "R" || worktreeStatus === "R") {
111
- renamed = true;
198
+ renamed.push(rawPath);
199
+ continue;
200
+ }
201
+ if (indexStatus === "M" || worktreeStatus === "M") {
202
+ modified.push(rawPath);
112
203
  }
113
204
  }
114
- return { added, deleted, renamed };
205
+ return { added, deleted, renamed, modified };
206
+ }
207
+
208
+ const REASON_FILE_DISPLAY_LIMIT = 5;
209
+
210
+ function nameFiles(message: string, files: string[]): string {
211
+ if (files.length === 0) {
212
+ return message;
213
+ }
214
+ const shown = files.slice(0, REASON_FILE_DISPLAY_LIMIT).join(", ");
215
+ const overflow = files.length > REASON_FILE_DISPLAY_LIMIT
216
+ ? ` (+${files.length - REASON_FILE_DISPLAY_LIMIT} more)`
217
+ : "";
218
+ return `${message}: ${shown}${overflow}`;
219
+ }
220
+
221
+ // W7 AC4 follow-up: when `provenance.commit !== head.commit`, name which
222
+ // file(s) actually moved between the two, the same way the working-tree
223
+ // triggers already do — a caller told "HEAD moved" without knowing which
224
+ // files still has to run a full diff itself to find out. One extra git call,
225
+ // gated behind the already-uncommon "commit moved" branch (never on the
226
+ // common clean/fresh path), so this stays within the module's own
227
+ // cheap-probe budget.
228
+ async function committedChangedFiles(
229
+ cwd: string,
230
+ fromCommit: string,
231
+ toCommit: string,
232
+ rootPrefix: string,
233
+ ): Promise<string[]> {
234
+ // F9: `-z` here too — `--name-only` quotes/octal-escapes an unusual path
235
+ // (e.g. `src/café.ts` → `"src/caf\303\251.ts"`) exactly like `git status`
236
+ // does, which fed a mismatched, unstat-able path into `nameFiles` for the
237
+ // "HEAD moved" reason. `--name-only -z` lists only the destination path per
238
+ // entry even for a rename (unlike `git status -z`, which pairs it with an
239
+ // orig-path field), so this stays a plain NUL-split with no pairing logic.
240
+ const diff = await gitCmd(cwd, ["diff", "--name-only", "-z", fromCommit, toCommit]);
241
+ if (diff === null) {
242
+ return [];
243
+ }
244
+ const metaprojectPrefix = `${rootPrefix}.metaproject/`;
245
+ return diff
246
+ .split("\0")
247
+ .filter((entry) => entry.length > 0 && !entry.startsWith(metaprojectPrefix));
115
248
  }
116
249
 
117
250
  /**
@@ -149,7 +282,28 @@ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck>
149
282
  // provenance is honestly advanced, an ADVANCING mismatch here really does
150
283
  // mean the graph predates HEAD, and this is exactly the check the wiki gate
151
284
  // (`../wiki/staleness.ts`) needs to keep trusting for that.
285
+ // W7 AC4 (PART A item 3): a graph that DID record provenance and then had
286
+ // `.provenance.json` deleted (or one that was never recorded at all) has no
287
+ // reliable "did the commit move" signal any more — only the best-effort
288
+ // `.git/logs/HEAD` mtime fallback below, which is silent whenever the build
289
+ // happened to run after the last commit (the common, unremarkable case).
290
+ // Before this fix, that silence made the whole check return `fresh` with no
291
+ // trace that the one strong freshness signal (a recorded baseline commit)
292
+ // was missing — exactly the "silently reads as confirmed-current" outcome
293
+ // this module's own contract says a check must never produce. `provenanceMissing`
294
+ // is set whenever there is nothing to compare HEAD against; if nothing else
295
+ // below turns up a concrete reason, the result downgrades from `fresh` to
296
+ // `unknown` (a weaker claim — "not verified", not "confirmed stale") rather
297
+ // than staying silently fresh.
298
+ let provenanceMissing = false;
152
299
  const head = await gitHead(cwd);
300
+ // T19 finding 3: `--show-prefix` is the project root's path relative to the
301
+ // git root (empty string AT the git root) — needed both here (to scope a
302
+ // commit-range diff to real source, excluding the graph's own bookkeeping)
303
+ // and below (the working-tree `.metaproject/` exclusion). Fetched once and
304
+ // reused, so this stays within the module's stated "one or two git calls"
305
+ // budget rather than asking twice.
306
+ const rootPrefix = head !== null ? ((await gitCmd(cwd, ["rev-parse", "--show-prefix"])) ?? "") : "";
153
307
  if (head === null) {
154
308
  gitFailed = true;
155
309
  reasons.push("git rev-parse HEAD failed (not a git repository, or git is unavailable)");
@@ -157,11 +311,16 @@ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck>
157
311
  const provenance = await readProvenance(cwd, "gdgraph");
158
312
  if (provenance) {
159
313
  if (provenance.commit !== head.commit) {
314
+ const changedFiles = await committedChangedFiles(cwd, provenance.commit, head.commit, rootPrefix);
160
315
  reasons.push(
161
- `HEAD moved since the graph was built (built at ${provenance.commit.slice(0, 12)}, now ${head.commit.slice(0, 12)})`,
316
+ nameFiles(
317
+ `HEAD moved since the graph was built (built at ${provenance.commit.slice(0, 12)}, now ${head.commit.slice(0, 12)})`,
318
+ changedFiles,
319
+ ),
162
320
  );
163
321
  }
164
322
  } else {
323
+ provenanceMissing = true;
165
324
  // No recorded build provenance for this graph. Fall back to the
166
325
  // reflog's mtime: unlike `.git/HEAD` (a symbolic ref whose content is
167
326
  // usually just "ref: refs/heads/<branch>" and does not change on an
@@ -176,24 +335,57 @@ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck>
176
335
  }
177
336
  }
178
337
 
179
- // --- untracked / deleted / renamed ----------------------------------------
180
- const porcelain = await gitCmd(cwd, ["status", "--porcelain=v1"]);
338
+ // --- untracked / deleted / renamed / modified ------------------------------
339
+ const porcelain = await gitStatusPorcelain(cwd);
181
340
  if (porcelain === null) {
182
341
  gitFailed = true;
183
342
  reasons.push("git status failed");
184
343
  } else {
185
- // T19 finding 3: `--show-prefix` is the project root's path relative to
186
- // the git root (empty string AT the git root, "packages/proj/" style
187
- // below it) — exactly the prefix `git status --porcelain`'s repo-root-
344
+ // `rootPrefix` (fetched once, above) is the project root's path relative
345
+ // to the git root — exactly what `git status --porcelain`'s repo-root-
188
346
  // relative paths need for the `.metaproject/` exclusion to match
189
- // regardless of where the project root sits. A failed/non-git lookup
190
- // (already caught above by the HEAD/status checks) falls back to "",
191
- // preserving today's at-the-root behavior rather than under-excluding.
192
- const rootPrefix = (await gitCmd(cwd, ["rev-parse", "--show-prefix"])) ?? "";
193
- const { added, deleted, renamed } = categorizeStatusLines(porcelain, rootPrefix);
194
- if (added) reasons.push("an untracked or newly added file exists in the working tree");
195
- if (deleted) reasons.push("a tracked file was deleted in the working tree");
196
- if (renamed) reasons.push("a file was renamed (staged) in the working tree");
347
+ // regardless of where the project root sits (T19 finding 3).
348
+ // `-z` output is NUL-separated (never newline-separated, and a filename
349
+ // can itself contain "\n"), so entries are split on "\0", not "\n".
350
+ const entries = porcelain.split("\0").filter((entry) => entry.length > 0);
351
+ const { added, deleted, renamed, modified } = categorizeStatusEntries(entries, rootPrefix);
352
+ if (added.length > 0) {
353
+ reasons.push(nameFiles("an untracked or newly added file exists in the working tree", added));
354
+ }
355
+ if (deleted.length > 0) {
356
+ reasons.push(nameFiles("a tracked file was deleted in the working tree", deleted));
357
+ }
358
+ if (renamed.length > 0) {
359
+ reasons.push(nameFiles("a file was renamed (staged) in the working tree", renamed));
360
+ }
361
+ // W7 AC4 follow-up (flow 304 T8): a pure content edit is a trigger too,
362
+ // but ONLY when the file's own mtime postdates the graph's build — an
363
+ // edit already on disk when `gdgraph build` ran is already reflected in
364
+ // the graph; re-flagging it would false-stale every build whose source
365
+ // tree was not pristine (i.e. nearly all of them). `nodesStat.mtimeMs` is
366
+ // this module's existing build-time proxy (already used for the config-
367
+ // change check below), reused here rather than introducing a second
368
+ // "when was it built" concept.
369
+ if (modified.length > 0) {
370
+ const postBuildEdits: string[] = [];
371
+ for (const displayPath of modified) {
372
+ const projectRelative = rootPrefix && displayPath.startsWith(rootPrefix)
373
+ ? displayPath.slice(rootPrefix.length)
374
+ : rootPrefix
375
+ ? null // outside this project's subtree in the repo — not ours to report
376
+ : displayPath;
377
+ if (projectRelative === null) {
378
+ continue;
379
+ }
380
+ const fileStat = await stat(path.join(cwd, projectRelative)).catch(() => null);
381
+ if (fileStat && fileStat.mtimeMs > nodesStat.mtimeMs) {
382
+ postBuildEdits.push(displayPath);
383
+ }
384
+ }
385
+ if (postBuildEdits.length > 0) {
386
+ reasons.push(nameFiles("a tracked file was modified since the graph was built", postBuildEdits));
387
+ }
388
+ }
197
389
  }
198
390
 
199
391
  // --- config changed ---------------------------------------------------------
@@ -208,7 +400,23 @@ export async function checkGraphStaleness(cwd: string): Promise<StalenessCheck>
208
400
  // either, since we could not fully verify either way.
209
401
  return { status: "unknown", reasons };
210
402
  }
211
- return reasons.length > 0 ? { status: "stale", reasons } : { status: "fresh", reasons: [] };
403
+ if (reasons.length > 0) {
404
+ return { status: "stale", reasons };
405
+ }
406
+ if (provenanceMissing) {
407
+ // No concrete trigger fired, but there was also no recorded build
408
+ // baseline to check the strongest signal (the commit) against — the
409
+ // mtime fallback is a best-effort proxy, not confirmation. Reported as
410
+ // `unknown`, not `fresh`: freshness genuinely was not established here,
411
+ // it just was not disproven either.
412
+ return {
413
+ status: "unknown",
414
+ reasons: [
415
+ "no build provenance recorded (.provenance.json missing) — HEAD movement since the last build could not be fully verified, only inferred from .git/logs/HEAD mtime",
416
+ ],
417
+ };
418
+ }
419
+ return { status: "fresh", reasons: [] };
212
420
  }
213
421
 
214
422
  // Flow 237 T11 (F4): the boolean wrapper `graphMaybeStale` used to live here.
@@ -0,0 +1,55 @@
1
+ ---
2
+ schema_version: 1
3
+ name: codebase-navigator
4
+ description: "Finds code, symbols, cross-references, and file relationships fast, and reports back exact paths and line ranges. Dispatched for a narrow, bounded lookup — where something is defined, what calls it, which files match a pattern — not for design review or open-ended analysis."
5
+ role: >
6
+ A fast, literal-minded search agent that returns exact file paths and line
7
+ numbers instead of paraphrased summaries, and that says plainly when a
8
+ search came back empty instead of stretching a partial match into an answer.
9
+ tools:
10
+ - read_file
11
+ - list_dir
12
+ - get_cwd
13
+ - search_code
14
+ - graph_affected
15
+ model_tier: light
16
+ policy_profile: read-only
17
+ output_contract: subagent-result
18
+ isolation: none
19
+ origin:
20
+ kind: authored
21
+ ---
22
+
23
+ # Codebase Navigator
24
+
25
+ ## Scope
26
+
27
+ Narrow, bounded lookups only: find a symbol's definition, find every caller of
28
+ a function, find files matching a naming pattern, find where a config value
29
+ is set. Never edits files. Never expands a lookup into a design opinion or a
30
+ review — if the caller wanted that, they dispatched the wrong agent.
31
+
32
+ ## Procedure
33
+
34
+ 1. Parse the request into a concrete search target: a symbol name, a file
35
+ pattern, or a relationship ("what depends on X").
36
+ 2. Search code with `search_code` (`keryx ctx rg` semantics); use
37
+ `graph_affected` (`keryx gdgraph affected`) when the question is about
38
+ relationships or blast radius rather than a plain text match.
39
+ 3. Open only the specific files the search points at with `read_file` to
40
+ confirm the match and pull the exact line range — never open a whole
41
+ directory tree speculatively.
42
+ 4. If the first search comes back empty, try one or two alternate spellings
43
+ or naming conventions before reporting nothing found.
44
+ 5. Stop as soon as the target is located; do not continue exploring beyond
45
+ the asked question.
46
+
47
+ ## Report
48
+
49
+ Findings section: for each match, the file path, line number or range, and a
50
+ one-line quote or description of what is there. State explicitly when a
51
+ search found nothing rather than substituting a near-miss.
52
+
53
+ The reply's first line is `STATUS: DONE|DONE_WITH_CONCERNS|NEEDS_CONTEXT|BLOCKED`
54
+ per the subagent-result contract. Use `NEEDS_CONTEXT` when the search target
55
+ is too ambiguous to resolve into a concrete query.
@@ -0,0 +1,64 @@
1
+ ---
2
+ schema_version: 1
3
+ name: design-advisor
4
+ description: "Weighs structural tradeoffs for a proposed change before code is written: module boundaries, dependency direction, data flow, coupling, and long-term maintainability cost. Dispatched ahead of a non-trivial implementation, when a plan needs a design opinion before it is built, or when two or more structural approaches need comparing on their tradeoffs rather than their surface syntax."
5
+ role: >
6
+ A senior systems designer who reads a codebase's existing boundaries before
7
+ proposing any change to them, favors the option with the smaller blast
8
+ radius when tradeoffs are close, and states assumptions and risks explicitly
9
+ rather than presenting a single design as the only one considered.
10
+ tools:
11
+ - read_file
12
+ - list_dir
13
+ - get_cwd
14
+ - search_code
15
+ - graph_affected
16
+ - memory_search
17
+ - web_search
18
+ model_tier: deep
19
+ policy_profile: read-only
20
+ skills:
21
+ - brainstorm
22
+ output_contract: subagent-result
23
+ isolation: none
24
+ origin:
25
+ kind: authored
26
+ ---
27
+
28
+ # Design Advisor
29
+
30
+ ## Scope
31
+
32
+ Design-level reasoning only. Never edits files. Never invents a design from
33
+ memory when the codebase already shows a convention — read it first.
34
+
35
+ ## Procedure
36
+
37
+ 1. Establish the actual question: what is changing, what must not change, and
38
+ what the caller already decided versus what is still open.
39
+ 2. Locate the current shape of the affected area with `graph_affected` and
40
+ `search_code` (via `keryx gdgraph affected` / `keryx ctx rg` semantics)
41
+ before proposing anything — a design that ignores an existing convention is
42
+ not a tradeoff, it is a miss.
43
+ 3. Check `memory_search` for prior decisions or constraints already recorded
44
+ for this area; a design that repeats a rejected approach needs a reason
45
+ why this time is different.
46
+ 4. Enumerate the realistic options (rarely more than three), each with: what
47
+ it costs to build, what it costs to change later, what it couples to, and
48
+ what breaks if the assumption behind it turns out wrong.
49
+ 5. Recommend one option. State the recommendation before the reasoning, then
50
+ the reasoning, then the rejected alternatives and why each was set aside.
51
+ 6. Name open questions that only the caller or a human can resolve — do not
52
+ guess past a genuine unknown.
53
+
54
+ ## Report
55
+
56
+ Findings section: recommended design, structural risks, affected modules
57
+ (with paths), rejected alternatives and why, open questions. No code changes
58
+ are ever part of the report.
59
+
60
+ The reply's first line is `STATUS: DONE|DONE_WITH_CONCERNS|NEEDS_CONTEXT|BLOCKED`
61
+ per the subagent-result contract. Use `DONE_WITH_CONCERNS` when a design is
62
+ recommended but carries a real, named risk; `NEEDS_CONTEXT` when the question
63
+ cannot be answered without information only the caller holds; `BLOCKED` only
64
+ when the affected area could not be read at all.
@@ -0,0 +1,56 @@
1
+ ---
2
+ schema_version: 1
3
+ name: docs-maintainer
4
+ description: "Reconciles documentation and code comments with a landed code change: updates README sections, docstrings, and inline comments that describe behavior the change altered. Dispatched once an implementation change is in place and its accompanying docs or comments need to catch up, not for authoring new standalone documentation."
5
+ role: >
6
+ A precise technical editor who updates only the documentation and comments
7
+ that describe behavior a given change actually altered, never rewrites
8
+ unrelated prose while in the area, and flags a doc claim it cannot verify
9
+ against the current code instead of guessing.
10
+ tools:
11
+ - read_file
12
+ - list_dir
13
+ - get_cwd
14
+ - search_code
15
+ - apply_patch
16
+ model_tier: standard
17
+ policy_profile: workspace-write
18
+ output_contract: subagent-result
19
+ isolation: none
20
+ origin:
21
+ kind: authored
22
+ ---
23
+
24
+ # Docs Maintainer
25
+
26
+ ## Scope
27
+
28
+ Only documentation and comments describing behavior the given change altered.
29
+ No new documentation sections beyond what the change requires, no unrelated
30
+ prose cleanup, no code behavior changes.
31
+
32
+ ## Procedure
33
+
34
+ 1. Read the code change (diff or named files) and list exactly which
35
+ behaviors, signatures, or configuration it altered.
36
+ 2. Use `search_code` to find every doc file, README section, docstring, and
37
+ inline comment that references the altered behavior — a doc update that
38
+ only touches the file next to the code misses a README describing the
39
+ same thing elsewhere.
40
+ 3. For each reference found, read it with `read_file` and compare it against
41
+ the new behavior; update only the parts that are now inaccurate or
42
+ incomplete.
43
+ 4. Keep edits minimal and local — fix the sentence that is wrong, do not
44
+ restructure the surrounding document.
45
+ 5. When a doc claim cannot be confirmed against the current code (an
46
+ external behavior not visible from this change alone), leave it and note
47
+ it in the report rather than guessing at a rewrite.
48
+
49
+ ## Report
50
+
51
+ Findings section: changed files, what was updated in each and why, any doc
52
+ claim left unverified.
53
+
54
+ The reply's first line is `STATUS: DONE|DONE_WITH_CONCERNS|NEEDS_CONTEXT|BLOCKED`
55
+ per the subagent-result contract. Use `NEEDS_CONTEXT` when the code change
56
+ itself was not enough information to know what documentation it affects.
@@ -0,0 +1,56 @@
1
+ ---
2
+ schema_version: 1
3
+ name: end-to-end-tester
4
+ description: "Drives an end-to-end test pass for a named flow or the full suite, and reports pass/fail results with failure detail. Dispatched to execute and report on existing end-to-end tests, not to author new ones or to fix the underlying application code."
5
+ role: >
6
+ A methodical test-execution operator who runs the exact commands the
7
+ project already defines for its end-to-end suite, captures failure output
8
+ precisely rather than paraphrasing it, and distinguishes a flaky failure
9
+ from a reproducible one by re-running before reporting either way.
10
+ tools:
11
+ - read_file
12
+ - list_dir
13
+ - get_cwd
14
+ - search_code
15
+ - shell_exec
16
+ model_tier: standard
17
+ policy_profile: workspace-write
18
+ output_contract: subagent-result
19
+ isolation: none
20
+ origin:
21
+ kind: authored
22
+ ---
23
+
24
+ # End-to-End Tester
25
+
26
+ ## Scope
27
+
28
+ Execution and reporting only. Runs the project's own existing end-to-end
29
+ tests and reports the outcome; does not write new tests and does not fix
30
+ application code — a failure is a finding to report, not a bug to patch.
31
+
32
+ ## Procedure
33
+
34
+ 1. Locate the project's end-to-end test entry point (`search_code` for the
35
+ test runner config, or `keryx test related` conventions) rather than
36
+ guessing a command.
37
+ 2. Confirm the target scope: a named flow/spec, or the full suite, exactly as
38
+ the dispatch specified — do not silently narrow or widen it.
39
+ 3. Run the tests via `shell_exec` and capture the full pass/fail output.
40
+ 4. For any failure, re-run just that case once before reporting, to tell a
41
+ flaky result from a reproducible one; report both outcomes if they differ.
42
+ 5. For a reproducible failure, read the relevant test and application code
43
+ with `read_file` far enough to describe what broke, without changing
44
+ anything.
45
+ 6. Stop after the target scope has been run and results captured — do not
46
+ chase a failure into an unrelated part of the suite.
47
+
48
+ ## Report
49
+
50
+ Findings section: command run, overall pass/fail counts, per-failure detail
51
+ (test name, error output, reproducible vs flaky), and, where evident, the
52
+ likely area of the codebase responsible.
53
+
54
+ The reply's first line is `STATUS: DONE|DONE_WITH_CONCERNS|NEEDS_CONTEXT|BLOCKED`
55
+ per the subagent-result contract. Use `DONE_WITH_CONCERNS` when the suite ran
56
+ but has failures; `BLOCKED` when the suite could not be run at all.