@infinitedusky/indusk-mcp 1.44.0 → 1.44.2

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 (55) hide show
  1. package/admin/.next/BUILD_ID +1 -1
  2. package/admin/.next/build-manifest.json +3 -3
  3. package/admin/.next/fallback-build-manifest.json +3 -3
  4. package/admin/.next/prerender-manifest.json +3 -3
  5. package/admin/.next/server/app/_global-error.html +1 -1
  6. package/admin/.next/server/app/_global-error.rsc +1 -1
  7. package/admin/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/admin/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/admin/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/admin/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/admin/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/admin/.next/server/middleware-build-manifest.js +3 -3
  13. package/admin/.next/server/pages/500.html +1 -1
  14. package/admin/.next/server/server-reference-manifest.js +1 -1
  15. package/admin/.next/server/server-reference-manifest.json +1 -1
  16. package/admin/.next/trace +1 -1
  17. package/admin/.next/trace-build +1 -1
  18. package/dist/bin/commands/agent.js +3 -3
  19. package/dist/bin/commands/extensions.d.ts +11 -0
  20. package/dist/bin/commands/extensions.js +10 -0
  21. package/dist/bin/commands/run.js +21 -0
  22. package/dist/bin/commands/update.js +18 -14
  23. package/dist/bin/commands/workbench.d.ts +0 -8
  24. package/dist/bin/commands/workbench.js +28 -10
  25. package/dist/bin/commands/worktree.d.ts +10 -0
  26. package/dist/bin/commands/worktree.js +12 -5
  27. package/dist/lib/cleanup/oversized.js +16 -4
  28. package/dist/lib/eval/evaluator-runner.d.ts +3 -2
  29. package/dist/lib/stray-state-audit.js +5 -2
  30. package/dist/lib/trajectory/state-ops.d.ts +0 -6
  31. package/dist/lib/trajectory/state-ops.js +0 -15
  32. package/dist/lib/verify/roots.js +5 -2
  33. package/dist/lib/worktree/layout.js +9 -3
  34. package/dist/lib/worktree/repos.d.ts +10 -1
  35. package/dist/lib/worktree/repos.js +13 -2
  36. package/dist/lib/worktree/shareable.d.ts +3 -1
  37. package/dist/lib/worktree/shareable.js +53 -5
  38. package/extensions/worktree/hooks/on_enable.sh +17 -4
  39. package/extensions/worktree/manifest.json +1 -0
  40. package/extensions/worktree/scripts/lib/workbench-helpers.sh +58 -4
  41. package/extensions/worktree/scripts/preflight.sh +10 -46
  42. package/extensions/worktree/scripts/refresh-worktree.sh +18 -10
  43. package/extensions/worktree/scripts/setup-worktree.sh +11 -3
  44. package/extensions/worktree/templates/worktree-config.template.json +1 -1
  45. package/hooks/_hook-paths.js +129 -17
  46. package/hooks/_impl-phases.js +88 -0
  47. package/hooks/check-gates.js +3 -70
  48. package/hooks/eval-trigger.js +31 -5
  49. package/hooks/gate-reminder.js +92 -187
  50. package/package.json +2 -2
  51. package/skills/cleanup.md +1 -1
  52. package/skills/retrospective.md +1 -0
  53. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_buildManifest.js +0 -0
  54. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_clientMiddlewareManifest.js +0 -0
  55. /package/admin/.next/static/{4Mqj7sc33tNR898aibslD → F21UYqHRn4_drSf3X1wvn}/_ssgManifest.js +0 -0
@@ -61,55 +61,19 @@ if [[ ! -f "$CONFIG_FILE" ]]; then
61
61
  exit 1
62
62
  fi
63
63
 
