yadflow 3.19.0-next.2 → 4.0.0-next.3

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 (158) hide show
  1. package/CHANGELOG.md +371 -0
  2. package/README.md +160 -22
  3. package/bin/commands.mjs +46 -0
  4. package/bin/yad.mjs +506 -117
  5. package/cli/artifact-status.mjs +30 -11
  6. package/cli/capture.mjs +417 -0
  7. package/cli/checkpoint.mjs +47 -27
  8. package/cli/claims.mjs +165 -0
  9. package/cli/codeowners-command.mjs +170 -0
  10. package/cli/codeowners.mjs +397 -0
  11. package/cli/commit.mjs +13 -9
  12. package/cli/dial.mjs +183 -0
  13. package/cli/docs.mjs +88 -32
  14. package/cli/doctor.mjs +1682 -90
  15. package/cli/epic-state.mjs +3460 -211
  16. package/cli/epic.mjs +506 -0
  17. package/cli/errors.mjs +3 -0
  18. package/cli/fold.mjs +247 -0
  19. package/cli/gate-local.mjs +296 -0
  20. package/cli/gate.mjs +977 -183
  21. package/cli/history.mjs +556 -0
  22. package/cli/hook.mjs +264 -53
  23. package/cli/hubcommit.mjs +9 -19
  24. package/cli/index-command.mjs +87 -0
  25. package/cli/ledger.mjs +54 -4
  26. package/cli/lib.mjs +161 -9
  27. package/cli/manifest.mjs +376 -38
  28. package/cli/migrate.mjs +683 -52
  29. package/cli/mode.mjs +170 -0
  30. package/cli/next.mjs +365 -86
  31. package/cli/openpr.mjs +193 -34
  32. package/cli/owners.mjs +266 -0
  33. package/cli/people.mjs +657 -0
  34. package/cli/plan.mjs +466 -125
  35. package/cli/platform.mjs +109 -128
  36. package/cli/product-index.mjs +287 -0
  37. package/cli/protection.mjs +706 -0
  38. package/cli/reconcile.mjs +40 -12
  39. package/cli/repo-publish.mjs +24 -26
  40. package/cli/repo.mjs +23 -14
  41. package/cli/report.mjs +24 -14
  42. package/cli/review.mjs +19 -22
  43. package/cli/riskmap-command.mjs +289 -0
  44. package/cli/riskmap.mjs +373 -0
  45. package/cli/setup.mjs +127 -279
  46. package/cli/ship.mjs +6 -5
  47. package/cli/skill.mjs +180 -0
  48. package/cli/skip.mjs +211 -30
  49. package/cli/thread.mjs +41 -16
  50. package/cli/tidy.mjs +18 -18
  51. package/cli/update-commit.mjs +21 -21
  52. package/cli/usage.mjs +115 -109
  53. package/package.json +3 -3
  54. package/skills/sdlc/config.yaml +149 -70
  55. package/skills/sdlc/module-help.csv +24 -24
  56. package/skills/yad-analysis/SKILL.md +121 -59
  57. package/skills/yad-architecture/SKILL.md +30 -18
  58. package/skills/yad-architecture/references/contract-format.md +8 -5
  59. package/skills/yad-backfill/SKILL.md +10 -4
  60. package/skills/yad-backfill/references/backfill.md +8 -1
  61. package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
  62. package/skills/yad-change/SKILL.md +119 -42
  63. package/skills/yad-change/references/triage.md +42 -27
  64. package/skills/yad-checks/SKILL.md +68 -35
  65. package/skills/yad-checks/references/check-gates.md +310 -85
  66. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  67. package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
  68. package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
  69. package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
  70. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  71. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
  72. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  73. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  74. package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
  75. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  76. package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
  77. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  78. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
  79. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  80. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
  81. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  82. package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
  83. package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
  84. package/skills/yad-commit/SKILL.md +4 -4
  85. package/skills/yad-connect-design/SKILL.md +5 -5
  86. package/skills/yad-connect-design/references/design-context.md +1 -1
  87. package/skills/yad-connect-design/references/design-registry.md +2 -2
  88. package/skills/yad-connect-docs/SKILL.md +11 -11
  89. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  90. package/skills/yad-connect-learning/SKILL.md +5 -5
  91. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  92. package/skills/yad-connect-repos/SKILL.md +66 -45
  93. package/skills/yad-connect-repos/references/code-context.md +4 -4
  94. package/skills/yad-connect-repos/references/hub-config.md +47 -51
  95. package/skills/yad-connect-repos/references/repos-registry.md +8 -7
  96. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  97. package/skills/yad-connect-testing/SKILL.md +5 -5
  98. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  99. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  100. package/skills/yad-defects/SKILL.md +7 -7
  101. package/skills/yad-discovery/SKILL.md +131 -94
  102. package/skills/yad-discovery/references/discovery-schema.md +22 -6
  103. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  104. package/skills/yad-docs/SKILL.md +13 -8
  105. package/skills/yad-docs/references/data-mapping.md +9 -7
  106. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  107. package/skills/yad-docs-overview/SKILL.md +30 -15
  108. package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
  109. package/skills/yad-docs-sync/SKILL.md +10 -5
  110. package/skills/yad-docs-sync/references/staleness.md +8 -7
  111. package/skills/yad-engineer-review/SKILL.md +81 -17
  112. package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
  113. package/skills/yad-epic/SKILL.md +173 -92
  114. package/skills/yad-epic/references/state-schema.md +610 -94
  115. package/skills/yad-hub-bridge/SKILL.md +57 -38
  116. package/skills/yad-hub-bridge/references/bridge.md +94 -67
  117. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  118. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  119. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  120. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  121. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
  122. package/skills/yad-implement/SKILL.md +27 -13
  123. package/skills/yad-implement/references/implement-conventions.md +2 -2
  124. package/skills/yad-learn/SKILL.md +9 -9
  125. package/skills/yad-learn/references/learning-state.md +2 -2
  126. package/skills/yad-open-pr/SKILL.md +61 -26
  127. package/skills/yad-pair-review/SKILL.md +7 -5
  128. package/skills/yad-pair-review/references/session-state.md +1 -1
  129. package/skills/yad-pr-template/SKILL.md +47 -26
  130. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  131. package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
  132. package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
  133. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  134. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  135. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  136. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
  137. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
  138. package/skills/yad-reconcile/SKILL.md +3 -3
  139. package/skills/yad-report/SKILL.md +5 -5
  140. package/skills/yad-review-companion/SKILL.md +6 -3
  141. package/skills/yad-review-gate/SKILL.md +203 -93
  142. package/skills/yad-review-gate/references/gating.md +232 -54
  143. package/skills/yad-run/SKILL.md +81 -51
  144. package/skills/yad-run/references/run-loop.md +61 -39
  145. package/skills/yad-ship/SKILL.md +15 -11
  146. package/skills/yad-spec/SKILL.md +21 -6
  147. package/skills/yad-spec/references/spec-handoff.md +17 -5
  148. package/skills/yad-status/SKILL.md +115 -50
  149. package/skills/yad-stories/SKILL.md +37 -21
  150. package/skills/yad-stories/references/story-schema.md +7 -6
  151. package/skills/yad-stub/SKILL.md +58 -47
  152. package/skills/yad-sync-repos/SKILL.md +2 -2
  153. package/skills/yad-test-cases/SKILL.md +32 -23
  154. package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
  155. package/skills/yad-timeline/SKILL.md +8 -7
  156. package/skills/yad-ui/SKILL.md +41 -18
  157. package/cli/roster.mjs +0 -164
  158. package/skills/sdlc/install.sh +0 -68
