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.
- package/CHANGELOG.md +371 -0
- package/README.md +160 -22
- package/bin/commands.mjs +46 -0
- package/bin/yad.mjs +506 -117
- package/cli/artifact-status.mjs +30 -11
- package/cli/capture.mjs +417 -0
- package/cli/checkpoint.mjs +47 -27
- package/cli/claims.mjs +165 -0
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1682 -90
- package/cli/epic-state.mjs +3460 -211
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +3 -0
- package/cli/fold.mjs +247 -0
- package/cli/gate-local.mjs +296 -0
- package/cli/gate.mjs +977 -183
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +264 -53
- package/cli/hubcommit.mjs +9 -19
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +54 -4
- package/cli/lib.mjs +161 -9
- package/cli/manifest.mjs +376 -38
- package/cli/migrate.mjs +683 -52
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +365 -86
- package/cli/openpr.mjs +193 -34
- package/cli/owners.mjs +266 -0
- package/cli/people.mjs +657 -0
- package/cli/plan.mjs +466 -125
- package/cli/platform.mjs +109 -128
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +40 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +24 -14
- package/cli/review.mjs +19 -22
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +127 -279
- package/cli/ship.mjs +6 -5
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +41 -16
- package/cli/tidy.mjs +18 -18
- package/cli/update-commit.mjs +21 -21
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +149 -70
- package/skills/sdlc/module-help.csv +24 -24
- package/skills/yad-analysis/SKILL.md +121 -59
- package/skills/yad-architecture/SKILL.md +30 -18
- package/skills/yad-architecture/references/contract-format.md +8 -5
- package/skills/yad-backfill/SKILL.md +10 -4
- package/skills/yad-backfill/references/backfill.md +8 -1
- package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
- package/skills/yad-change/SKILL.md +119 -42
- package/skills/yad-change/references/triage.md +42 -27
- package/skills/yad-checks/SKILL.md +68 -35
- package/skills/yad-checks/references/check-gates.md +310 -85
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
- package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
- package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +13 -2
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +3 -3
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
- package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
- package/skills/yad-commit/SKILL.md +4 -4
- package/skills/yad-connect-design/SKILL.md +5 -5
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +11 -11
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +66 -45
- package/skills/yad-connect-repos/references/code-context.md +4 -4
- package/skills/yad-connect-repos/references/hub-config.md +47 -51
- package/skills/yad-connect-repos/references/repos-registry.md +8 -7
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +5 -5
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +7 -7
- package/skills/yad-discovery/SKILL.md +131 -94
- package/skills/yad-discovery/references/discovery-schema.md +22 -6
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +13 -8
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +30 -15
- package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +81 -17
- package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
- package/skills/yad-epic/SKILL.md +173 -92
- package/skills/yad-epic/references/state-schema.md +610 -94
- package/skills/yad-hub-bridge/SKILL.md +57 -38
- package/skills/yad-hub-bridge/references/bridge.md +94 -67
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +21 -11
- package/skills/yad-implement/SKILL.md +27 -13
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +61 -26
- package/skills/yad-pair-review/SKILL.md +7 -5
- package/skills/yad-pair-review/references/session-state.md +1 -1
- package/skills/yad-pr-template/SKILL.md +47 -26
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
- package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +14 -13
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +6 -3
- package/skills/yad-review-gate/SKILL.md +203 -93
- package/skills/yad-review-gate/references/gating.md +232 -54
- package/skills/yad-run/SKILL.md +81 -51
- package/skills/yad-run/references/run-loop.md +61 -39
- package/skills/yad-ship/SKILL.md +15 -11
- package/skills/yad-spec/SKILL.md +21 -6
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +115 -50
- package/skills/yad-stories/SKILL.md +37 -21
- package/skills/yad-stories/references/story-schema.md +7 -6
- package/skills/yad-stub/SKILL.md +58 -47
- package/skills/yad-sync-repos/SKILL.md +2 -2
- package/skills/yad-test-cases/SKILL.md +32 -23
- package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +41 -18
- package/cli/roster.mjs +0 -164
- 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,
|
|
12
|
-
HOOK_WIRING,
|
|
13
|
-
LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES,
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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 : [
|
|
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.
|
|
266
|
-
//
|
|
267
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(
|
|
562
|
+
const hub = readJSON(productConfigPath(root));
|
|
445
563
|
if (!isVerifiedLedger(hub)) return [];
|
|
446
|
-
const wiring = [...
|
|
447
|
-
return legacyFileActions('hub', root,
|
|
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
|
-
//
|
|
479
|
-
// platform and the verified ledger is explicitly enabled — a local
|
|
480
|
-
export function
|
|
481
|
-
const hub = readJSON(
|
|
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 [...
|
|
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
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
501
|
-
// deliberately never a substring test: `includes('hooks/ledger-guard.sh')` would also
|
|
502
|
-
// own wrapper at `.claude/hooks/ledger-guard.sh` and silently rewrite it to ours, on
|
|
503
|
-
// path that takes no backup. Matching exactly means the worst case is a second entry
|
|
504
|
-
// twice — harmless) instead of someone else's hook disappearing.
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
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 (
|
|
689
|
+
if (entry.command === adapter.command) continue;
|
|
690
|
+
entry.command = adapter.command;
|
|
524
691
|
changed = true;
|
|
525
|
-
|
|
526
|
-
});
|
|
692
|
+
}
|
|
527
693
|
}
|
|
528
|
-
if (!found) {
|
|
529
|
-
hooks.
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
547
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
624
|
-
// the entry that invokes it. Verified-only exactly like `
|
|
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(
|
|
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
|
|
635
|
-
if (
|
|
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
|
|
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(
|
|
893
|
+
return actions.map(asNew);
|
|
643
894
|
}
|
|
644
895
|
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
656
|
-
return [...out].sort();
|
|
959
|
+
return actions;
|
|
657
960
|
}
|
|
658
961
|
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
+
|