@cohortapp/agent-sdk 2.12.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +86 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/lib/telemetry/collect.mjs +357 -5
  25. package/lib/telemetry/collect.test.mjs +285 -0
  26. package/package.json +1 -1
  27. package/plugins/maestro-skills/plugin.json +4 -0
  28. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  42. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  43. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  44. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  45. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  46. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  47. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  50. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  51. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  89. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  90. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  95. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  96. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  97. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  98. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  99. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  100. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  105. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  106. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  113. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  114. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  116. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  117. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  118. package/scripts/ci/check-skill-packs.mjs +388 -0
  119. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  120. package/scripts/ci/check.mjs +3 -0
  121. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  122. package/scripts/daemon/agent-daemon.mjs +108 -0
  123. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  124. package/scripts/daemon/cadence-consumer.mjs +46 -22
  125. package/scripts/daemon/prompt-builder.mjs +19 -3
  126. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  127. package/scripts/vendor/skill-packs.mjs +354 -0
  128. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  129. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-skill-packs.mjs — integrity ratchet for the vendored design skill packs.
4
+ *
5
+ * WP-M7 ships four upstream skill packs inside the tarball
6
+ * (`plugins/maestro-skills/vendor/<pack>/`), and `global-setup` copies them into
7
+ * `~/.claude/skills/` on every seat. That makes them prompt content executed by
8
+ * every session on the fleet, sourced from repositories we do not control.
9
+ *
10
+ * **What this guard does and does not establish.** It cannot prove provenance:
11
+ * nothing offline can show that a vendored byte came from the pinned upstream
12
+ * commit, because `UPSTREAM.json` is written from the very bytes it records.
13
+ * What it establishes is that the vendored trees have not moved since a human
14
+ * reviewed them — and it makes moving them require editing a file OUTSIDE the
15
+ * vendored tree, so the change cannot hide inside a data blob. Re-verifying
16
+ * against upstream is a networked maintainer step:
17
+ * `node scripts/vendor/sync-skill-packs.mjs --all --dry-run`.
18
+ *
19
+ * Six assertions, per pack:
20
+ *
21
+ * 1. **Manifest fidelity.** Every path in `UPSTREAM.json` exists and hashes to
22
+ * the recorded sha256, and no file exists in the pack that the manifest
23
+ * does not list. A tampered byte and a smuggled extra file both go red.
24
+ * 2. **Content pin.** `manifestDigest(files)` matches the `contentSha256`
25
+ * pinned for the pack in `scripts/vendor/skill-packs.mjs`. This is what
26
+ * closes assertion 1's loop: a manifest rewritten to bless a new file is
27
+ * self-consistent and would otherwise pass, and the pack's rules are
28
+ * *patterns* (`reference/**.md`), so the allow-list alone admits a file
29
+ * that never existed upstream. Adding one now also has to change a pinned
30
+ * hex string in a reviewed source file.
31
+ * 3. **Allow-list.** Every vendored path is one the pack's rules admit — the
32
+ * shape check that keeps a re-sync from widening what a pack may carry.
33
+ * 4. **No hooks, in manifest OR in machinery.** No vendored file is a hook
34
+ * manifest (a `hooks.json`/`settings.json`, or JSON with a lifecycle-keyed
35
+ * `hooks` object), and no vendored file is a hook installer or hook body by
36
+ * name ({@link HOOK_SCRIPT_BASENAMES}). unlazy's Stop hook is not merely
37
+ * "not installed": its two scripts are not vendored, because a file that
38
+ * reaches the seat can be run whatever an executable bit says.
39
+ * 5. **Reviewed supply-chain surface.** Every fetch-and-execute instruction in
40
+ * vendored prose — `curl … | sh`, a fetch to a non-GitHub host, `npx <pkg>`,
41
+ * `npm install`, `pip install`, `git clone`, a remote script/asset URL in
42
+ * sample markup — yields a token that must appear in the pack's
43
+ * `acknowledged` list, and each acknowledgement's `covered` literal must
44
+ * appear in the harmoniser skill. A re-sync that pulls in a NEW such
45
+ * instruction stops until someone reviews it and says what to do instead.
46
+ * 6. **No executables** except where a pack declares one (unlazy's `scripts/`).
47
+ *
48
+ * Pure, dependency-light: Node builtins only. ESM.
49
+ *
50
+ * Usage: `node scripts/ci/check-skill-packs.mjs` (exit 0 ok / 1 violations / 2 could not run)
51
+ * @module scripts/ci/check-skill-packs
52
+ */
53
+
54
+ "use strict";
55
+
56
+ import { createHash } from "node:crypto";
57
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
58
+ import path from "node:path";
59
+ import { fileURLToPath } from "node:url";
60
+ import {
61
+ PACKS, isAllowedDest, isExecAllowed, posix,
62
+ FETCH_HOST_ALLOWLIST, HOOK_SCRIPT_BASENAMES, manifestDigest,
63
+ } from "../vendor/skill-packs.mjs";
64
+
65
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
66
+
67
+ /** Where vendored packs live, relative to the repo root. */
68
+ export const VENDOR_REL = path.join("plugins", "maestro-skills", "vendor");
69
+
70
+ /** The harmoniser skill, which every acknowledgement must be answered in. */
71
+ export const HARMONISER_REL = path.join("plugins", "maestro-skills", "skills", "cohort-design.md");
72
+
73
+ /** Claude Code lifecycle events; a JSON `hooks` object keyed by one is a manifest. */
74
+ export const HOOK_EVENT_KEYS = Object.freeze([
75
+ "PreToolUse", "PostToolUse", "Stop", "SubagentStop", "SessionStart", "SessionEnd",
76
+ "UserPromptSubmit", "PreCompact", "Notification",
77
+ ]);
78
+
79
+ /** Filenames that are hook/settings manifests whatever they contain. */
80
+ export const MANIFEST_BASENAMES = Object.freeze(["hooks.json", "settings.json", "settings.local.json"]);
81
+
82
+ /**
83
+ * The supply-chain rules. Each one names a way for vendored prose to tell a
84
+ * reader "retrieve something from the network and run it" — the capability the
85
+ * rule is about, not one command's spelling.
86
+ *
87
+ * This started as a single `curl|wget|fetch` regex, which was the wrong shape
88
+ * twice over: `npx some-package`, `pip install --index-url …`, `git clone` and
89
+ * a CDN `<script src>` all fetch and execute without matching it, and a
90
+ * `curl … | sh` whose URL is on `raw.githubusercontent.com` matched but was
91
+ * waved through, even though anyone may publish anything there.
92
+ *
93
+ * `host` names the capture group holding a URL (the token carries its host);
94
+ * `cap` names a group holding a package specifier; neither means the whole
95
+ * match is the token. `allowHost: false` means the GitHub allow-list does NOT
96
+ * apply — piping a download into a shell is the same act wherever it is hosted.
97
+ *
98
+ * @type {ReadonlyArray<{id:string, re:RegExp, host?:number, cap?:number, allowHost?:boolean, label:string}>}
99
+ */
100
+ export const SUPPLY_CHAIN_RULES = Object.freeze([
101
+ {
102
+ id: "pipe-exec",
103
+ label: "downloads and pipes straight into a shell",
104
+ re: /\b(?:curl|wget)\b[^\n|]{0,200}?(https?:\/\/[^\s"'`)<>]+)[^\n|]{0,80}\|\s*(?:sudo\s+)?(?:sh|bash|zsh|node|python[23]?|ruby|perl)\b/gi,
105
+ host: 1,
106
+ allowHost: false,
107
+ },
108
+ {
109
+ id: "fetch-exec",
110
+ label: "fetches from a host outside GitHub",
111
+ re: /(?:\b(?:curl|wget)\b[^\n]{0,200}?|\bfetch\s*\(\s*["'`]|\bInvoke-(?:WebRequest|RestMethod)\b[^\n]{0,120}?)(https?:\/\/[^\s"'`)<>]+)/gi,
112
+ host: 1,
113
+ },
114
+ {
115
+ id: "git-clone",
116
+ label: "clones a repository",
117
+ re: /\bgit\s+clone\b[^\n]{0,160}?(https?:\/\/[^\s"'`)<>]+)/gi,
118
+ host: 1,
119
+ },
120
+ {
121
+ id: "npx",
122
+ label: "runs a package straight from a registry",
123
+ re: /\bnpx\s+(?:--?[\w-]+(?:[= ][^\s]+)?\s+)*(@?[\w.@/-]+)/gi,
124
+ cap: 1,
125
+ },
126
+ {
127
+ id: "pkg-install",
128
+ label: "installs a package from a registry",
129
+ re: /\b(?:npm|pnpm|yarn|bun)\s+(?:install|add)\b(?!\s*$)/gi,
130
+ },
131
+ {
132
+ id: "pip-install",
133
+ label: "installs a package from a registry",
134
+ re: /\bpip3?\s+install\b/gi,
135
+ },
136
+ {
137
+ id: "remote-asset",
138
+ label: "points shipped markup at a remote asset",
139
+ re: /(?:src\s*=\s*["'`]|url\s*\(\s*["'`]?)(https?:\/\/[^\s"'`)<>]+)/gi,
140
+ host: 1,
141
+ },
142
+ {
143
+ // A URL with an interpolation placeholder is not a citation — it is a
144
+ // template the reader is told to emit into code. That is what separates
145
+ // "https://picsum.photos/seed/{name}/1920/1080" from a link in prose.
146
+ id: "template-url",
147
+ label: "is a URL template for generated markup",
148
+ re: /(https?:\/\/[^\s"'`)<>]*\{[^\s"'`)<>]*)/gi,
149
+ host: 1,
150
+ },
151
+ ]);
152
+
153
+ /**
154
+ * Wording that walks a reader through installing a hook, in a file that is not
155
+ * itself a manifest. `reference/hooks.md` is prose and so invisible to the
156
+ * no-manifest rule, but the shipped risk of a hook is the instruction to make
157
+ * one, not the JSON.
158
+ */
159
+ const HOOK_DOC_RE = /(?:settings(?:\.local)?\.json|hooks\.json)/i;
160
+
161
+ /** @param {string} u @returns {string|null} lowercased host, or null when unparseable */
162
+ export function hostOf(u) {
163
+ try { return new URL(u.split("{")[0]).host.toLowerCase() || null; } catch { return null; }
164
+ }
165
+
166
+ /**
167
+ * Every supply-chain token in `body`, with an example of each. Pure.
168
+ *
169
+ * A token is coarse on purpose — `pkg-install`, `npx:<package>`,
170
+ * `remote-asset:<host>` — because the reviewable unit is the capability and its
171
+ * counterparty, not the twelve `npm install` lines a style guide happens to
172
+ * list.
173
+ *
174
+ * @param {string} body
175
+ * @param {string} [rel] the file's pack-relative path, for the hook-doc rule
176
+ * @returns {Array<{token:string, label:string, sample:string}>}
177
+ */
178
+ export function findSupplyChainTokens(body, rel = "") {
179
+ const text = String(body || "");
180
+ const seen = new Map();
181
+ const add = (token, label, sample) => {
182
+ if (!seen.has(token)) seen.set(token, { token, label, sample: sample.slice(0, 90).replace(/\s+/g, " ") });
183
+ };
184
+ for (const rule of SUPPLY_CHAIN_RULES) {
185
+ rule.re.lastIndex = 0;
186
+ let m;
187
+ while ((m = rule.re.exec(text)) !== null) {
188
+ let token = rule.id;
189
+ if (rule.host != null) {
190
+ const host = hostOf(m[rule.host]);
191
+ if (!host) continue;
192
+ if (rule.allowHost !== false && FETCH_HOST_ALLOWLIST.includes(host)) continue;
193
+ token = `${rule.id}:${host}`;
194
+ } else if (rule.cap != null) {
195
+ token = `${rule.id}:${m[rule.cap]}`;
196
+ }
197
+ add(token, rule.label, m[0]);
198
+ }
199
+ }
200
+ if (/\.md$/i.test(posix(rel)) && HOOK_DOC_RE.test(text) && /\bhooks?\b/i.test(text)) {
201
+ const line = text.split("\n").find((l) => HOOK_DOC_RE.test(l)) || "";
202
+ add("hook-install-doc", "documents installing a hook into a settings file", line);
203
+ }
204
+ return [...seen.values()].sort((a, b) => (a.token < b.token ? -1 : 1));
205
+ }
206
+
207
+ /**
208
+ * Fetch instructions in `body` that point outside GitHub. Kept as the narrow
209
+ * predicate it always was; {@link findSupplyChainTokens} is the rule the guard
210
+ * runs. Pure.
211
+ * @param {string} body
212
+ * @returns {Array<{url:string, host:string}>}
213
+ */
214
+ export function findForeignFetches(body) {
215
+ return findSupplyChainTokens(body)
216
+ .filter((t) => t.token.startsWith("fetch-exec:") || t.token.startsWith("pipe-exec:"))
217
+ .map((t) => ({ url: t.sample, host: t.token.slice(t.token.indexOf(":") + 1) }));
218
+ }
219
+
220
+ /**
221
+ * Is this file a hook manifest? Name-based for the well-known manifest files,
222
+ * content-based for any JSON carrying a lifecycle-keyed `hooks` object. Pure.
223
+ * @param {string} rel @param {string} body
224
+ * @returns {string|null} the reason, or null when clean
225
+ */
226
+ export function hookManifestReason(rel, body) {
227
+ const base = posix(rel).split("/").pop();
228
+ if (MANIFEST_BASENAMES.includes(base)) return `${base} is a hook/settings manifest`;
229
+ // A hook INSTALLER on the seat is worse than a hook manifest in the tarball:
230
+ // it is one `node <path>` away from writing a Stop hook into the settings
231
+ // file every session reads, and the executable bit it does not need is the
232
+ // only thing the mode check would have looked at.
233
+ if (HOOK_SCRIPT_BASENAMES.includes(base)) return `${base} is hook machinery — vendor the prose, never the installer`;
234
+ if (!base.endsWith(".json")) return null;
235
+ let doc;
236
+ try { doc = JSON.parse(String(body)); } catch { return null; }
237
+ const hooks = doc && typeof doc === "object" ? doc.hooks : null;
238
+ if (!hooks || typeof hooks !== "object") return null;
239
+ const events = Object.keys(hooks).filter((k) => HOOK_EVENT_KEYS.includes(k));
240
+ if (events.length === 0) return null;
241
+ return `JSON carries a hooks manifest for ${events.join(", ")}`;
242
+ }
243
+
244
+ /** Recursively list files under `dir` relative to it (POSIX), sorted. */
245
+ export function listFiles(dir, prefix = "") {
246
+ let out = [];
247
+ let entries;
248
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
249
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
250
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
251
+ if (e.isDirectory()) out = out.concat(listFiles(path.join(dir, e.name), rel));
252
+ else out.push(rel);
253
+ }
254
+ return out;
255
+ }
256
+
257
+ /** Collapse runs of whitespace so a hard-wrapped sentence matches itself. Pure. */
258
+ export function flatten(text) {
259
+ return String(text || "").replace(/\s+/g, " ").trim();
260
+ }
261
+
262
+ /** File extensions worth scanning for text rules. */
263
+ const TEXT_EXT = new Set([".md", ".json", ".mjs", ".js", ".txt", ".yaml", ".yml", ".sh", ""]);
264
+
265
+ /**
266
+ * Check every pack. Returns `{ok, checked, violations[]}` — never throws for a
267
+ * violation; a missing vendor tree IS a violation (the tarball would ship a
268
+ * global-setup step with nothing to install).
269
+ *
270
+ * @param {object} [o] @param {string} [o.repoRoot] @param {Array} [o.packs]
271
+ */
272
+ export function checkSkillPacks(o = {}) {
273
+ const repoRoot = o.repoRoot || REPO_ROOT;
274
+ const packs = o.packs || PACKS;
275
+ const violations = [];
276
+ let checked = 0;
277
+ let harmoniser = null;
278
+ // Whitespace-flattened: the harmoniser is hard-wrapped markdown, so a phrase
279
+ // that reads as one sentence is two lines on disk. Matching the flattened
280
+ // text lets an acknowledgement quote the sentence, not a fragment that
281
+ // happens to fall between two wraps.
282
+ try { harmoniser = flatten(readFileSync(path.join(repoRoot, HARMONISER_REL), "utf8")); } catch { harmoniser = null; }
283
+ for (const pack of packs) {
284
+ const acknowledged = new Set((pack.acknowledged || []).map((a) => a && a.token));
285
+ const dir = path.join(repoRoot, VENDOR_REL, pack.id);
286
+ const add = (file, reason) => violations.push({ pack: pack.id, file, reason });
287
+ if (!existsSync(dir)) { add(`${VENDOR_REL}/${pack.id}`, "vendored pack directory is missing (run scripts/vendor/sync-skill-packs.mjs)"); continue; }
288
+ const manifestPath = path.join(dir, "UPSTREAM.json");
289
+ let manifest;
290
+ try {
291
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
292
+ } catch (err) {
293
+ add("UPSTREAM.json", `unreadable or not JSON: ${err && err.message}`);
294
+ continue;
295
+ }
296
+ if (manifest.repo !== pack.repo) add("UPSTREAM.json", `repo ${manifest.repo} does not match the pinned ${pack.repo}`);
297
+ if (manifest.sha !== pack.sha) add("UPSTREAM.json", `sha ${manifest.sha} does not match the pinned ${pack.sha}`);
298
+ if (manifest.license !== pack.license) add("UPSTREAM.json", `license ${manifest.license} does not match the pinned ${pack.license}`);
299
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0) { add("UPSTREAM.json", "files[] is empty — the manifest records nothing"); continue; }
300
+
301
+ // Assertion 2: the manifest as a whole is pinned in skill-packs.mjs, so a
302
+ // manifest rewritten to bless a file the *patterns* would admit still fails.
303
+ const digest = manifestDigest(manifest.files);
304
+ if (!pack.contentSha256) {
305
+ add("UPSTREAM.json", `pack declares no contentSha256 — pin ${digest} in scripts/vendor/skill-packs.mjs`);
306
+ } else if (digest !== pack.contentSha256) {
307
+ add("UPSTREAM.json", `manifest digest ${digest} does not match the pinned contentSha256 ${pack.contentSha256} — the vendored file list changed`);
308
+ }
309
+
310
+ const onDisk = listFiles(dir).filter((f) => f !== "UPSTREAM.json");
311
+ const listed = new Set();
312
+ for (const entry of manifest.files) {
313
+ const rel = posix(entry && entry.path);
314
+ if (!rel) { add("UPSTREAM.json", "files[] entry with no path"); continue; }
315
+ listed.add(rel);
316
+ const abs = path.join(dir, rel);
317
+ if (!existsSync(abs)) { add(rel, "listed in UPSTREAM.json but missing on disk"); continue; }
318
+ checked += 1;
319
+ const actual = createHash("sha256").update(readFileSync(abs)).digest("hex");
320
+ if (actual !== entry.sha256) add(rel, `sha256 mismatch: expected ${entry.sha256}, found ${actual}`);
321
+ if (!isAllowedDest(pack, rel)) add(rel, "outside the pack's allow-list in scripts/vendor/skill-packs.mjs");
322
+ }
323
+ for (const rel of onDisk) {
324
+ if (!listed.has(rel)) add(rel, "present in the vendored tree but not listed in UPSTREAM.json");
325
+ if (!isAllowedDest(pack, rel)) add(rel, "outside the pack's allow-list in scripts/vendor/skill-packs.mjs");
326
+ }
327
+
328
+ for (const rel of onDisk) {
329
+ const abs = path.join(dir, rel);
330
+ let st;
331
+ try { st = statSync(abs); } catch { continue; }
332
+ if ((st.mode & 0o111) !== 0 && !isExecAllowed(pack, rel)) add(rel, "carries an executable bit (vendored skill content is data, never a program)");
333
+ const ext = path.extname(rel).toLowerCase();
334
+ if (!TEXT_EXT.has(ext)) continue;
335
+ let body;
336
+ try { body = readFileSync(abs, "utf8"); } catch { continue; }
337
+ const hook = hookManifestReason(rel, body);
338
+ if (hook) add(rel, hook);
339
+ for (const t of findSupplyChainTokens(body, rel)) {
340
+ if (acknowledged.has(t.token)) continue;
341
+ add(rel, `unreviewed supply-chain instruction — it ${t.label}: "${t.sample}". Token: ${t.token}. Either the pack should not carry it, or add {token, why, covered} to this pack's acknowledged[] in scripts/vendor/skill-packs.mjs and say what to do instead in ${HARMONISER_REL}.`);
342
+ }
343
+ }
344
+
345
+ // Assertion 5b: an acknowledgement is only a review if the harmoniser tells
346
+ // a reader what to do instead. An `acknowledged` entry whose `covered`
347
+ // literal is absent from cohort-design.md is a note to nobody.
348
+ for (const a of pack.acknowledged || []) {
349
+ if (!a || !a.covered) { add("skill-packs.mjs", `acknowledged entry ${a && a.token} has no covered literal`); continue; }
350
+ if (harmoniser === null) continue; // reported once, below
351
+ if (!harmoniser.includes(flatten(a.covered))) {
352
+ add("cohort-design.md", `acknowledges ${a.token} but the harmoniser skill never says "${a.covered}" — a capability nobody was told to avoid is not reviewed`);
353
+ }
354
+ }
355
+ }
356
+ if (harmoniser === null && packs.some((p) => (p.acknowledged || []).length > 0)) {
357
+ violations.push({ pack: "-", file: "cohort-design.md", reason: "the harmoniser skill is missing or unreadable, so no acknowledgement can be checked against it" });
358
+ }
359
+ return { ok: violations.length === 0, checked, violations };
360
+ }
361
+
362
+ /** @param {string} [repoRoot] @returns {Promise<number>} */
363
+ export async function run(repoRoot = REPO_ROOT) {
364
+ let result;
365
+ try {
366
+ result = checkSkillPacks({ repoRoot });
367
+ } catch (err) {
368
+ console.error(`check-skill-packs: ERROR — ${err && err.message ? err.message : err}`);
369
+ return 2;
370
+ }
371
+ if (result.ok) {
372
+ console.log(`check-skill-packs: OK (${PACKS.length} pack(s), ${result.checked} vendored file(s) verified against UPSTREAM.json)`);
373
+ return 0;
374
+ }
375
+ console.error("check-skill-packs: FAIL — vendored skill packs do not match their pinned manifests:");
376
+ for (const v of result.violations) console.error(` ${v.pack}/${v.file} :: ${v.reason}`);
377
+ console.error(" Fix: re-run `node scripts/vendor/sync-skill-packs.mjs <pack>` and review the diff — never hand-edit a vendored tree.");
378
+ return 1;
379
+ }
380
+
381
+ if (import.meta.url === `file://${process.argv[1]}`) {
382
+ run().then((c) => process.exit(c)).catch((e) => { console.error("check-skill-packs: ERROR", e && e.message ? e.message : e); process.exit(2); });
383
+ }
384
+
385
+ export default {
386
+ run, checkSkillPacks, findForeignFetches, findSupplyChainTokens, hookManifestReason,
387
+ listFiles, VENDOR_REL, HARMONISER_REL, SUPPLY_CHAIN_RULES,
388
+ };