64
- # Reject preflighting the trunk — preflight is a feature-branch concept.
65
- if [[ "$SLUG" == "$REPO" ]]; then
64
+ # One resolution surface — the root and every declared worktrees dir, exact
65
+ # match first, `-<slug>` suffix as fallback, ambiguity refused by name — the
66
+ # same resolver behind `wt` (workbench-trust-fixes, F5). The private root-only
67
+ # scan that stood here could not see a declared worktrees dir and carried its
68
+ # own reserved-name list, already drifted from the shared one.
69
+ WORKTREE_PATH="$(_wt_resolve_target "$SLUG")" || exit 1
70
+ TRUNK_DIR="$(_wt_resolve_trunk_dir "$REPO")" || exit 1
71
+ # Reject preflighting the trunk — preflight is a feature-branch concept. By
72
+ # resolved path: under a declared `path` the trunk's basename is not the name.
73
+ if [[ "$(cd "$WORKTREE_PATH" && pwd -P)" == "$(cd "$TRUNK_DIR" && pwd -P)" ]]; then
66
74
  echo "Error: preflight cannot target the trunk; pick a worktree slug" >&2
67
75
  exit 1
68
76
  fi
69
-
70
- # Slug resolution (single-pass against workbench root; same as wt.sh).
71
- _is_reserved_name() {
72
- case "$1" in
73
- .indusk | .claude | .vscode | .cursor | node_modules | dist | build | .git | .next | scripts | env) return 0 ;;
74
- *) return 1 ;;
75
- esac
76
- }
77
-
78
- exact_paths=()
79
- suffix_paths=()
80
- for entry in "$WORKBENCH_ROOT"/*; do
81
- [[ -d "$entry" ]] || continue
82
- name="$(basename "$entry")"
83
- _is_reserved_name "$name" && continue
84
- # Skip the trunk symlink — preflight is for feature worktrees.
85
- [[ "$name" == "$REPO" ]] && continue
86
- if [[ "$name" == "$SLUG" ]]; then
87
- exact_paths+=("$entry")
88
- elif [[ "$name" == *"-$SLUG" ]]; then
89
- suffix_paths+=("$entry")
90
- fi
91
- done
92
-
93
- candidate_paths=()
94
- if [[ ${#exact_paths[@]} -gt 0 ]]; then
95
- candidate_paths=("${exact_paths[@]}")
96
- elif [[ ${#suffix_paths[@]} -gt 0 ]]; then
97
- candidate_paths=("${suffix_paths[@]}")
98
- fi
99
-
100
- if [[ ${#candidate_paths[@]} -eq 0 ]]; then
101
- echo "Error: no worktree matching slug '$SLUG' at $WORKBENCH_ROOT" >&2
102
- exit 1
103
- fi
104
- if [[ ${#candidate_paths[@]} -gt 1 ]]; then
105
- echo "Error: multiple worktrees match slug '$SLUG':" >&2
106
- for p in "${candidate_paths[@]}"; do
107
- printf ' %s\n' "$(basename "$p")" >&2
108
- done
109
- exit 1
110
- fi
111
-
112
- WORKTREE_PATH="${candidate_paths[0]}"
113
77
  WORKTREE_NAME="$(basename "$WORKTREE_PATH")"
114
78
 
115
79
  # Read preflight[] from config. Each entry: { name, command, when? }.
@@ -36,7 +36,9 @@ export WORKBENCH_ROOT
36
36
  REPO="$(_resolve_workbench_repo "${REPO_ARG:-}")"
37
37
 
38
38
  SIBLING_PARENT="$(_read_repos_root)"
39
- CLIENT_ROOT="$SIBLING_PARENT/$REPO"
39
+ # Trunk from CONFIG (declared `path` honored) — workbench-trust-fixes, F5.
40
+ CLIENT_ROOT="$(_wt_resolve_trunk_dir "$REPO")"
41
+ CLIENT_ROOT_REAL="$(cd "$CLIENT_ROOT" && pwd -P)"
40
42
 
41
43
  CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${REPO}.json"
42
44
  if [[ ! -f "$CONFIG_FILE" ]]; then
@@ -54,8 +56,10 @@ refresh_one() {
54
56
  echo " SKIP: $slug — directory not found"
55
57
  return 0
56
58
  fi
57
- if [[ "$slug" == "$REPO" ]]; then
58
- return 0 # trunk symlink, not a worktree
59
+ # The trunk is excluded by resolved path, not by name — under a declared
60
+ # `path` its basename need not be the repo name.
61
+ if [[ "$(cd "$worktree_path" && pwd -P)" == "$CLIENT_ROOT_REAL" ]]; then
62
+ return 0 # the trunk checkout, not a worktree
59
63
  fi
60
64
  # Check this is actually a git worktree (has a .git file pointing into
61
65
  # the canonical clone's worktrees/ dir).
@@ -65,7 +69,7 @@ refresh_one() {
65
69
  fi
66
70
 
67
71
  echo ""
68
- echo "Refreshing: $slug"
72
+ echo "Refreshing: $slug ($worktree_path)"
69
73
 
70
74
  local gitdir
71
75
  gitdir="$(cd "$worktree_path" && git rev-parse --git-dir)"
@@ -180,13 +184,17 @@ refresh_one() {
180
184
  }
181
185
 
182
186
  if [[ "$ARG" == "--all" ]]; then
183
- echo "Refreshing all worktrees in $WORKBENCH_ROOT (excluding the trunk symlink)..."
184
- for d in "$WORKBENCH_ROOT"/*/; do
185
- [[ -d "$d" ]] || continue
186
- refresh_one "${d%/}"
187
- done
187
+ echo "Refreshing all worktrees in $WORKBENCH_ROOT and its declared worktrees dirs (excluding the trunk)..."
188
+ while IFS= read -r d; do
189
+ [[ -n "$d" ]] && refresh_one "$d"
190
+ done < <(_wt_list_worktree_dirs)
188
191
  else