package/cli/plan.mjs CHANGED
@@ -5,12 +5,13 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { err } from './errors.mjs';
7
7
  import {
8
- asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha, warn,
8
+ asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha, warn, isPlainObject,
9
9
  } from './lib.mjs';
10
10
  import {
11
- VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES, isVerifiedLedger,
12
- HOOK_WIRING, HOOK_SETTINGS, HOOK_TOOL_MATCHER, HOOK_COMMAND, HOOK_COMMAND_LEGACY,
13
- LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
11
+ VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, IDE_OPENCODE_TARGET, IDE_RECOVERY_TARGET, MODULE_CONFIG, wiringFor, PRODUCT_WIRING, PROJECT_FILES, isVerifiedLedger,
12
+ HOOK_WIRING, HOOK_ADAPTERS, CLAUDE_HOOK_ADAPTER, CAPTURE_WIRING, CAPTURE_ADAPTERS,
13
+ LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_PRODUCT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
14
+ productConfigPath,
14
15
  } from './manifest.mjs';
15
16
 
16
17
  // A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
@@ -19,12 +20,24 @@ import {
19
20
  // removed file underneath it, so a dirAction needs only its top-level dest.
20
21
  const rel = (root, dest) => path.relative(root, dest).split(path.sep).join('/');
21
22
 
22
- // status: 'ok' | 'missing' | 'outdated'. `root` is the repo the write lands in (the hub for module
23
+ // status: 'ok' | 'missing' | 'outdated'. `root` is the repo the write lands in (the Product for module
23
24
  // installs, a connected repo for its wiring); `paths` is the pathspec(s) touched, for the push stage.
25
+ // An `exec` file whose execute bit is gone is OUTDATED, not ok — its bytes are right and it cannot
26
+ // run. `chmod` lives inside `apply()`, which an `ok` action never reaches, so a content-only status
27
+ // meant `yad check --fix` had nothing to do and the mode was never restored. For a gate script that
28
+ // is a failed pipeline; for `hooks/ledger-guard*.sh` it is worse, because the harness entry pointing
29
+ // at it carries fail-open semantics, so every ledger edit is quietly PERMITTED while `yad check` and
30
+ // `yad doctor` both report the guard healthy. Reachable without anyone doing anything odd: a zip or
31
+ // tarball download, `cp` without `-p`, a restrictive umask, `core.fileMode=false`.
32
+ const isExecutable = (dest) => {
33
+ try { return !!(fs.statSync(dest).mode & 0o111); } catch { return false; }
34
+ };
24
35
  const fileAction = (scope, item, src, dest, { root, exec = false } = {}) => ({
25
36
  scope,
26
37
  item,
27
- status: !exists(dest) ? 'missing' : sameContent(src, dest) ? 'ok' : 'outdated',
38
+ status: !exists(dest) ? 'missing'
39
+ : sameContent(src, dest) && (!exec || isExecutable(dest)) ? 'ok'
40
+ : 'outdated',
28
41
  root,
29
42
  paths: root ? [rel(root, dest)] : [],
30
43
  apply: () => copyFile(src, dest, { exec }),
@@ -74,6 +87,9 @@ const wiredFileAction = (scope, item, src, dest, { root, exec = false, ledger =
74
87
  const managed = { src, dest, root };
75
88
  if (base.status !== 'outdated') return { ...base, managed };
76
89
  const recorded = ledger[rel(root, dest)];
90
+ // Compares CONTENT. A file that is outdated only because its execute bit was lost still has the
91
+ // sha we recorded, so it reads as ours and is re-applied with no backup — which is right: nothing
92
+ // of the team's is being discarded, the mode is simply restored.
77
93
  const ours = !!recorded && recorded === fileSha(dest);
78
94
  const backup = ours ? null : backupPathFor(dest);
79
95
  return {
@@ -129,13 +145,37 @@ const lstatIfPresent = (full) => {
129
145
  try {
130
146
  return fs.lstatSync(full);
131
147
  } catch (e) {
132
- if (e?.code === 'ENOENT') return null;
148
+ // Every code here means "there is nothing I can read at this path", which is what the callers ask.
149
+ // ENOENT nothing there.
150
+ // ENOTDIR an ancestor is a file, so the child cannot exist — the caller that cares about the
151
+ // broken ancestor reports it properly.
152
+ // EACCES / EPERM a directory we are not allowed to look inside. Newly reachable: `.cursor/` is
153
+ // an install target now, so a repo holding one at mode 000 would otherwise abort
154
+ // `yad check` and `yad setup` outright, on a directory yad has no business reading.
155
+ // ELOOP / ENAMETOOLONG a symlink cycle or an unusable path; not a target either way.
156
+ // Not installing into something we cannot stat is the safe answer in all of them.
157
+ if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'ELOOP', 'ENAMETOOLONG'].includes(e?.code)) return null;
133
158
  throw e;
134
159
  }
135
160
  };
136
- const ideContainers = (ide) => ide === '.opencode'
137
- ? [ide, IDE_OPENCODE_DIR]
138
- : [ide, path.join(ide, 'skills')];
161
+ // The directory yad installs INTO for a target — `.opencode`'s flat commands folder, everyone else's
162
+ // `skills/`. One definition, because `ideContainers` (safety) and `hasInstallContainer` (detection)
163
+ // asking the same question two different ways is how they drift: the second spelling of this already
164
+ // had `'.opencode'` as a literal where the first had the constant.
165
+ const installContainer = (ide) => (ide === IDE_OPENCODE_TARGET ? IDE_OPENCODE_DIR : path.join(ide, 'skills'));
166
+ const ideContainers = (ide) => [ide, installContainer(ide)];
167
+
168
+ // The ROOT itself, before any target under it, on BOTH paths into the planner — detection and an
169
+ // explicit target list. Widening `lstatIfPresent` to swallow ENOTDIR made a non-directory root
170
+ // silently yield "nothing here", and the planner then built a full install plan against a path that
171
+ // is a regular file. It used to abort by accident, on the raw ENOTDIR; aborting on purpose, with a
172
+ // sentence naming the problem, is what it should have been doing.
173
+ function assertRootIsDirectory(root) {
174
+ const stat = lstatIfPresent(root);
175
+ if (stat && !stat.isDirectory()) {
176
+ throw ideTargetError(`not a directory: ${root} — a project root must be a directory`);
177
+ }
178
+ }
139
179
 
140
180
  function assertSafeIdeContainers(root, ide) {
141
181
  for (const relPath of ideContainers(ide)) {
@@ -201,20 +241,91 @@ export function canonicalIdeTargets(input) {
201
241
  // every target before constructing ANY actions, so a bad later target cannot cause a partial install.
202
242
  export function safeIdeTargetsFor(root, input) {
203
243
  const targets = canonicalIdeTargets(input);
244
+ assertRootIsDirectory(root);
204
245
  for (const ide of targets) assertSafeIdeContainers(root, ide);
205
246
  return targets;
206
247
  }
207
248
 
249
+ // Is a wired hook script present AND runnable? Shared by the planner (via `fileAction`'s exec rule)
250
+ // and `yad doctor`, so the two cannot disagree about whether the guard is installed.
251
+ export const hookScriptReady = (root, relPath) => {
252
+ const full = path.join(root, relPath);
253
+ return !!lstatIfPresent(full) && isExecutable(full);
254
+ };
255
+
256
+ // Which of these targets are safe to write through, without throwing on the ones that are not.
257
+ // `safeIdeTargetsFor` throws on the first bad target, which is right for an installer and wrong for a
258
+ // report: `yad doctor` has to NAME the unusable one rather than die on it.
259
+ export function safeIdeTargetStateFor(root, input) {
260
+ const targets = [];
261
+ const unsafe = [];
262
+ assertRootIsDirectory(root);
263
+ for (const ide of canonicalIdeTargets(input)) {
264
+ try {
265
+ assertSafeIdeContainers(root, ide);
266
+ targets.push(ide);
267
+ } catch (e) {
268
+ if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
269
+ unsafe.push({ target: ide, message: e.message });
270
+ }
271
+ }
272
+ return { targets, unsafe };
273
+ }
274
+
208
275
  // Fallback discovery must not promote a supported-looking file/symlink into an install target.
209
276
  // Keep unsafe entries for diagnostics, while returning only real IDE directories with safe install
210
277
  // containers. Unexpected filesystem errors remain fatal instead of being mistaken for bad input.
278
+ //
279
+ // THE DIRECTORY IS NOT ENOUGH — the INSTALL CONTAINER has to be there (E11). `.cursor/` is an ordinary
280
+ // sight in a repo that uses Cursor for rules alone, and `.claude/` in one that only ever set
281
+ // permissions. Detecting on the bare directory meant that adding `.cursor` to the supported list
282
+ // silently enrolled every such project: no `ideTargets` in the stamp, so this fallback runs, `.cursor`
283
+ // is "detected", and the next `yad check --fix` writes 38 skill folders into `.cursor/skills/`, adds
284
+ // `.cursor/hooks.json`, and stamps the target permanently — for a team that never asked. Requiring
285
+ // `<ide>/skills/` (or `.opencode/commands/`) means detection finds only a directory yad already
286
+ // installs into, which is the question this function was always trying to answer.
287
+ //
288
+ // A `.claude/` holding only `settings.json` is therefore no longer DETECTED — and still ends up as
289
+ // `.claude`, because that is `IDE_RECOVERY_TARGET`, the fallback when detection finds nothing. The
290
+ // outcome is unchanged only while those two happen to be the same directory; if the recovery target
291
+ // ever moves, this stops being a coincidence and starts being a behaviour change. The test named
292
+ // "a project with a broken stamp recovers to .claude alone" is what would catch it.
293
+ const hasInstallContainer = (root, ide) => !!lstatIfPresent(path.join(root, installContainer(ide)));
294
+
295
+ // The OTHER evidence that a directory is an install of ours: its harness settings file names a hook
296
+ // command we wrote. Without this, requiring a skills container was an upgrade REGRESSION rather than
297
+ // a tightening — a project that keeps its skills in `.agents/` and has an armed `.claude/settings.json`
298
+ // (which a previous `yad check --fix` put there) lost `.claude` from detection, and `needsRepair`
299
+ // then wrote the loss into the stamp. The live entry would still fire on every edit while nothing
300
+ // updated it, normalised its legacy spelling, or reported it. Reading the FILE, not just its
301
+ // existence, is what keeps this from re-admitting a `.claude/` that only ever held permissions.
302
+ // Either of our hooks counts: the guard's, or — on a local-ledger Product, where it is the only one — the
303
+ // capture entry (E43). Missing the capture entry here would drop `.claude` from detection and let the next
304
+ // `--fix` unwire it as a removed target, the regression described above.
305
+ const hasOurHookEntry = (root, ide) => [HOOK_ADAPTERS[ide], CAPTURE_ADAPTERS[ide]].some((adapter) => {
306
+ if (!adapter) return false;
307
+ const full = path.join(root, adapter.settings);
308
+ if (!lstatIfPresent(full)) return false;
309
+ try {
310
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
311
+ const entries = parsed?.hooks?.[adapter.event];
312
+ if (!Array.isArray(entries)) return false;
313
+ return entries.some((entry) => entryIsOurs(adapter, entry));
314
+ } catch { return false; }
315
+ });
316
+
211
317
  export function detectedIdeTargetStateFor(root) {
318
+ assertRootIsDirectory(root);
212
319
  const targets = [];
213
320
  const unsafe = [];
214
321
  for (const ide of IDE_TARGETS) {
215
322
  if (!lstatIfPresent(path.join(root, ide))) continue;
216
323
  try {
324
+ // Safety FIRST, and the container second. An `.agents` that is a file or a symlink is a finding
325
+ // this function must report, not something to quietly skip because no `skills/` could be found
326
+ // underneath it — the diagnostic is the whole reason `unsafe` exists.
217
327
  assertSafeIdeContainers(root, ide);
328
+ if (!hasInstallContainer(root, ide) && !hasOurHookEntry(root, ide)) continue;
218
329
  targets.push(ide);
219
330
  } catch (e) {
220
331
  if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
@@ -225,7 +336,10 @@ export function detectedIdeTargetStateFor(root) {
225
336
  }
226
337
 
227
338
  // Which IDE targets this project wants. Persisted values are recovery-oriented: repair the one known
228
- // alias, filter everything else, then fall back to supported IDE dirs already present (or .claude).
339
+ // alias, filter everything else, then fall back to supported IDE dirs already present (or, with none,
340
+ // `IDE_RECOVERY_TARGET`). That last fallback is deliberately the MINIMUM and not `DEFAULT_IDE_TARGETS`
341
+ // — a recovery restores a project to a working state, it does not enrol it in a newer default and
342
+ // write skill folders nobody asked for. A fresh `yad setup` is where the default applies.
229
343
  // The full state lets reconcile report drift without mutating during a read-only check.
230
344
  export function ideTargetStateFor(root) {
231
345
  const stampPath = path.join(root, PROJECT_FILES.version);
@@ -240,7 +354,7 @@ export function ideTargetStateFor(root) {
240
354
  let unsafeDetected = [];
241
355
  if (!targets.length) {
242
356
  const detected = detectedIdeTargetStateFor(root);
243
- targets = detected.targets.length ? detected.targets : ['.claude'];
357
+ targets = detected.targets.length ? detected.targets : [IDE_RECOVERY_TARGET];
244
358
  unsafeDetected = detected.unsafe;
245
359
  usedFallback = true;
246
360
  }
@@ -262,11 +376,14 @@ export function ideTargetsFor(root) {
262
376
 
263
377
  // A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
264
378
  // so it rides `yad update` (--scope=changed) — like 'legacy'/'removed', the `changed` filter only
265
- // excludes literal 'missing', so 'new' survives. Scoped to SKILL installs ONLY: repo/hub wiring and
266
- // _bmad files stay 'missing' (excluded from update), so `update` never does one-time setup.
267
- const asNewSkill = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a);
379
+ // excludes literal 'missing', so 'new' survives. Used for the module — the skills and the config they
380
+ // read — and for the hook wiring (see `hookActions`): repo/Product wiring stays 'missing' (excluded from
381
+ // update), so `update` never does one-time setup. The config rides too because E3 moved it. Every
382
+ // existing install is missing `.sdlc/config.yaml`, and an update that brought the skills pointing at it
383
+ // without the file would leave them reading nothing.
384
+ const asNew = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a);
268
385
 
269
- // Module = skills installed into each IDE target + the _bmad/sdlc registration.
386
+ // Module = skills installed into each IDE target + the module config in `.sdlc/`.
270
387
  export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
271
388
  const targets = safeIdeTargetsFor(root, ideTargets);
272
389
  if (targets.includes('.opencode')) assertSafeOpenCodeWriteDestinations(root, SKILLS);
@@ -274,7 +391,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
274
391
  for (const ide of targets) {
275
392
  if (ide === '.opencode') {
276
393
  for (const s of SKILLS) {
277
- actions.push(asNewSkill(fileAction(
394
+ actions.push(asNew(fileAction(
278
395
  ide, s,
279
396
  asset('skills', s, 'SKILL.md'),
280
397
  path.join(root, IDE_OPENCODE_DIR, `${s}.md`),
@@ -283,7 +400,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
283
400
  }
284
401
  } else {
285
402
  for (const s of SKILLS) {
286
- actions.push(asNewSkill(dirAction(
403
+ actions.push(asNew(dirAction(
287
404
  ide, s,
288
405
  asset('skills', s),
289
406
  path.join(root, ide, 'skills', s),
@@ -292,14 +409,15 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
292
409
  }
293
410
  }
294
411
  }
295
- for (const f of MODULE_FILES) {
296
- actions.push(fileAction(
297
- '_bmad', f,
298
- asset('skills', 'sdlc', f),
299
- path.join(root, '_bmad', 'sdlc', f),
300
- { root },
301
- ));
302
- }
412
+ // A wired file, like the Product wiring, because the team may edit it — a language, a code repos root.
413
+ // An edited copy reads `modified` and is kept; only `--overwrite-local` replaces it, after a backup. A
414
+ // plain fileAction would copy the shipped file over that edit on every update, with no backup.
415
+ actions.push(asNew(wiredFileAction(
416
+ '.sdlc', 'config.yaml',
417
+ asset('skills', 'sdlc', 'config.yaml'),
418
+ path.join(root, MODULE_CONFIG),
419
+ { root, ledger: readManagedLedger(root) },
420
+ )));
303
421
  return actions;
304
422
  }
305
423
 
@@ -441,10 +559,10 @@ export function legacyRepoActions(root, repo) {
441
559
  }
442
560
 
443
561
  export function legacyHubActions(root) {
444
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
562
+ const hub = readJSON(productConfigPath(root));
445
563
  if (!isVerifiedLedger(hub)) return [];
446
- const wiring = [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])];
447
- return legacyFileActions('hub', root, LEGACY_HUB_FILES[hub.platform], wiring);
564
+ const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])];
565
+ return legacyFileActions('hub', root, LEGACY_PRODUCT_FILES[hub.platform], wiring);
448
566
  }
449
567
 
450
568
  // Per-repo wiring (gate scripts, CI, PR template).
@@ -475,77 +593,189 @@ export function repoActions(root, repo) {
475
593
  return actions.map((a) => (a.status === 'missing' && neverWritten(a) ? { ...a, status: 'new' } : a));
476
594
  }
477
595
 
478
- // Hub wiring (gate-sync + verified-commits CI on the product hub itself). Only when the hub has a
479
- // platform and the verified ledger is explicitly enabled — a local hub stays local, with no error.
480
- export function hubActions(root) {
481
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
596
+ // Product wiring (gate-sync + verified-commits CI on the Product itself). Only when the Product has a
597
+ // platform and the verified ledger is explicitly enabled — a local Product stays local, with no error.
598
+ export function productActions(root) {
599
+ const hub = readJSON(productConfigPath(root));
482
600
  // `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented hub-config schema); older setup versions
483
601
  // wrote `bridge` — `isVerifiedLedger` accepts an explicit true in either spelling, and is the one
484
602
  // predicate the CLI, the wiring, and the ledger hook all read (#186). Wire nothing otherwise.
485
603
  if (!isVerifiedLedger(hub)) return [];
486
604
  const ledger = readManagedLedger(root);
487
- return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
605
+ return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])].map((w) =>
488
606
  wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
489
607
  );
490
608
  }
491
609
 
492
- // ---- harness hooks (#171) --------------------------------------------------------------------
493
- // The desired hook entry, in the shape a harness reads it.
494
- export const hookEntry = () => ({
495
- matcher: HOOK_TOOL_MATCHER,
496
- hooks: [{ type: 'command', command: HOOK_COMMAND }],
497
- });
610
+ // ---- harness hooks (#171, two harnesses since E11) ---------------------------------------------
611
+ //
612
+ // Every function here takes an ADAPTER — the per-harness record in `HOOK_ADAPTERS` that says which
613
+ // file, which event key, which tool-name matcher, which command, and which of the two entry shapes.
614
+ // It defaults to Claude Code's, so the callers and tests written when there was only one harness read
615
+ // exactly as they did. What changed is that "the hook" is no longer a single spelling compiled in.
616
+ // The desired hook entry, in the shape THIS harness reads it. `nested: true` is Claude Code's, where
617
+ // one matcher entry carries a list of commands; `false` is Cursor's flat entry. `failClosed: false`
618
+ // is written once, on a new entry, to state the stance the script already takes — only an explicit
619
+ // deny blocks. It is never enforced afterwards: a team that turns it on has made a choice.
620
+ // An `observe` hook (E43's capture) only watches, so it states no `failClosed`; a null matcher is an
621
+ // event that takes none.
622
+ export const hookEntry = (adapter = CLAUDE_HOOK_ADAPTER) => (adapter.nested
623
+ ? { ...(adapter.matcher ? { matcher: adapter.matcher } : {}), hooks: [{ type: 'command', command: adapter.command }] }
624
+ : { ...(adapter.matcher ? { matcher: adapter.matcher } : {}), command: adapter.command, ...(adapter.observe ? {} : { failClosed: false }) });
498
625
 
499
626
  // Ours is a hook command EXACTLY equal to one we have written — the current spelling or a
500
- // documented past one. Never "the entry at index N", never "the entry with our matcher", and
501
- // deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also claim a team's
502
- // own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on the `outdated`
503
- // path that takes no backup. Matching exactly means the worst case is a second entry (the guard runs
504
- // twice — harmless) instead of someone else's hook disappearing.
505
- const OWNED_COMMANDS = new Set([HOOK_COMMAND, ...HOOK_COMMAND_LEGACY]);
506
- const OURS = (h) => typeof h?.command === 'string' && OWNED_COMMANDS.has(h.command);
507
-
508
- // Additive merge of our PreToolUse entry into a parsed settings object. Returns
509
- // `{ settings, changed }`; `settings` is a new object, so a caller can compare without mutating.
627
+ // documented past one, FOR THIS HARNESS. Never "the entry at index N", never "the entry with our
628
+ // matcher", and deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also
629
+ // claim a team's own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on
630
+ // the `outdated` path that takes no backup. Matching exactly means the worst case is a second entry
631
+ // (the guard runs twice — harmless) instead of someone else's hook disappearing.
632
+ //
633
+ // Per-harness and not one shared set: the two commands name different variables, and a `.cursor`
634
+ // entry spelled with `$CLAUDE_PROJECT_DIR` is someone else's, not an old one of ours to normalise.
635
+ const isOurCommand = (adapter, command) => typeof command === 'string'
636
+ && (command === adapter.command || adapter.legacyCommands.includes(command));
637
+ const entryIsOurs = (adapter, entry) => (adapter.nested
638
+ ? Array.isArray(entry?.hooks) && entry.hooks.some((h) => isOurCommand(adapter, h?.command))
639
+ : isOurCommand(adapter, entry?.command));
640
+
641
+ // Additive merge of our pre-edit entry into a parsed settings object. Returns `{ settings, changed }`;
642
+ // `settings` is a new object, so a caller can compare without mutating.
510
643
  // A matcher the team NARROWED is left alone (only the command is normalised) — the same respect for
511
644
  // a local edit that `modified` gives a managed file. Widening it back would silently undo their choice.
512
- export function mergeHookSettings(input) {
513
- const settings = { ...(input && typeof input === 'object' && !Array.isArray(input) ? input : {}) };
514
- const hooks = { ...(settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {}) };
515
- const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.map((e) => ({ ...e })) : [];
645
+ export function mergeHookSettings(input, adapter = CLAUDE_HOOK_ADAPTER) {
646
+ // A SHAPE WE DO NOT RECOGNISE IS REFUSED, not overwritten. `hooks` that is not an object, or an
647
+ // event whose value is not a list, means the team's file says something this merge cannot read —
648
+ // and rebuilding it from our own entry would delete whatever was there. That runs on the `outdated`
649
+ // path, which takes no backup, in a file yadflow has never written before and does not own.
650
+ //
651
+ // `unreadable` is returned rather than thrown so the caller can report it exactly as it reports a
652
+ // file that will not parse: warn, status `modified`, write nothing, and let a human look at it.
653
+ // (An input that is not an object at all is a different case — there is nothing to lose, so it is
654
+ // synthesized as before.)
655
+ if (isPlainObject(input)) {
656
+ if (Object.hasOwn(input, 'hooks') && !isPlainObject(input.hooks)) {
657
+ return { settings: input, changed: false, unreadable: 'hooks is not an object' };
658
+ }
659
+ const existing = isPlainObject(input.hooks) ? input.hooks[adapter.event] : undefined;
660
+ if (existing !== undefined && !Array.isArray(existing)) {
661
+ return { settings: input, changed: false, unreadable: `hooks.${adapter.event} is not a list` };
662
+ }
663
+ }
664
+ const settings = { ...(isPlainObject(input) ? input : {}) };
516
665
  let changed = false;
666
+ // Keys the FILE requires beside `hooks` (Cursor's `version`), added only when absent — see the
667
+ // `preamble` note in the manifest. A file we create gets them; a file the team wrote keeps theirs.
668
+ for (const [key, value] of Object.entries(adapter.preamble || {})) {
669
+ if (Object.hasOwn(settings, key)) continue;
670
+ settings[key] = value;
671
+ changed = true;
672
+ }
673
+ const hooks = { ...(isPlainObject(settings.hooks) ? settings.hooks : {}) };
674
+ const entries = Array.isArray(hooks[adapter.event]) ? hooks[adapter.event].map((e) => ({ ...e })) : [];
517
675
  let found = false;
518
- for (const entry of pre) {
519
- if (!Array.isArray(entry.hooks)) continue;
520
- entry.hooks = entry.hooks.map((h) => {
521
- if (!OURS(h)) return h;
676
+ for (const entry of entries) {
677
+ if (adapter.nested) {
678
+ if (!Array.isArray(entry.hooks)) continue;
679
+ entry.hooks = entry.hooks.map((h) => {
680
+ if (!isOurCommand(adapter, h?.command)) return h;
681
+ found = true;
682
+ if (h.command === adapter.command && h.type === 'command') return h;
683
+ changed = true;
684
+ return { ...h, type: 'command', command: adapter.command };
685
+ });
686
+ } else {
687
+ if (!isOurCommand(adapter, entry.command)) continue;
522
688
  found = true;
523
- if (h.command === HOOK_COMMAND && h.type === 'command') return h;
689
+ if (entry.command === adapter.command) continue;
690
+ entry.command = adapter.command;
524
691
  changed = true;
525
- return { ...h, type: 'command', command: HOOK_COMMAND };
526
- });
692
+ }
527
693
  }
528
- if (!found) { pre.push(hookEntry()); changed = true; }
529
- hooks.PreToolUse = pre;
694
+ if (!found) { entries.push(hookEntry(adapter)); changed = true; }
695
+ hooks[adapter.event] = entries;
530
696
  settings.hooks = hooks;
531
697
  return { settings, changed };
532
698
  }
533
699
 
700
+ // The inverse of the merge: take OUR entry out and leave everything else exactly as it was. Returns
701
+ // `{ settings, changed }`, and `changed: false` when there was nothing of ours to remove.
702
+ //
703
+ // An entry is removed whole only when it carried nothing but our command. Claude Code's shape lets a
704
+ // single matcher entry hold several commands, so a team that added theirs beside ours keeps their
705
+ // entry, minus our line. The same refusal as the merge applies to a shape we cannot read: it is not
706
+ // ours to rewrite.
707
+ export function unmergeHookSettings(input, adapter = CLAUDE_HOOK_ADAPTER) {
708
+ if (!isPlainObject(input) || !isPlainObject(input.hooks)) return { settings: input, changed: false };
709
+ const entries = input.hooks[adapter.event];
710
+ if (!Array.isArray(entries)) return { settings: input, changed: false };
711
+ let changed = false;
712
+ const kept = [];
713
+ for (const entry of entries) {
714
+ if (!entryIsOurs(adapter, entry)) { kept.push(entry); continue; }
715
+ changed = true;
716
+ if (!adapter.nested) continue; // flat entry: ours entirely, drop it
717
+ const others = entry.hooks.filter((h) => !isOurCommand(adapter, h?.command));
718
+ if (others.length) kept.push({ ...entry, hooks: others });
719
+ }
720
+ if (!changed) return { settings: input, changed: false };
721
+ const hooks = { ...input.hooks };
722
+ // Leave no empty array behind where the team had no such key before us.
723
+ if (kept.length) hooks[adapter.event] = kept; else delete hooks[adapter.event];
724
+ const settings = { ...input };
725
+ if (Object.keys(hooks).length) settings.hooks = hooks; else delete settings.hooks;
726
+ return { settings, changed: true };
727
+ }
728
+
729
+ // A hand-wired command that names OUR guard but would not work — returned as a sentence, or null.
730
+ //
731
+ // The discipline everywhere else is that a command we did not write is the team's, and we leave it
732
+ // alone. This is the one exception, and it is narrow on purpose: it fires only on a command that
733
+ // references our own script names, only for a harness whose hook answers with a JSON verdict, and it
734
+ // only REPORTS. The reason it earns the exception is that the mistake is catastrophic and silent —
735
+ // wiring `ledger-guard.sh` straight into Cursor, or adding `--format cursor` in a spelling the
736
+ // command string never splits, makes the guard answer with empty stdout, which Cursor reads as
737
+ // "deny", which blocks EVERY file write in the project with nothing saying why.
738
+ //
739
+ // `check-gates.md` documents hand-wiring for other harnesses, so this is a path people are invited
740
+ // down; it should have a handrail.
741
+ export function miswiredGuardCommand(entry, adapter) {
742
+ if (!adapter?.requiresJsonVerdict) return null;
743
+ const command = adapter.nested
744
+ ? (Array.isArray(entry?.hooks) ? entry.hooks.map((h) => h?.command).find((cmdStr) => typeof cmdStr === 'string' && /ledger-guard/.test(cmdStr)) : undefined)
745
+ : entry?.command;
746
+ if (typeof command !== 'string' || !/ledger-guard/.test(command)) return null;
747
+ if (command === adapter.command) return null;
748
+ if (adapter.legacyCommands.includes(command)) return null;
749
+ // Names the shared script rather than the adapter's wrapper.
750
+ if (!/ledger-guard-cursor/.test(command) && !/--format[= ]cursor/.test(command)) {
751
+ return `${command} answers with an exit code, not the JSON verdict this harness needs — it will block every file write`;
752
+ }
753
+ // Names the right protocol but through a spelling we do not own, so we cannot vouch for it.
754
+ return `${command} is not the command yad wires (${adapter.command}) — check it answers with a JSON verdict, or it will block every file write`;
755
+ }
756
+
534
757
  // Does the installed entry still select at least one file-editing tool? The merge deliberately
535
758
  // leaves a narrowed `matcher` alone — it is the team's — but a matcher narrowed to nothing (blanked,
536
759
  // or pointed at `Bash`) means the guard is installed and never fires, which must not read as healthy.
537
760
  // The matcher is a regex the harness tests tool names against, so test it as one; an invalid regex
538
- // cannot fire either.
539
- export function hookMatcherFires(settings) {
540
- const pre = settings?.hooks?.PreToolUse;
541
- if (!Array.isArray(pre)) return false;
542
- const tools = HOOK_TOOL_MATCHER.split('|');
543
- for (const entry of pre) {
544
- if (!Array.isArray(entry?.hooks) || !entry.hooks.some(OURS)) continue;
761
+ // cannot fire either. The tool names tested are the ADAPTER's: Cursor's `Delete` is a file write and
762
+ // Claude's `MultiEdit` is a tool Cursor does not have, so one shared list would be wrong for both.
763
+ export function hookMatcherFires(settings, adapter = CLAUDE_HOOK_ADAPTER) {
764
+ const entries = settings?.hooks?.[adapter.event];
765
+ if (!Array.isArray(entries)) return false;
766
+ // An event that takes no matcher (Cursor's `afterFileEdit`) fires whenever our entry is there.
767
+ if (!adapter.matcher) return entries.some((e) => entryIsOurs(adapter, e));
768
+ const tools = adapter.matcher.split('|');
769
+ for (const entry of entries) {
770
+ if (!entryIsOurs(adapter, entry)) continue;
771
+ // Both harnesses document an empty matcher as matching every tool; Cursor documents `*` the same
772
+ // way. `*` is NOT a valid regex on its own (`new RegExp('*')` throws "Nothing to repeat"), so
773
+ // testing it as one would drop into the catch below and report a correctly configured project as
774
+ // an installed-but-dead guard — a warning the team can only clear by breaking their own config.
775
+ if (!entry.matcher || entry.matcher === '*') return true;
545
776
  let re;
546
- try { re = new RegExp(entry.matcher ?? ''); } catch { continue; }
547
- // An empty matcher matches every tool name in Claude Code, so it is armed, not blank.
548
- if (!entry.matcher || tools.some((t) => re.test(t))) return true;
777
+ try { re = new RegExp(entry.matcher); } catch { continue; }
778
+ if (tools.some((t) => re.test(t))) return true;
549
779
  }
550
780
  return false;
551
781
  }
@@ -554,7 +784,9 @@ export function hookMatcherFires(settings) {
554
784
  // bytes against — the file belongs to the team and we own exactly one entry inside it. So it is also
555
785
  // deliberately NOT recorded in `.sdlc/managed.json` (recordManagedWrites only records a dest that
556
786
  // byte-matches its src); the marker above is its provenance instead.
557
- function hookSettingsAction(root, ide, relDest) {
787
+ function hookSettingsAction(root, adapter) {
788
+ const ide = adapter.target;
789
+ const relDest = adapter.settings;
558
790
  const dest = path.join(root, relDest);
559
791
  const raw = exists(dest) ? fs.readFileSync(dest, 'utf8') : null;
560
792
  let parsed = null;
@@ -584,10 +816,16 @@ function hookSettingsAction(root, ide, relDest) {
584
816
  // hand — "replace them with `yad update --overwrite-local`" — advice that can never clear this,
585
817
  // since this action deliberately writes nothing. The specific reason has to surface either way.
586
818
  if (unreadable) {
587
- warn(`${relDest} does not parse — the ledger guard cannot be wired; fix the JSON, then re-run \`yad check --fix\``);
819
+ warn(`${relDest} does not parse — the ${adapter.label || 'ledger guard'} cannot be wired; fix the JSON, then re-run \`yad check --fix\``);
820
+ return { ...base, status: 'modified', apply: () => {} };
821
+ }
822
+ const { changed, unreadable: unmergeable } = mergeHookSettings(parsed, adapter);
823
+ // Same treatment as a file that does not parse, and for the same reason: everything in it is the
824
+ // team's, there is no shipped template to restore, and `--overwrite-local` must not invent one.
825
+ if (unmergeable) {
826
+ warn(`${relDest}: ${unmergeable} — the ${adapter.label || 'ledger guard'} cannot be wired without discarding what is there; fix it by hand, then re-run \`yad check --fix\``);
588
827
  return { ...base, status: 'modified', apply: () => {} };
589
828
  }
590
- const { changed } = mergeHookSettings(parsed);
591
829
  return {
592
830
  ...base,
593
831
  status: raw === null ? 'missing' : changed ? 'outdated' : 'ok',
@@ -611,79 +849,182 @@ function hookSettingsAction(root, ide, relDest) {
611
849
  warn(`${relDest} does not parse — left untouched; fix the JSON, then re-run \`yad check --fix\``);
612
850
  return;
613
851
  }
614
- writeJSON(dest, mergeHookSettings(current).settings);
852
+ const merged = mergeHookSettings(current, adapter);
853
+ if (merged.unreadable) {
854
+ warn(`${relDest}: ${merged.unreadable} — left untouched; fix it by hand, then re-run \`yad check --fix\``);
855
+ return;
856
+ }
857
+ writeJSON(dest, merged.settings);
615
858
  return;
616
859
  }
617
860
  fs.mkdirSync(path.dirname(dest), { recursive: true });
618
- writeJSON(dest, mergeHookSettings({}).settings);
861
+ writeJSON(dest, mergeHookSettings({}, adapter).settings);
619
862
  },
620
863
  };
621
864
  }
622
865
 
623
- // Harness-hook wiring on the hub: the guard script plus, per IDE target that defines a hook protocol,
624
- // the entry that invokes it. Verified-only exactly like `hubActions` — with a local ledger it is
866
+ // Harness-hook wiring on the Product: the guard script plus, per IDE target that defines a hook protocol,
867
+ // the entry that invokes it. Verified-only exactly like `productActions` — with a local ledger it is
625
868
  // locally owned, the hand-edit the authoring skills describe is CORRECT, and a guard would be wrong.
626
869
  export function hookActions(root, ideTargets = ideTargetsFor(root)) {
627
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
870
+ const hub = readJSON(productConfigPath(root));
628
871
  if (!isVerifiedLedger(hub)) return [];
629
872
  const ledger = readManagedLedger(root);
630
873
  const actions = HOOK_WIRING.map((w) =>
631
874
  wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
632
875
  );
633
876
  for (const ide of safeIdeTargetsFor(root, ideTargets)) {
634
- const relDest = HOOK_SETTINGS[ide];
635
- if (relDest) actions.push(hookSettingsAction(root, ide, relDest));
877
+ const adapter = HOOK_ADAPTERS[ide];
878
+ if (!adapter) continue;
879
+ // A harness whose protocol the shared script cannot speak brings its own wrapper, and it is
880
+ // installed ONLY for a project that selected that target — an unused adapter script in a
881
+ // `.claude`-only tree is a file nobody can explain. It is pushed BEFORE the settings entry that
882
+ // points at it, so the two land in the order `asNew` already guarantees they land together.
883
+ for (const w of adapter.wiring) {
884
+ actions.push(wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }));
885
+ }
886
+ actions.push(hookSettingsAction(root, adapter));
636
887
  }
637
888
  // The two halves must land TOGETHER, so `missing` is relabelled `new` — the same relabel a new
638
889
  // first-party skill gets, and for the same reason: `yad update` (--scope=changed) excludes only
639
- // the literal 'missing'. Without it, an upgrade on a hub that already has a settings.json applies
890
+ // the literal 'missing'. Without it, an upgrade on a Product that already has a settings.json applies
640
891
  // the entry (`outdated`) while skipping the script (`missing`), leaving every file edit firing a
641
892
  // PreToolUse command that does not exist — a hook error per edit, and no guarding at all.
642
- return actions.map(asNewSkill);
893
+ return actions.map(asNew);
643
894
  }
644
895
 
645
- // Every email the verified-commits gate should accept as a known author: the hub roster's `email`
646
- // (or `emails`) fields plus hub.json's free-form `verified_authors` list. Lower-cased, deduped,
647
- // sorted — deterministic so the generated file is drift-checkable like any wired file.
648
- export function verifiedAuthorEmails(hub) {
649
- const out = new Set();
650
- for (const r of hub?.roster || []) {
651
- for (const e of [r.email, ...(Array.isArray(r.emails) ? r.emails : [])]) {
652
- if (e) out.add(String(e).toLowerCase());
896
+ // A target that has LEFT `ideTargets`, but whose wiring is still on disk and still running.
897
+ //
898
+ // Dropping `.cursor` used to leave `hooks/ledger-guard-cursor.sh` behind AND `.cursor/hooks.json`
899
+ // still invoking it on every write. That is not merely untidy: the entry keeps firing while nothing
900
+ // checks it for drift any more (every check here is keyed on the current targets), so the next
901
+ // release that changes the wrapper's protocol leaves that project running the old one, silently. The
902
+ // entry goes first and the script follows, and only a script no REMAINING target still needs is
903
+ // removed.
904
+ //
905
+ // Verified-only, like `hookActions`, and for the same reason: with a local ledger none of this was
906
+ // installed in the first place.
907
+ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
908
+ const hub = readJSON(productConfigPath(root));
909
+ if (!isVerifiedLedger(hub)) return [];
910
+ const kept = new Set(safeIdeTargetsFor(root, ideTargets));
911
+ const stillNeeded = new Set(
912
+ [...kept].flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest)),
913
+ );
914
+ const actions = [];
915
+ for (const adapter of Object.values(HOOK_ADAPTERS)) {
916
+ if (kept.has(adapter.target)) continue;
917
+ const settingsPath = path.join(root, adapter.settings);
918
+ if (exists(settingsPath)) {
919
+ // Read STRICTLY. A file that will not parse is the team's to fix, and rewriting it from a
920
+ // default would throw their whole harness config away — the same refusal the merge makes.
921
+ //
922
+ // Only OUR entry comes out. A `version` the merge added stays, because nothing on disk records
923
+ // whether we wrote it or the team did, and a key that might be theirs is not ours to delete —
924
+ // so a hooks.json we emptied is left as `{"version": 1}` rather than removed.
925
+ let parsed;
926
+ try { parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { /* refused below */ }
927
+ if (isPlainObject(parsed) && unmergeHookSettings(parsed, adapter).changed) {
928
+ actions.push({
929
+ scope: adapter.target,
930
+ item: `${path.basename(adapter.settings)} (removed)`,
931
+ status: 'removed',
932
+ root,
933
+ // Co-owned, exactly like the merge side: never staged into a `yad update --push` commit.
934
+ paths: [],
935
+ apply: () => {
936
+ let current;
937
+ try { current = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return; }
938
+ if (!isPlainObject(current)) return;
939
+ const { settings, changed } = unmergeHookSettings(current, adapter);
940
+ if (changed) writeJSON(settingsPath, settings);
941
+ },
942
+ });
943
+ }
944
+ }
945
+ for (const w of adapter.wiring) {
946
+ if (stillNeeded.has(w.dest)) continue;
947
+ const dest = path.join(root, w.dest);
948
+ if (!exists(dest)) continue;
949
+ actions.push({
950
+ scope: 'hub',
951
+ item: `${w.dest} (removed)`,
952
+ status: 'removed',
953
+ root,
954
+ paths: [w.dest],
955
+ apply: () => fs.rmSync(dest, { force: true }),
956
+ });
653
957
  }
654
958
  }
655
- for (const e of hub?.verified_authors || []) if (e) out.add(String(e).toLowerCase());
656
- return [...out].sort();
959
+ return actions;
657
960
  }
658
961
 
659
- // Generate .sdlc/verified-authors (one email per line) in the hub AND every registered repo, from
660
- // the hub config. No emails configured → no actions (the gate then warns instead of blocking —
661
- // never enforce an empty allowlist).
662
- export function authorsActions(root, repos = []) {
663
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
664
- const emails = verifiedAuthorEmails(hub);
665
- if (!emails.length) return [];
666
- const desired = [
667
- '# Generated by `yad check --fix` from .sdlc/hub.json (roster emails + verified_authors).',
668
- '# The verified-commits gate accepts only these author emails. Edit hub.json, not this file.',
669
- ...emails,
670
- ].join('\n') + '\n';
671
- const targets = [
672
- { scope: 'hub', root, dest: path.join(root, '.sdlc', 'verified-authors') },
673
- ...repos.map((r) => {
674
- const repoRoot = path.resolve(root, r.path);
675
- return { scope: r.name, root: repoRoot, dest: path.join(repoRoot, '.sdlc', 'verified-authors') };
676
- }),
677
- ];
678
- return targets.map(({ scope, root: targetRoot, dest }) => ({
679
- scope,
680
- item: '.sdlc/verified-authors',
681
- status: !exists(dest) ? 'missing' : fs.readFileSync(dest, 'utf8') === desired ? 'ok' : 'outdated',
682
- root: targetRoot,
683
- paths: [rel(targetRoot, dest)],
684
- apply: () => {
685
- fs.mkdirSync(path.dirname(dest), { recursive: true });
686
- fs.writeFileSync(dest, desired);
687
- },
688
- }));
962
+ // ---- background capture wiring (E43) -----------------------------------------------------------
963
+ //
964
+ // The post-edit capture hook: `hooks/yad-capture.sh`, plus one entry per IDE target that has a post-edit
965
+ // protocol. Unlike `hookActions` it is wired in BOTH ledger modes — never losing a draft matters whoever
966
+ // owns the ledger — on any Product (a `.sdlc/hub.json` or `product.json`) whose config does not say
967
+ // `"capture": false`. Only the CONFIG turns the wiring off: `YAD_CAPTURE=0` is one shell's choice, and
968
+ // letting it unwire a committed file would make `yad check` flip-flop between two people's shells.
969
+ function captureWanted(root) {
970
+ const cfgPath = productConfigPath(root);
971
+ if (!exists(cfgPath)) return false;
972
+ const cfg = readJSON(cfgPath, null);
973
+ return !(cfg && cfg.capture === false);
689
974
  }
975
+
976
+ export function captureHookActions(root, ideTargets = ideTargetsFor(root)) {
977
+ if (!captureWanted(root)) return [];
978
+ const ledger = readManagedLedger(root);
979
+ const actions = CAPTURE_WIRING.map((w) =>
980
+ wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
981
+ );
982
+ for (const ide of safeIdeTargetsFor(root, ideTargets)) {
983
+ const adapter = CAPTURE_ADAPTERS[ide];
984
+ if (!adapter) continue;
985
+ actions.push(hookSettingsAction(root, adapter));
986
+ }
987
+ // The script and its entries land together, for the reason `hookActions` gives.
988
+ return actions.map(asNew);
989
+ }
990
+
991
+ // Capture wiring that should no longer run: an entry for a target that left `ideTargets`, or every entry
992
+ // and the script once the config says `"capture": false`. Only OUR entry comes out, as `orphanHookActions`
993
+ // does it; the script goes only when capture is off (a remaining target still invokes it otherwise).
994
+ export function orphanCaptureHookActions(root, ideTargets = ideTargetsFor(root)) {
995
+ if (!exists(productConfigPath(root))) return [];
996
+ const on = captureWanted(root);
997
+ const kept = on ? new Set(safeIdeTargetsFor(root, ideTargets)) : new Set();
998
+ const actions = [];
999
+ for (const adapter of Object.values(CAPTURE_ADAPTERS)) {
1000
+ if (kept.has(adapter.target)) continue;
1001
+ const settingsPath = path.join(root, adapter.settings);
1002
+ if (!exists(settingsPath)) continue;
1003
+ let parsed;
1004
+ try { parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { continue; }
1005
+ if (!isPlainObject(parsed) || !unmergeHookSettings(parsed, adapter).changed) continue;
1006
+ actions.push({
1007
+ scope: adapter.target,
1008
+ item: `${path.basename(adapter.settings)} (capture entry removed)`,
1009
+ status: 'removed',
1010
+ root,
1011
+ paths: [],
1012
+ apply: () => {
1013
+ let current;
1014
+ try { current = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return; }
1015
+ if (!isPlainObject(current)) return;
1016
+ const { settings, changed } = unmergeHookSettings(current, adapter);
1017
+ if (changed) writeJSON(settingsPath, settings);
1018
+ },
1019
+ });
1020
+ }
1021
+ if (!on) {
1022
+ for (const w of CAPTURE_WIRING) {
1023
+ const dest = path.join(root, w.dest);
1024
+ if (!exists(dest)) continue;
1025
+ actions.push({ scope: 'hub', item: `${w.dest} (removed)`, status: 'removed', root, paths: [w.dest], apply: () => fs.rmSync(dest, { force: true }) });
1026
+ }
1027
+ }
1028
+ return actions;
1029
+ }
1030
+