yadflow 3.18.1 → 4.0.0-next.1

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 (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. 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, isBridgeHub,
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,
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,89 @@ 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
+ const hasOurHookEntry = (root, ide) => {
303
+ const adapter = HOOK_ADAPTERS[ide];
304
+ if (!adapter) return false;
305
+ const full = path.join(root, adapter.settings);
306
+ if (!lstatIfPresent(full)) return false;
307
+ try {
308
+ const parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
309
+ const entries = parsed?.hooks?.[adapter.event];
310
+ if (!Array.isArray(entries)) return false;
311
+ return entries.some((entry) => entryIsOurs(adapter, entry));
312
+ } catch { return false; }
313
+ };
314
+
211
315
  export function detectedIdeTargetStateFor(root) {
316
+ assertRootIsDirectory(root);
212
317
  const targets = [];
213
318
  const unsafe = [];
214
319
  for (const ide of IDE_TARGETS) {
215
320
  if (!lstatIfPresent(path.join(root, ide))) continue;
216
321
  try {
322
+ // Safety FIRST, and the container second. An `.agents` that is a file or a symlink is a finding
323
+ // this function must report, not something to quietly skip because no `skills/` could be found
324
+ // underneath it — the diagnostic is the whole reason `unsafe` exists.
217
325
  assertSafeIdeContainers(root, ide);
326
+ if (!hasInstallContainer(root, ide) && !hasOurHookEntry(root, ide)) continue;
218
327
  targets.push(ide);
219
328
  } catch (e) {
220
329
  if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
@@ -225,7 +334,10 @@ export function detectedIdeTargetStateFor(root) {
225
334
  }
226
335
 
227
336
  // 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).
337
+ // alias, filter everything else, then fall back to supported IDE dirs already present (or, with none,
338
+ // `IDE_RECOVERY_TARGET`). That last fallback is deliberately the MINIMUM and not `DEFAULT_IDE_TARGETS`
339
+ // — a recovery restores a project to a working state, it does not enrol it in a newer default and
340
+ // write skill folders nobody asked for. A fresh `yad setup` is where the default applies.
229
341
  // The full state lets reconcile report drift without mutating during a read-only check.
230
342
  export function ideTargetStateFor(root) {
231
343
  const stampPath = path.join(root, PROJECT_FILES.version);
@@ -240,7 +352,7 @@ export function ideTargetStateFor(root) {
240
352
  let unsafeDetected = [];
241
353
  if (!targets.length) {
242
354
  const detected = detectedIdeTargetStateFor(root);
243
- targets = detected.targets.length ? detected.targets : ['.claude'];
355
+ targets = detected.targets.length ? detected.targets : [IDE_RECOVERY_TARGET];
244
356
  unsafeDetected = detected.unsafe;
245
357
  usedFallback = true;
246
358
  }
@@ -262,11 +374,14 @@ export function ideTargetsFor(root) {
262
374
 
263
375
  // A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
264
376
  // 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);
268
-
269
- // Module = skills installed into each IDE target + the _bmad/sdlc registration.
377
+ // excludes literal 'missing', so 'new' survives. Used for the module — the skills and the config they
378
+ // read — and for the hook wiring (see `hookActions`): repo/Product wiring stays 'missing' (excluded from
379
+ // update), so `update` never does one-time setup. The config rides too because E3 moved it. Every
380
+ // existing install is missing `.sdlc/config.yaml`, and an update that brought the skills pointing at it
381
+ // without the file would leave them reading nothing.
382
+ const asNew = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a);
383
+
384
+ // Module = skills installed into each IDE target + the module config in `.sdlc/`.
270
385
  export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
271
386
  const targets = safeIdeTargetsFor(root, ideTargets);
272
387
  if (targets.includes('.opencode')) assertSafeOpenCodeWriteDestinations(root, SKILLS);
@@ -274,7 +389,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
274
389
  for (const ide of targets) {
275
390
  if (ide === '.opencode') {
276
391
  for (const s of SKILLS) {
277
- actions.push(asNewSkill(fileAction(
392
+ actions.push(asNew(fileAction(
278
393
  ide, s,
279
394
  asset('skills', s, 'SKILL.md'),
280
395
  path.join(root, IDE_OPENCODE_DIR, `${s}.md`),
@@ -283,7 +398,7 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
283
398
  }
284
399
  } else {
285
400
  for (const s of SKILLS) {
286
- actions.push(asNewSkill(dirAction(
401
+ actions.push(asNew(dirAction(
287
402
  ide, s,
288
403
  asset('skills', s),
289
404
  path.join(root, ide, 'skills', s),
@@ -292,14 +407,15 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
292
407
  }
293
408
  }
294
409
  }
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
- }
410
+ // A wired file, like the Product wiring, because the team may edit it — a language, a code repos root.
411
+ // An edited copy reads `modified` and is kept; only `--overwrite-local` replaces it, after a backup. A
412
+ // plain fileAction would copy the shipped file over that edit on every update, with no backup.
413
+ actions.push(asNew(wiredFileAction(
414
+ '.sdlc', 'config.yaml',
415
+ asset('skills', 'sdlc', 'config.yaml'),
416
+ path.join(root, MODULE_CONFIG),
417
+ { root, ledger: readManagedLedger(root) },
418
+ )));
303
419
  return actions;
304
420
  }
305
421
 
@@ -441,92 +557,219 @@ export function legacyRepoActions(root, repo) {
441
557
  }
442
558
 
443
559
  export function legacyHubActions(root) {
444
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
445
- if (!isBridgeHub(hub)) return [];
446
- const wiring = [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])];
447
- return legacyFileActions('hub', root, LEGACY_HUB_FILES[hub.platform], wiring);
560
+ const hub = readJSON(productConfigPath(root));
561
+ if (!isVerifiedLedger(hub)) return [];
562
+ const wiring = [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])];
563
+ return legacyFileActions('hub', root, LEGACY_PRODUCT_FILES[hub.platform], wiring);
448
564
  }
449
565
 
450
566
  // Per-repo wiring (gate scripts, CI, PR template).
451
567
  export function repoActions(root, repo) {
452
568
  const repoRoot = path.resolve(root, repo.path);
453
569
  const ledger = readManagedLedger(repoRoot);
454
- return wiringFor(repo.platform).map((w) =>
570
+ const actions = wiringFor(repo.platform).map((w) =>
455
571
  wiredFileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { root: repoRoot, exec: !!w.exec, ledger }),
456
572
  );
573
+ // A repo that already carries ANY of its wiring is a wired repo, and a template a later release
574
+ // ADDED to the wiring is `new` there, not `missing` — the same relabel a new first-party skill
575
+ // gets, for the same reason: `yad update` (--scope=changed) excludes only the literal 'missing'.
576
+ // Without it an upgrade rewrites the files that CALL the new template (`outdated`) while skipping
577
+ // the template itself, and every PR fails the gate with "file not found" until someone runs
578
+ // `yad check --fix`. The ledger tells the two absences apart: a file yad RECORDED writing and
579
+ // someone since removed stays `missing` (the team's deletion is respected, as before), while one
580
+ // yad never wrote is the new template. A repo with none of its wiring stays `missing` throughout:
581
+ // update never does one-time setup.
582
+ //
583
+ // "Wired" means yad wired it: the ledger has records, or (an install from before the ledger) a
584
+ // gate script under checks/ is present. A team-owned file that merely occupies a wired path — a
585
+ // hand-written PR template reads as `outdated` — is not evidence, or update would perform the
586
+ // whole one-time setup on a repo that never asked for it.
587
+ const wired = Object.keys(ledger).length > 0
588
+ || actions.some((a) => a.status !== 'missing' && a.item.startsWith('checks/'));
589
+ if (!wired) return actions;
590
+ const neverWritten = (a) => !ledger[rel(a.managed.root, a.managed.dest)];
591
+ return actions.map((a) => (a.status === 'missing' && neverWritten(a) ? { ...a, status: 'new' } : a));
457
592
  }
458
593
 
459
- // Hub wiring (gate-sync + verified-commits CI on the product hub itself). Only when the hub has a
460
- // platform and the bridge is explicitly enabled — a file-only hub stays file-only, with no error.
461
- export function hubActions(root) {
462
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
463
- // `bridge_enabled` is the canonical flag (the documented hub-config schema); older setup versions
464
- // wrote `bridge` — `isBridgeHub` accepts an explicit true in either spelling, and is the one
594
+ // Product wiring (gate-sync + verified-commits CI on the Product itself). Only when the Product has a
595
+ // platform and the verified ledger is explicitly enabled — a local Product stays local, with no error.
596
+ export function productActions(root) {
597
+ const hub = readJSON(productConfigPath(root));
598
+ // `ledger` is the canonical switch and `bridge_enabled` its older spelling (the documented hub-config schema); older setup versions
599
+ // wrote `bridge` — `isVerifiedLedger` accepts an explicit true in either spelling, and is the one
465
600
  // predicate the CLI, the wiring, and the ledger hook all read (#186). Wire nothing otherwise.
466
- if (!isBridgeHub(hub)) return [];
601
+ if (!isVerifiedLedger(hub)) return [];
467
602
  const ledger = readManagedLedger(root);
468
- return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
603
+ return [...PRODUCT_WIRING.common, ...(PRODUCT_WIRING[hub.platform] || [])].map((w) =>
469
604
  wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
470
605
  );
471
606
  }
472
607
 
473
- // ---- harness hooks (#171) --------------------------------------------------------------------
474
- // The desired hook entry, in the shape a harness reads it.
475
- export const hookEntry = () => ({
476
- matcher: HOOK_TOOL_MATCHER,
477
- hooks: [{ type: 'command', command: HOOK_COMMAND }],
478
- });
608
+ // ---- harness hooks (#171, two harnesses since E11) ---------------------------------------------
609
+ //
610
+ // Every function here takes an ADAPTER — the per-harness record in `HOOK_ADAPTERS` that says which
611
+ // file, which event key, which tool-name matcher, which command, and which of the two entry shapes.
612
+ // It defaults to Claude Code's, so the callers and tests written when there was only one harness read
613
+ // exactly as they did. What changed is that "the hook" is no longer a single spelling compiled in.
614
+ // The desired hook entry, in the shape THIS harness reads it. `nested: true` is Claude Code's, where
615
+ // one matcher entry carries a list of commands; `false` is Cursor's flat entry. `failClosed: false`
616
+ // is written once, on a new entry, to state the stance the script already takes — only an explicit
617
+ // deny blocks. It is never enforced afterwards: a team that turns it on has made a choice.
618
+ export const hookEntry = (adapter = CLAUDE_HOOK_ADAPTER) => (adapter.nested
619
+ ? { matcher: adapter.matcher, hooks: [{ type: 'command', command: adapter.command }] }
620
+ : { matcher: adapter.matcher, command: adapter.command, failClosed: false });
479
621
 
480
622
  // Ours is a hook command EXACTLY equal to one we have written — the current spelling or a
481
- // documented past one. Never "the entry at index N", never "the entry with our matcher", and
482
- // deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also claim a team's
483
- // own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on the `outdated`
484
- // path that takes no backup. Matching exactly means the worst case is a second entry (the guard runs
485
- // twice — harmless) instead of someone else's hook disappearing.
486
- const OWNED_COMMANDS = new Set([HOOK_COMMAND, ...HOOK_COMMAND_LEGACY]);
487
- const OURS = (h) => typeof h?.command === 'string' && OWNED_COMMANDS.has(h.command);
488
-
489
- // Additive merge of our PreToolUse entry into a parsed settings object. Returns
490
- // `{ settings, changed }`; `settings` is a new object, so a caller can compare without mutating.
623
+ // documented past one, FOR THIS HARNESS. Never "the entry at index N", never "the entry with our
624
+ // matcher", and deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also
625
+ // claim a team's own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on
626
+ // the `outdated` path that takes no backup. Matching exactly means the worst case is a second entry
627
+ // (the guard runs twice — harmless) instead of someone else's hook disappearing.
628
+ //
629
+ // Per-harness and not one shared set: the two commands name different variables, and a `.cursor`
630
+ // entry spelled with `$CLAUDE_PROJECT_DIR` is someone else's, not an old one of ours to normalise.
631
+ const isOurCommand = (adapter, command) => typeof command === 'string'
632
+ && (command === adapter.command || adapter.legacyCommands.includes(command));
633
+ const entryIsOurs = (adapter, entry) => (adapter.nested
634
+ ? Array.isArray(entry?.hooks) && entry.hooks.some((h) => isOurCommand(adapter, h?.command))
635
+ : isOurCommand(adapter, entry?.command));
636
+
637
+ // Additive merge of our pre-edit entry into a parsed settings object. Returns `{ settings, changed }`;
638
+ // `settings` is a new object, so a caller can compare without mutating.
491
639
  // A matcher the team NARROWED is left alone (only the command is normalised) — the same respect for
492
640
  // a local edit that `modified` gives a managed file. Widening it back would silently undo their choice.
493
- export function mergeHookSettings(input) {
494
- const settings = { ...(input && typeof input === 'object' && !Array.isArray(input) ? input : {}) };
495
- const hooks = { ...(settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {}) };
496
- const pre = Array.isArray(hooks.PreToolUse) ? hooks.PreToolUse.map((e) => ({ ...e })) : [];
641
+ export function mergeHookSettings(input, adapter = CLAUDE_HOOK_ADAPTER) {
642
+ // A SHAPE WE DO NOT RECOGNISE IS REFUSED, not overwritten. `hooks` that is not an object, or an
643
+ // event whose value is not a list, means the team's file says something this merge cannot read —
644
+ // and rebuilding it from our own entry would delete whatever was there. That runs on the `outdated`
645
+ // path, which takes no backup, in a file yadflow has never written before and does not own.
646
+ //
647
+ // `unreadable` is returned rather than thrown so the caller can report it exactly as it reports a
648
+ // file that will not parse: warn, status `modified`, write nothing, and let a human look at it.
649
+ // (An input that is not an object at all is a different case — there is nothing to lose, so it is
650
+ // synthesized as before.)
651
+ if (isPlainObject(input)) {
652
+ if (Object.hasOwn(input, 'hooks') && !isPlainObject(input.hooks)) {
653
+ return { settings: input, changed: false, unreadable: 'hooks is not an object' };
654
+ }
655
+ const existing = isPlainObject(input.hooks) ? input.hooks[adapter.event] : undefined;
656
+ if (existing !== undefined && !Array.isArray(existing)) {
657
+ return { settings: input, changed: false, unreadable: `hooks.${adapter.event} is not a list` };
658
+ }
659
+ }
660
+ const settings = { ...(isPlainObject(input) ? input : {}) };
497
661
  let changed = false;
662
+ // Keys the FILE requires beside `hooks` (Cursor's `version`), added only when absent — see the
663
+ // `preamble` note in the manifest. A file we create gets them; a file the team wrote keeps theirs.
664
+ for (const [key, value] of Object.entries(adapter.preamble || {})) {
665
+ if (Object.hasOwn(settings, key)) continue;
666
+ settings[key] = value;
667
+ changed = true;
668
+ }
669
+ const hooks = { ...(isPlainObject(settings.hooks) ? settings.hooks : {}) };
670
+ const entries = Array.isArray(hooks[adapter.event]) ? hooks[adapter.event].map((e) => ({ ...e })) : [];
498
671
  let found = false;
499
- for (const entry of pre) {
500
- if (!Array.isArray(entry.hooks)) continue;
501
- entry.hooks = entry.hooks.map((h) => {
502
- if (!OURS(h)) return h;
672
+ for (const entry of entries) {
673
+ if (adapter.nested) {
674
+ if (!Array.isArray(entry.hooks)) continue;
675
+ entry.hooks = entry.hooks.map((h) => {
676
+ if (!isOurCommand(adapter, h?.command)) return h;
677
+ found = true;
678
+ if (h.command === adapter.command && h.type === 'command') return h;
679
+ changed = true;
680
+ return { ...h, type: 'command', command: adapter.command };
681
+ });
682
+ } else {
683
+ if (!isOurCommand(adapter, entry.command)) continue;
503
684
  found = true;
504
- if (h.command === HOOK_COMMAND && h.type === 'command') return h;
685
+ if (entry.command === adapter.command) continue;
686
+ entry.command = adapter.command;
505
687
  changed = true;
506
- return { ...h, type: 'command', command: HOOK_COMMAND };
507
- });
688
+ }
508
689
  }
509
- if (!found) { pre.push(hookEntry()); changed = true; }
510
- hooks.PreToolUse = pre;
690
+ if (!found) { entries.push(hookEntry(adapter)); changed = true; }
691
+ hooks[adapter.event] = entries;
511
692
  settings.hooks = hooks;
512
693
  return { settings, changed };
513
694
  }
514
695
 
696
+ // The inverse of the merge: take OUR entry out and leave everything else exactly as it was. Returns
697
+ // `{ settings, changed }`, and `changed: false` when there was nothing of ours to remove.
698
+ //
699
+ // An entry is removed whole only when it carried nothing but our command. Claude Code's shape lets a
700
+ // single matcher entry hold several commands, so a team that added theirs beside ours keeps their
701
+ // entry, minus our line. The same refusal as the merge applies to a shape we cannot read: it is not
702
+ // ours to rewrite.
703
+ export function unmergeHookSettings(input, adapter = CLAUDE_HOOK_ADAPTER) {
704
+ if (!isPlainObject(input) || !isPlainObject(input.hooks)) return { settings: input, changed: false };
705
+ const entries = input.hooks[adapter.event];
706
+ if (!Array.isArray(entries)) return { settings: input, changed: false };
707
+ let changed = false;
708
+ const kept = [];
709
+ for (const entry of entries) {
710
+ if (!entryIsOurs(adapter, entry)) { kept.push(entry); continue; }
711
+ changed = true;
712
+ if (!adapter.nested) continue; // flat entry: ours entirely, drop it
713
+ const others = entry.hooks.filter((h) => !isOurCommand(adapter, h?.command));
714
+ if (others.length) kept.push({ ...entry, hooks: others });
715
+ }
716
+ if (!changed) return { settings: input, changed: false };
717
+ const hooks = { ...input.hooks };
718
+ // Leave no empty array behind where the team had no such key before us.
719
+ if (kept.length) hooks[adapter.event] = kept; else delete hooks[adapter.event];
720
+ const settings = { ...input };
721
+ if (Object.keys(hooks).length) settings.hooks = hooks; else delete settings.hooks;
722
+ return { settings, changed: true };
723
+ }
724
+
725
+ // A hand-wired command that names OUR guard but would not work — returned as a sentence, or null.
726
+ //
727
+ // The discipline everywhere else is that a command we did not write is the team's, and we leave it
728
+ // alone. This is the one exception, and it is narrow on purpose: it fires only on a command that
729
+ // references our own script names, only for a harness whose hook answers with a JSON verdict, and it
730
+ // only REPORTS. The reason it earns the exception is that the mistake is catastrophic and silent —
731
+ // wiring `ledger-guard.sh` straight into Cursor, or adding `--format cursor` in a spelling the
732
+ // command string never splits, makes the guard answer with empty stdout, which Cursor reads as
733
+ // "deny", which blocks EVERY file write in the project with nothing saying why.
734
+ //
735
+ // `check-gates.md` documents hand-wiring for other harnesses, so this is a path people are invited
736
+ // down; it should have a handrail.
737
+ export function miswiredGuardCommand(entry, adapter) {
738
+ if (!adapter?.requiresJsonVerdict) return null;
739
+ const command = adapter.nested
740
+ ? (Array.isArray(entry?.hooks) ? entry.hooks.map((h) => h?.command).find((cmdStr) => typeof cmdStr === 'string' && /ledger-guard/.test(cmdStr)) : undefined)
741
+ : entry?.command;
742
+ if (typeof command !== 'string' || !/ledger-guard/.test(command)) return null;
743
+ if (command === adapter.command) return null;
744
+ if (adapter.legacyCommands.includes(command)) return null;
745
+ // Names the shared script rather than the adapter's wrapper.
746
+ if (!/ledger-guard-cursor/.test(command) && !/--format[= ]cursor/.test(command)) {
747
+ return `${command} answers with an exit code, not the JSON verdict this harness needs — it will block every file write`;
748
+ }
749
+ // Names the right protocol but through a spelling we do not own, so we cannot vouch for it.
750
+ return `${command} is not the command yad wires (${adapter.command}) — check it answers with a JSON verdict, or it will block every file write`;
751
+ }
752
+
515
753
  // Does the installed entry still select at least one file-editing tool? The merge deliberately
516
754
  // leaves a narrowed `matcher` alone — it is the team's — but a matcher narrowed to nothing (blanked,
517
755
  // or pointed at `Bash`) means the guard is installed and never fires, which must not read as healthy.
518
756
  // The matcher is a regex the harness tests tool names against, so test it as one; an invalid regex
519
- // cannot fire either.
520
- export function hookMatcherFires(settings) {
521
- const pre = settings?.hooks?.PreToolUse;
522
- if (!Array.isArray(pre)) return false;
523
- const tools = HOOK_TOOL_MATCHER.split('|');
524
- for (const entry of pre) {
525
- if (!Array.isArray(entry?.hooks) || !entry.hooks.some(OURS)) continue;
757
+ // cannot fire either. The tool names tested are the ADAPTER's: Cursor's `Delete` is a file write and
758
+ // Claude's `MultiEdit` is a tool Cursor does not have, so one shared list would be wrong for both.
759
+ export function hookMatcherFires(settings, adapter = CLAUDE_HOOK_ADAPTER) {
760
+ const entries = settings?.hooks?.[adapter.event];
761
+ if (!Array.isArray(entries)) return false;
762
+ const tools = adapter.matcher.split('|');
763
+ for (const entry of entries) {
764
+ if (!entryIsOurs(adapter, entry)) continue;
765
+ // Both harnesses document an empty matcher as matching every tool; Cursor documents `*` the same
766
+ // way. `*` is NOT a valid regex on its own (`new RegExp('*')` throws "Nothing to repeat"), so
767
+ // testing it as one would drop into the catch below and report a correctly configured project as
768
+ // an installed-but-dead guard — a warning the team can only clear by breaking their own config.
769
+ if (!entry.matcher || entry.matcher === '*') return true;
526
770
  let re;
527
- try { re = new RegExp(entry.matcher ?? ''); } catch { continue; }
528
- // An empty matcher matches every tool name in Claude Code, so it is armed, not blank.
529
- if (!entry.matcher || tools.some((t) => re.test(t))) return true;
771
+ try { re = new RegExp(entry.matcher); } catch { continue; }
772
+ if (tools.some((t) => re.test(t))) return true;
530
773
  }
531
774
  return false;
532
775
  }
@@ -535,7 +778,9 @@ export function hookMatcherFires(settings) {
535
778
  // bytes against — the file belongs to the team and we own exactly one entry inside it. So it is also
536
779
  // deliberately NOT recorded in `.sdlc/managed.json` (recordManagedWrites only records a dest that
537
780
  // byte-matches its src); the marker above is its provenance instead.
538
- function hookSettingsAction(root, ide, relDest) {
781
+ function hookSettingsAction(root, adapter) {
782
+ const ide = adapter.target;
783
+ const relDest = adapter.settings;
539
784
  const dest = path.join(root, relDest);
540
785
  const raw = exists(dest) ? fs.readFileSync(dest, 'utf8') : null;
541
786
  let parsed = null;
@@ -568,7 +813,13 @@ function hookSettingsAction(root, ide, relDest) {
568
813
  warn(`${relDest} does not parse — the ledger guard cannot be wired; fix the JSON, then re-run \`yad check --fix\``);
569
814
  return { ...base, status: 'modified', apply: () => {} };
570
815
  }
571
- const { changed } = mergeHookSettings(parsed);
816
+ const { changed, unreadable: unmergeable } = mergeHookSettings(parsed, adapter);
817
+ // Same treatment as a file that does not parse, and for the same reason: everything in it is the
818
+ // team's, there is no shipped template to restore, and `--overwrite-local` must not invent one.
819
+ if (unmergeable) {
820
+ warn(`${relDest}: ${unmergeable} — the ledger guard cannot be wired without discarding what is there; fix it by hand, then re-run \`yad check --fix\``);
821
+ return { ...base, status: 'modified', apply: () => {} };
822
+ }
572
823
  return {
573
824
  ...base,
574
825
  status: raw === null ? 'missing' : changed ? 'outdated' : 'ok',
@@ -592,79 +843,113 @@ function hookSettingsAction(root, ide, relDest) {
592
843
  warn(`${relDest} does not parse — left untouched; fix the JSON, then re-run \`yad check --fix\``);
593
844
  return;
594
845
  }
595
- writeJSON(dest, mergeHookSettings(current).settings);
846
+ const merged = mergeHookSettings(current, adapter);
847
+ if (merged.unreadable) {
848
+ warn(`${relDest}: ${merged.unreadable} — left untouched; fix it by hand, then re-run \`yad check --fix\``);
849
+ return;
850
+ }
851
+ writeJSON(dest, merged.settings);
596
852
  return;
597
853
  }
598
854
  fs.mkdirSync(path.dirname(dest), { recursive: true });
599
- writeJSON(dest, mergeHookSettings({}).settings);
855
+ writeJSON(dest, mergeHookSettings({}, adapter).settings);
600
856
  },
601
857
  };
602
858
  }
603
859
 
604
- // Harness-hook wiring on the hub: the guard script plus, per IDE target that defines a hook protocol,
605
- // the entry that invokes it. Bridge-gated exactly like `hubActions` — with no bridge the ledger is
860
+ // Harness-hook wiring on the Product: the guard script plus, per IDE target that defines a hook protocol,
861
+ // the entry that invokes it. Verified-only exactly like `productActions` — with a local ledger it is
606
862
  // locally owned, the hand-edit the authoring skills describe is CORRECT, and a guard would be wrong.
607
863
  export function hookActions(root, ideTargets = ideTargetsFor(root)) {
608
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
609
- if (!isBridgeHub(hub)) return [];
864
+ const hub = readJSON(productConfigPath(root));
865
+ if (!isVerifiedLedger(hub)) return [];
610
866
  const ledger = readManagedLedger(root);
611
867
  const actions = HOOK_WIRING.map((w) =>
612
868
  wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
613
869
  );
614
870
  for (const ide of safeIdeTargetsFor(root, ideTargets)) {
615
- const relDest = HOOK_SETTINGS[ide];
616
- if (relDest) actions.push(hookSettingsAction(root, ide, relDest));
871
+ const adapter = HOOK_ADAPTERS[ide];
872
+ if (!adapter) continue;
873
+ // A harness whose protocol the shared script cannot speak brings its own wrapper, and it is
874
+ // installed ONLY for a project that selected that target — an unused adapter script in a
875
+ // `.claude`-only tree is a file nobody can explain. It is pushed BEFORE the settings entry that
876
+ // points at it, so the two land in the order `asNew` already guarantees they land together.
877
+ for (const w of adapter.wiring) {
878
+ actions.push(wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }));
879
+ }
880
+ actions.push(hookSettingsAction(root, adapter));
617
881
  }
618
882
  // The two halves must land TOGETHER, so `missing` is relabelled `new` — the same relabel a new
619
883
  // first-party skill gets, and for the same reason: `yad update` (--scope=changed) excludes only
620
- // the literal 'missing'. Without it, an upgrade on a hub that already has a settings.json applies
884
+ // the literal 'missing'. Without it, an upgrade on a Product that already has a settings.json applies
621
885
  // the entry (`outdated`) while skipping the script (`missing`), leaving every file edit firing a
622
886
  // PreToolUse command that does not exist — a hook error per edit, and no guarding at all.
623
- return actions.map(asNewSkill);
887
+ return actions.map(asNew);
624
888
  }
625
889
 
626
- // Every email the verified-commits gate should accept as a known author: the hub roster's `email`
627
- // (or `emails`) fields plus hub.json's free-form `verified_authors` list. Lower-cased, deduped,
628
- // sorted — deterministic so the generated file is drift-checkable like any wired file.
629
- export function verifiedAuthorEmails(hub) {
630
- const out = new Set();
631
- for (const r of hub?.roster || []) {
632
- for (const e of [r.email, ...(Array.isArray(r.emails) ? r.emails : [])]) {
633
- if (e) out.add(String(e).toLowerCase());
890
+ // A target that has LEFT `ideTargets`, but whose wiring is still on disk and still running.
891
+ //
892
+ // Dropping `.cursor` used to leave `hooks/ledger-guard-cursor.sh` behind AND `.cursor/hooks.json`
893
+ // still invoking it on every write. That is not merely untidy: the entry keeps firing while nothing
894
+ // checks it for drift any more (every check here is keyed on the current targets), so the next
895
+ // release that changes the wrapper's protocol leaves that project running the old one, silently. The
896
+ // entry goes first and the script follows, and only a script no REMAINING target still needs is
897
+ // removed.
898
+ //
899
+ // Verified-only, like `hookActions`, and for the same reason: with a local ledger none of this was
900
+ // installed in the first place.
901
+ export function orphanHookActions(root, ideTargets = ideTargetsFor(root)) {
902
+ const hub = readJSON(productConfigPath(root));
903
+ if (!isVerifiedLedger(hub)) return [];
904
+ const kept = new Set(safeIdeTargetsFor(root, ideTargets));
905
+ const stillNeeded = new Set(
906
+ [...kept].flatMap((ide) => (HOOK_ADAPTERS[ide]?.wiring || []).map((w) => w.dest)),
907
+ );
908
+ const actions = [];
909
+ for (const adapter of Object.values(HOOK_ADAPTERS)) {
910
+ if (kept.has(adapter.target)) continue;
911
+ const settingsPath = path.join(root, adapter.settings);
912
+ if (exists(settingsPath)) {
913
+ // Read STRICTLY. A file that will not parse is the team's to fix, and rewriting it from a
914
+ // default would throw their whole harness config away — the same refusal the merge makes.
915
+ //
916
+ // Only OUR entry comes out. A `version` the merge added stays, because nothing on disk records
917
+ // whether we wrote it or the team did, and a key that might be theirs is not ours to delete —
918
+ // so a hooks.json we emptied is left as `{"version": 1}` rather than removed.
919
+ let parsed;
920
+ try { parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { /* refused below */ }
921
+ if (isPlainObject(parsed) && unmergeHookSettings(parsed, adapter).changed) {
922
+ actions.push({
923
+ scope: adapter.target,
924
+ item: `${path.basename(adapter.settings)} (removed)`,
925
+ status: 'removed',
926
+ root,
927
+ // Co-owned, exactly like the merge side: never staged into a `yad update --push` commit.
928
+ paths: [],
929
+ apply: () => {
930
+ let current;
931
+ try { current = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch { return; }
932
+ if (!isPlainObject(current)) return;
933
+ const { settings, changed } = unmergeHookSettings(current, adapter);
934
+ if (changed) writeJSON(settingsPath, settings);
935
+ },
936
+ });
937
+ }
938
+ }
939
+ for (const w of adapter.wiring) {
940
+ if (stillNeeded.has(w.dest)) continue;
941
+ const dest = path.join(root, w.dest);
942
+ if (!exists(dest)) continue;
943
+ actions.push({
944
+ scope: 'hub',
945
+ item: `${w.dest} (removed)`,
946
+ status: 'removed',
947
+ root,
948
+ paths: [w.dest],
949
+ apply: () => fs.rmSync(dest, { force: true }),
950
+ });
634
951
  }
635
952
  }
636
- for (const e of hub?.verified_authors || []) if (e) out.add(String(e).toLowerCase());
637
- return [...out].sort();
953
+ return actions;
638
954
  }
639
955
 
640
- // Generate .sdlc/verified-authors (one email per line) in the hub AND every registered repo, from
641
- // the hub config. No emails configured → no actions (the gate then warns instead of blocking —
642
- // never enforce an empty allowlist).
643
- export function authorsActions(root, repos = []) {
644
- const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
645
- const emails = verifiedAuthorEmails(hub);
646
- if (!emails.length) return [];
647
- const desired = [
648
- '# Generated by `yad check --fix` from .sdlc/hub.json (roster emails + verified_authors).',
649
- '# The verified-commits gate accepts only these author emails. Edit hub.json, not this file.',
650
- ...emails,
651
- ].join('\n') + '\n';
652
- const targets = [
653
- { scope: 'hub', root, dest: path.join(root, '.sdlc', 'verified-authors') },
654
- ...repos.map((r) => {
655
- const repoRoot = path.resolve(root, r.path);
656
- return { scope: r.name, root: repoRoot, dest: path.join(repoRoot, '.sdlc', 'verified-authors') };
657
- }),
658
- ];
659
- return targets.map(({ scope, root: targetRoot, dest }) => ({
660
- scope,
661
- item: '.sdlc/verified-authors',
662
- status: !exists(dest) ? 'missing' : fs.readFileSync(dest, 'utf8') === desired ? 'ok' : 'outdated',
663
- root: targetRoot,
664
- paths: [rel(targetRoot, dest)],
665
- apply: () => {
666
- fs.mkdirSync(path.dirname(dest), { recursive: true });
667
- fs.writeFileSync(dest, desired);
668
- },
669
- }));
670
- }