189
- refresh_one "$WORKBENCH_ROOT/$ARG"
192
+ # One resolution surface for a slug — the root and every declared worktrees
193
+ # dir, exact match first, `-<slug>` suffix as fallback, ambiguity refused by
194
+ # name — the resolver behind `wt` (workbench-trust-fixes, F5). A slug that
195
+ # resolves to nothing is an error, not a SKIP that exits 0.
196
+ target="$(_wt_resolve_target "$ARG")" || exit 1
197
+ refresh_one "$target"
190
198
  fi
191
199
 
192
200
  echo ""
@@ -66,11 +66,19 @@ if [[ -z "$SIBLING_PARENT" ]]; then
66
66
  exit 1
67
67
  fi
68
68
 
69
- CLIENT_ROOT="$SIBLING_PARENT/$REPO"
70
- if [[ ! -d "$CLIENT_ROOT/.git" ]]; then
71
- echo "Error: $CLIENT_ROOT is not a git repo" >&2
69
+ # The trunk checkout from CONFIG — declared `path` honored — through the one
70
+ # resolver `wt` has used since 1.42.0. Building "$SIBLING_PARENT/$REPO" by
71
+ # name failed outright on any repo declared at a path (workbench-trust-fixes, F5).
72
+ CLIENT_ROOT="$(_wt_resolve_trunk_dir "$REPO")"
73
+ if [[ ! -e "$CLIENT_ROOT/.git" ]]; then
74
+ echo "Error: $CLIENT_ROOT is not a git checkout" >&2
72
75
  exit 1
73
76
  fi
77
+ # A declared worktrees location applies whether or not the TS wrapper passed
78
+ # --worktrees-dir — this script is also run directly.
79
+ if [[ -z "$WORKTREES_DIR" ]]; then
80
+ WORKTREES_DIR="$(_wt_declared_worktrees_dir "$REPO")"
81
+ fi
74
82
 
75
83
  CONFIG_FILE="$WORKBENCH_ROOT/.indusk/worktree-configs/${REPO}.json"
76
84
  if [[ ! -f "$CONFIG_FILE" ]]; then
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "../../config.schema.json",
2
+ "$schema": "./config.schema.json",
3
3
  "trunk_branch": "main",
4
4
  "base_branch": "main",
5
5
  "post_create": ["pnpm install"],
@@ -4,9 +4,15 @@
4
4
  * The 4 hooks (eval-trigger, check-catchup, check-gates, validate-impl-structure)
5
5
  * each used to carry their own copy of `findProjectRoot()` that walked up looking
6
6
  * for `.indusk/`. In workbench-shaped projects that landed at the workbench root
7
- * — which is NOT a git repo. Hooks that then tried `git rev-parse` against that
8
- * path silently bailed. The eval pipeline never fired on numero_workbench for
9
- * 2 months. See `.indusk/planning/workbench-mode-rail-integrity/`.
7
+ * — which, before 1.37.0, was not a git repo. Hooks that then tried
8
+ * `git rev-parse` against that path silently bailed. The eval pipeline never
9
+ * fired on numero_workbench for 2 months. See
10
+ * `.indusk/planning/workbench-mode-rail-integrity/`.
11
+ *
12
+ * Since versioned-workbench (1.37.0) the root IS a git repo, and the failure
13
+ * inverted: the walk-up finds the workbench and would attribute a commit to
14
+ * its history of plan documents. `resolveStateAndGitPaths` refuses that
15
+ * (workbench-trust-fixes, F2).
10
16
  *
11
17
  * The fix: hooks need TWO paths, not one.
12
18
  *
@@ -139,11 +145,11 @@ function findGitPathFromWorkbenchConfig(statePath) {
139
145
  } catch {
140
146
  return null;
141
147
  }
142
- const declared = declaredRepoNames(config);
148
+ const declared = declaredRepos(config);
143
149
  // Ambiguous: more than one repo could hold this commit. Refuse rather than
144
150
  // attribute it to whichever happens to be declared first.
145
151
  if (declared.length !== 1) return null;
146
- const candidate = resolve(statePath, declared[0]);
152
+ const candidate = resolve(statePath, declared[0].dir);
147
153
  if (!existsSync(candidate)) return null;
148
154
  try {
149
155
  return realpathSync(candidate);
@@ -153,16 +159,18 @@ function findGitPathFromWorkbenchConfig(statePath) {
153
159
  }
154
160
 
155
161
  /**
156
- * The declared repo names, singular reduced into plural.
162
+ * The declared repos, singular reduced into plural, each with the directory
163
+ * its checkout lives in relative to the workbench root.
157
164
  *
158
- * Deliberate port of `readWorkbenchRepos` in `src/lib/worktree/repos.ts` —
159
- * hooks are plain JS and cannot import the TS module. Change both together.
160
- * Name-only: the hook never needs remotes.
165
+ * Deliberate port of `readWorkbenchRepos` + `repoDir` in
166
+ * `src/lib/worktree/repos.ts` — hooks are plain JS and cannot import the TS
167
+ * module. Change both together. Name and dir only: the hook never needs
168
+ * remotes.
161
169
  *
162
170
  * @param {unknown} config
163
- * @returns {string[]}
171
+ * @returns {{ name: string, dir: string }[]}
164
172
  */
165
- function declaredRepoNames(config) {
173
+ function declaredRepos(config) {
166
174
  const worktree = config && typeof config === "object" ? config.worktree : null;
167
175
  if (!worktree || typeof worktree !== "object") return [];
168
176
  const raw = Array.isArray(worktree.repos)
@@ -171,7 +179,7 @@ function declaredRepoNames(config) {
171
179
  ? [{ name: worktree.wrapped_repo }]
172
180
  : [];
173
181
  const seen = new Set();
174
- const names = [];
182
+ const repos = [];
175
183
  for (const entry of raw) {
176
184
  if (!entry || typeof entry !== "object") continue;
177
185
  const name = entry.name;
@@ -184,9 +192,34 @@ function declaredRepoNames(config) {
184
192
  !name.includes("\\");
185
193
  if (!clean || seen.has(name)) continue;
186
194
  seen.add(name);
187
- names.push(name);
195
+ // Where the checkout lives: the declared `path`, else the name.
196
+ // Deliberate port of `repoDir` in `src/lib/worktree/repos.ts` — every
197
+ // other surface reads a repo at its declared path, and looking it up by
198
+ // name here returned null (dark) for any repo declared elsewhere (F2).
199
+ repos.push({ name, dir: usableRelPath(entry.path) ?? name });
200
+ }
201
+ return repos;
202
+ }
203
+
204
+ function declaredRepoNames(config) {
205
+ return declaredRepos(config).map((r) => r.name);
206
+ }
207
+
208
+ /** A relative path with no empty, `.` or `..` segment; else null. Port of `isUsableRelPath`. */
209
+ function usableRelPath(value) {
210
+ if (typeof value !== "string") return null;
211
+ const v = value.trim();
212
+ if (v === "" || v.startsWith("/") || v.includes("\\")) return null;
213
+ if (v.split("/").some((s) => s === "" || s === "." || s === "..")) return null;
214
+ return v;
215
+ }
216
+
217
+ function samePath(a, b) {
218
+ try {
219
+ return realpathSync(a) === realpathSync(b);
220
+ } catch {
221
+ return a === b;
188
222
  }
189
- return names;
190
223
  }
191
224
 
192
225
  /**
@@ -211,7 +244,9 @@ export function declaredReposAt(statePath) {
211
244
  * Resolve both the InDusk state path and the git path for a given cwd. Either
212
245
  * may be null:
213
246
  * - `statePath` is null if no `.indusk/` directory exists in any ancestor.
214
- * - `gitPath` is null if `cwd` is not inside any git repo.
247
+ * - `gitPath` is null if `cwd` is not inside any git repo — or if the only
248
+ * repo found is the workbench itself and the declaration cannot name one
249
+ * code repo. Then `refusal` says why, for the caller to surface.
215
250
  *
216
251
  * The two paths may be the SAME (single-repo mode) or DIFFERENT (workbench mode).
217
252
  * Callers must use the right path for the right operation:
@@ -219,11 +254,37 @@ export function declaredReposAt(statePath) {
219
254
  * - Git operations (`git rev-parse --short HEAD`, etc.) → use `gitPath`
220
255
  *
221
256
  * @param {string} cwd
222
- * @returns {{ statePath: string | null, gitPath: string | null }}
257
+ * @returns {{ statePath: string | null, gitPath: string | null, refusal: string | null, attribution: string | null }}
223
258
  */
224
259
  export function resolveStateAndGitPaths(cwd) {
225
260
  const statePath = findStatePath(cwd);
226
261
  let gitPath = findGitPathFromCwd(cwd);
262
+ let refusal = null;
263
+ let attribution = null;
264
+ // A versioned workbench root IS a git repo, so the walk-up finds it. Its
265
+ // history is plan documents, and it CAN receive commits (a checkoff, a
266
+ // brief) — so when the repo found is the workbench itself, the commit may
267
+ // have gone to it or to the code repo, and the event cannot say which
268
+ // (`event.cwd` is the session cwd, not the subprocess's). With one repo
269
+ // declared, whichever has the NEWER HEAD received the commit; a tie or a
270
+ // missing code repo prefers the code repo, the 1.31.10 behaviour
271
+ // (workbench-trust-fixes F2, refined by falsification A20 — "never the
272
+ // workbench" was the wrong invariant). With several declared, refuse. A
273
+ // flat project declares no repos and keeps gitPath === statePath.
274
+ if (
275
+ gitPath &&
276
+ statePath &&
277
+ declaredReposAt(statePath).length > 0 &&
278
+ samePath(gitPath, statePath)
279
+ ) {
280
+ const workbenchRoot = gitPath;
281
+ const codeRepo = findGitPathFromWorkbenchConfig(statePath);
282
+ if (declaredReposAt(statePath).length === 1) {
283
+ ({ gitPath, attribution } = attributeRootCommit(workbenchRoot, codeRepo));
284
+ } else {
285
+ gitPath = null;
286
+ }
287
+ }
227
288
  if (!gitPath) {
228
289
  // Workbench-mode fallback (1.31.10). When event.cwd is the workbench
229
290
  // root, `git rev-parse` against it fails because the workbench root
@@ -233,6 +294,57 @@ export function resolveStateAndGitPaths(cwd) {
233
294
  // from the workbench root (the dominant operating model on Numero
234
295
  // and any future FDE engagement).
235
296
  gitPath = findGitPathFromWorkbenchConfig(statePath);
297
+ if (!gitPath) {
298
+ const names = declaredReposAt(statePath);
299
+ if (names.length > 1) {
300
+ refusal =
301
+ `eval: a commit made from ${cwd} could belong to any of the ${names.length} repos this workbench declares (${names.join(", ")}), ` +
302
+ "and attributing it to the wrong one is indistinguishable from attributing it right. Refusing to score it. " +
303
+ "Commit from inside the repo the change lives in, or run the evaluator there.";
304
+ }
305
+ }
306
+ }
307
+ return { statePath, gitPath, refusal, attribution };
308
+ }
309
+
310
+ /**
311
+ * Which repository received a commit made from a session sitting at the
312
+ * workbench root, when exactly one code repo is declared: the one whose HEAD
313
+ * is newer. A tie, or a code repo not yet on disk, goes to the code repo — the
314
+ * 1.31.10 behaviour, and the safer error (a plan-document commit scored as
315
+ * code is odd; a code commit never scored is a gap in the rail).
316
+ *
317
+ * @param {string} workbenchRoot
318
+ * @param {string | null} codeRepo
319
+ * @returns {{ gitPath: string, attribution: string }}
320
+ */
321
+ function attributeRootCommit(workbenchRoot, codeRepo) {
322
+ if (!codeRepo) {
323
+ return {
324
+ gitPath: workbenchRoot,
325
+ attribution: "the workbench (the one declared repo is not on disk)",
326
+ };
327
+ }
328
+ if (headCommitTime(workbenchRoot) > headCommitTime(codeRepo)) {
329
+ return {
330
+ gitPath: workbenchRoot,
331
+ attribution: "the workbench (newer HEAD than the declared repo)",
332
+ };
333
+ }
334
+ return { gitPath: codeRepo, attribution: "the declared repo (newer or equal HEAD)" };
335
+ }
336
+
337
+ /** HEAD's committer timestamp (seconds), or -1 when the repo has no commits or is not one. */
338
+ function headCommitTime(dir) {
339
+ try {
340
+ const out = execFileSync("git", ["log", "-1", "--format=%ct"], {
341
+ cwd: dir,
342
+ encoding: "utf-8",
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ });
345
+ const n = Number.parseInt(out.trim(), 10);
346
+ return Number.isFinite(n) ? n : -1;
347
+ } catch {
348
+ return -1;
236
349
  }
237
- return { statePath, gitPath };
238
350
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The one hook-side walk from an impl body to its phases and checkbox items.
3
+ *
4
+ * Deliberate port of the TS phase/item walk in `src/lib/impl-parser.ts` (what
5
+ * `getPhaseCompletion` reads). Hooks are plain JS and cannot import the TS
6
+ * module, so the rule lives in two places — this file and that one — and they
7
+ * must change together.
8
+ *
9
+ * Hook-local (`_`-prefixed): imported by hooks, never registered as one, so it
10
+ * needs no settings entry — but it must exist in `.claude/hooks/` or every
11
+ * importer dies at load. `globSync("*.js")` copies it on init and update.
12
+ *
13
+ * **One definition, shared by `check-gates.js` and `gate-reminder.js`, pinned
14
+ * by A24.** Each carried its own copy of this walk until workbench-trust-fixes'
15
+ * cleanup phase; gate-reminder's was the same walk minus the item text, and it
16
+ * recognized one gate kind fewer. That is the shape `_trajectory-parser.js`
17
+ * was extracted from — two copies that had already diverged without anything
18
+ * failing — and the reason this repo extracts hook-side parsers at two rather
19
+ * than waiting for three.
20
+ *
21
+ * Phases are two sequences ordered by document position (`Test Phase N`,
22
+ * `Build Phase N`; `Phase N` means build phase N). Fenced blocks are content,
23
+ * not structure: a deferral may carry a test body with checkbox- and
24
+ * heading-shaped lines. Items under a `Forward Intelligence` heading are
25
+ * notes, not work, and are excluded.
26
+ */
27
+
28
+ import {
29
+ FORWARD_INTELLIGENCE_HEADING,
30
+ fencedLineMask,
31
+ gateHeading,
32
+ parsePhaseHeading,
33
+ } from "./_impl-headings.js";
34
+ import { stripFrontmatter } from "./_trajectory-parser.js";
35
+
36
+ const GATE_KINDS = gateHeading("(Verification|OTel|Context|Document)");
37
+
38
+ /**
39
+ * @param {string} content — an impl document, with or without frontmatter
40
+ * @returns {{ kind: "test" | "build", number: number, ordinal: number, name: string,
41
+ * items: { checked: boolean, text: string, gate: string }[] }[]}
42
+ */
43
+ export function parseImplPhases(content) {
44
+ const lines = stripFrontmatter(content).split("\n");
45
+ const fenced = fencedLineMask(lines);
46
+ const phases = [];
47
+ let currentPhase = null;
48
+ let currentGateType = "implementation";
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const line = lines[i];
51
+ if (fenced[i]) continue;
52
+ const phaseMatch = parsePhaseHeading(line);
53
+ if (phaseMatch) {
54
+ if (currentPhase) phases.push(currentPhase);
55
+ currentPhase = {
56
+ number: phaseMatch.number,
57
+ kind: phaseMatch.kind,
58
+ ordinal: phases.length,
59
+ name: phaseMatch.name,
60
+ items: [],
61
+ };
62
+ currentGateType = "implementation";
63
+ continue;
64
+ }
65
+ // [1] is the phase number, [2] the gate kind — see gateHeading().
66
+ const gateMatch = line.match(GATE_KINDS);
67
+ if (gateMatch) {
68
+ currentGateType = gateMatch[2].toLowerCase();
69
+ continue;
70
+ }
71
+ if (FORWARD_INTELLIGENCE_HEADING.test(line)) {
72
+ currentGateType = "_fi";
73
+ continue;
74
+ }
75
+ if (currentPhase && currentGateType !== "_fi") {
76
+ const itemMatch = line.match(/^-\s+\[([ x])\]\s+(.*)/);
77
+ if (itemMatch) {
78
+ currentPhase.items.push({
79
+ checked: itemMatch[1] === "x",
80
+ text: itemMatch[2].trim(),
81
+ gate: currentGateType,
82
+ });
83
+ }
84
+ }
85
+ }
86
+ if (currentPhase) phases.push(currentPhase);
87
+ return phases;
88
+ }
@@ -12,15 +12,8 @@
12
12
 
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { resolveStateAndGitPaths } from "./_hook-paths.js";
15
- import {
16
- FORWARD_INTELLIGENCE_HEADING,
17
- fencedLineMask,
18
- gateHeading,
19
- parsePhaseHeading,
20
- phaseExists,
21
- phaseOrdinal,
22
- phaseSequence,
23
- } from "./_impl-headings.js";
15
+ import { phaseExists, phaseOrdinal, phaseSequence } from "./_impl-headings.js";
16
+ import { parseImplPhases as parsePhases } from "./_impl-phases.js";
24
17
  import { parseTrajectoryFromBody, stripFrontmatter } from "./_trajectory-parser.js";
25
18
 
26
19
  // Read hook input from stdin
@@ -168,67 +161,7 @@ const WORKFLOW_GATES = Object.fromEntries(
168
161
  ]),
169
162
  );
170
163
 
171
- // Parse phases from the NEW content (after edit) and OLD content (before edit)
172
- function parsePhases(content) {
173
- // Strip frontmatter
174
- const fmMatch = content.match(/^---\n[\s\S]*?\n---\n/);
175
- const body = fmMatch ? content.slice(fmMatch[0].length) : content;
176
-
177
- const lines = body.split("\n");
178
- // Fenced blocks are content, not structure — a deferral may carry the
179
- // deferred test's body, which contains checkbox- and heading-shaped lines.
180
- const fenced = fencedLineMask(lines);
181
- const phases = [];
182
- let currentPhase = null;
183
- let currentGateType = "implementation";
184
-
185
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
186
- const line = lines[lineIndex];
187
- if (fenced[lineIndex]) continue;
188
-
189
- const phaseMatch = parsePhaseHeading(line);
190
- if (phaseMatch) {
191
- if (currentPhase) phases.push(currentPhase);
192
- currentPhase = {
193
- number: phaseMatch.number,
194
- kind: phaseMatch.kind,
195
- // Position in the document — the only thing that orders two
196
- // independently-numbered sequences.
197
- ordinal: phases.length,
198
- name: phaseMatch.name,
199
- items: [],
200
- };
201
- currentGateType = "implementation";
202
- continue;
203
- }
204
-
205
- // [1] is the phase number, [2] the gate kind — see gateHeading().
206
- const gateMatch = line.match(gateHeading("(Verification|OTel|Context|Document)"));
207
- if (gateMatch) {
208
- currentGateType = gateMatch[2].toLowerCase();
209
- continue;
210
- }
211
-
212
- // Forward intelligence — skip
213
- if (line.match(FORWARD_INTELLIGENCE_HEADING)) {
214
- currentGateType = "_fi";
215
- continue;
216
- }
217
-
218
- if (currentPhase && currentGateType !== "_fi") {
219
- const itemMatch = line.match(/^-\s+\[([ x])\]\s+(.*)/);
220
- if (itemMatch) {
221
- currentPhase.items.push({
222
- checked: itemMatch[1] === "x",
223
- text: itemMatch[2].trim(),
224
- gate: currentGateType,
225
- });
226
- }
227
- }
228
- }
229
- if (currentPhase) phases.push(currentPhase);
230
- return phases;
231
- }
164
+ // The phase/item walk is shared with gate-reminder.js — one definition, pinned by A24.
232
165
 
233
166
  /** Name a phase unambiguously: the two sequences share their digits. */
234
167
  function phaseLabel(kind, number) {
@@ -91,7 +91,12 @@ if (cliSource !== null || drainPending) {
91
91
  // early syslog calls used raw `cwd` and silently created stray `.indusk/`
92
92
  // directories inside wrapped repos — exactly the "no lingering app-level
93
93
  // state" pattern this plan is fixing.
94
- const { statePath: resolvedStatePath, gitPath } = resolveStateAndGitPaths(cwd);
94
+ const {
95
+ statePath: resolvedStatePath,
96
+ gitPath,
97
+ refusal: attributionRefusal,
98
+ attribution,
99
+ } = resolveStateAndGitPaths(cwd);
95
100
  const statePath = resolvedStatePath ?? cwd;
96
101
 
97
102
  if (cliSource !== null || drainPending) {
@@ -156,7 +161,7 @@ const evalConfig = readEvalConfig(statePath);
156
161
 
157
162
  syslog(
158
163
  statePath,
159
- `statePath: ${statePath}, gitPath: ${gitPath ?? "(none)"}, eval.enabled: ${evalConfig.enabled}`,
164
+ `statePath: ${statePath}, gitPath: ${gitPath ?? "(none)"}, eval.enabled: ${evalConfig.enabled}${attribution ? ` — attributed to ${attribution}` : ""}`,
160
165
  );
161
166
 
162
167
  // Check if eval is disabled
@@ -194,9 +199,10 @@ if (drainPending) {
194
199
  }
195
200
 
196
201
  // Get the current commit ID. Runs against gitPath, not statePath — in
197
- // workbench mode the two differ (statePath = workbench root, NOT a git repo;
198
- // gitPath = wrapped repo or worktree). Pre-1.31.7 ran against statePath
199
- // and bailed on every commit in workbench-shaped projects.
202
+ // workbench mode the two differ (statePath = workbench root, whose own git
203
+ // history is plan documents; gitPath = the declared code repo or a worktree).
204
+ // Pre-1.31.7 ran against statePath and bailed on every commit in
205
+ // workbench-shaped projects; post-1.37.0 it would have scored the workbench.
200
206
  let changeId = changeIdArg ?? undefined;
201
207
  if (!changeId && gitPath) {
202
208
  try {
@@ -212,6 +218,26 @@ if (!changeId && gitPath) {
212
218
  }
213
219
  }
214
220
  if (!changeId) {
221
+ if (!gitPath && attributionRefusal) {
222
+ // Refuse VISIBLY. system.log is where the eval rail talks to itself; a
223
+ // commit the evaluator silently declined to score looks, to the
224
+ // session, exactly like one it scored. stdout JSON is the one channel a
225
+ // PostToolUse hook has to the model at exit 0 (workbench-trust-fixes,
226
+ // F2 / A8) — and only there: in CLI and drain modes stdout is a
227
+ // terminal or a parent process, not the model (falsification A21).
228
+ syslog(statePath, `refusing to attribute — ${attributionRefusal}`);
229
+ if (cliSource === null && !drainPending) {
230
+ console.info(
231
+ JSON.stringify({
232
+ hookSpecificOutput: {
233
+ hookEventName: "PostToolUse",
234
+ additionalContext: attributionRefusal,
235
+ },
236
+ }),
237
+ );
238
+ }
239
+ process.exit(0);
240
+ }
215
241
  syslog(
216
242
  statePath,
217
243
  gitPath