@deftai/directive 0.66.2 → 0.68.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.
package/dist/dispatch.js CHANGED
@@ -3,8 +3,9 @@
3
3
  * Routes to ported command modules in packages/cli and packages/core.
4
4
  */
5
5
  import { spawnSync } from "node:child_process";
6
- import { existsSync, mkdirSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "node:fs";
7
- import { homedir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
8
9
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
9
10
  import { engineInfo } from "@deftai/directive-core";
10
11
  import { parseInitArgv, runInitDepositCli, userConfigDir, } from "@deftai/directive-core/init-deposit";
@@ -50,6 +51,7 @@ export const CLI_MODULE_VERBS = [
50
51
  "pr-monitor",
51
52
  "pr-protected-issues",
52
53
  "pr-wait-mergeable",
54
+ "pr-watch",
53
55
  "preflight-cache",
54
56
  "preflight-gh",
55
57
  "probe-session",
@@ -85,12 +87,14 @@ export const CLI_MODULE_VERBS = [
85
87
  "vbrief-validation",
86
88
  "verify-branch",
87
89
  "verify-encoding",
90
+ "verify-forward-coverage",
88
91
  "verify-hooks-installed",
89
92
  "verify-investigation",
90
93
  "verify-judgment-gates",
91
94
  "verify-no-task-runtime",
92
95
  "validate-links",
93
96
  "validate-strategy-output",
97
+ "verify-biome-config",
94
98
  "verify-bridge-drift",
95
99
  "verify-capacity",
96
100
  "verify-content-manifest",
@@ -105,6 +109,8 @@ export const CLI_MODULE_VERBS = [
105
109
  "verify-story-ready",
106
110
  "verify-tools",
107
111
  "verify-wip-cap",
112
+ "verify-agents-md-budget",
113
+ "verify-agents-md-advisory",
108
114
  ];
109
115
  /** Core-only CLI entrypoints without a packages/cli wrapper. */
110
116
  export const CORE_MODULE_VERBS = [
@@ -116,6 +122,7 @@ export const CORE_MODULE_VERBS = [
116
122
  "reconcile-issues",
117
123
  "swarm-launch",
118
124
  "swarm-complete-cohort",
125
+ "swarm-finalize-cohort",
119
126
  "swarm-readiness",
120
127
  "swarm-routing-verify",
121
128
  "swarm-routing-set",
@@ -159,9 +166,12 @@ const TRIAGE_ACTION_COLON_ALIASES = Object.fromEntries(Object.keys(TRIAGE_ACTION
159
166
  /** Task-style aliases (framework_commands / Taskfile names). */
160
167
  export const VERB_ALIASES = {
161
168
  "verify:encoding": "verify-encoding",
169
+ "verify:forward-coverage": "verify-forward-coverage",
162
170
  "verify:branch": "verify-branch",
163
171
  "verify:vbrief-conformance": "vbrief-validate",
164
172
  "verify:wip-cap": "verify-wip-cap",
173
+ "verify:agents-md-budget": "verify-agents-md-budget",
174
+ "verify:agents-md-advisory": "verify-agents-md-advisory",
165
175
  "verify:hooks-installed": "verify-hooks-installed",
166
176
  "verify:no-task-runtime": "verify-no-task-runtime",
167
177
  "vbrief:validate": "vbrief-validate",
@@ -176,6 +186,7 @@ export const VERB_ALIASES = {
176
186
  "validate:links": "validate-links",
177
187
  "verify:rule-ownership": "rule-ownership-lint",
178
188
  "rule:ownership-lint": "rule-ownership-lint",
189
+ "verify:biome-config": "verify-biome-config",
179
190
  "verify:content-manifest": "verify-content-manifest",
180
191
  "verify:contract-drift": "verify-contract-drift",
181
192
  "verify:cursor-tier1": "verify-cursor-tier1",
@@ -218,6 +229,7 @@ export const VERB_ALIASES = {
218
229
  const SUBDIR_CLI_STEMS = {
219
230
  "verify-stubs": "verify-source-cli/verify-stubs",
220
231
  "rule-ownership-lint": "verify-source-cli/rule-ownership-lint",
232
+ "verify-biome-config": "verify-source-cli/verify-biome-config",
221
233
  "verify-content-manifest": "verify-source-cli/verify-content-manifest",
222
234
  "verify-contract-drift": "verify-source-cli/verify-contract-drift",
223
235
  "verify-cursor-tier1": "verify-source-cli/verify-cursor-tier1",
@@ -711,40 +723,115 @@ function extractExtraFrontmatter(frontmatter) {
711
723
  extra.pop();
712
724
  return extra.length ? extra.join("\n") : null;
713
725
  }
714
- const ROUTING_HEADING = "## Skill Routing";
715
- const ROUTING_PATH_RE = /`(?:content\/)?(skills\/[^`]+\/SKILL\.md)`/;
716
- const ARROW_SPLIT_RE = /\u2192|->/;
717
- function parseRouting(agentsMd) {
726
+ // Skill trigger keywords are sourced from durable, post-#838 surfaces rather
727
+ // than the removed AGENTS.md "## Skill Routing" table (#838 / #2152). Priority:
728
+ // 1. each SKILL.md frontmatter `triggers:` list (the skill's own contract);
729
+ // 2. the REFERENCES.md "Skills Index" table (the #838 single source of truth).
730
+ // This decouples the skills pack from AGENTS.md, so adding a skill no longer
731
+ // requires editing the always-loaded policy file and the trigger map stays
732
+ // non-empty after #838 removed the heading parseRouting used to read.
733
+ const SKILLS_INDEX_HEADING_RE = /Skills Index/i;
734
+ const HEADING_LINE_RE = /^#{1,6}\s/;
735
+ const SKILL_LINK_RE = /\(([^)]*skills\/[^)]+\/SKILL\.md)\)/;
736
+ const BACKTICK_TOKEN_RE = /`([^`]+)`/g;
737
+ /** Normalize a REFERENCES.md skill link path to the `skills/<name>/SKILL.md` key. */
738
+ function normalizeSkillIndexPath(linkPath) {
739
+ let p = linkPath.trim();
740
+ if (p.startsWith("./"))
741
+ p = p.slice(2);
742
+ const marker = p.indexOf("skills/");
743
+ return marker >= 0 ? p.slice(marker) : p;
744
+ }
745
+ /**
746
+ * Parse the REFERENCES.md "Skills Index" table into a `skills/<name>/SKILL.md`
747
+ * -> triggers map. Skill rows are identified by their SKILL.md link (so the
748
+ * header and separator rows are skipped); the trigger cell is the last
749
+ * pipe-delimited column and its keywords are the backtick-quoted tokens.
750
+ */
751
+ function parseSkillsIndexTriggers(referencesMd) {
718
752
  const mapping = new Map();
719
- const start = agentsMd.indexOf(ROUTING_HEADING);
720
- if (start === -1)
721
- return mapping;
722
- const rest = agentsMd.slice(start + ROUTING_HEADING.length);
723
- const end = rest.indexOf("\n## ");
724
- const section = end !== -1 ? rest.slice(0, end) : rest;
725
- for (const raw of splitLines(section)) {
753
+ let inSection = false;
754
+ for (const raw of splitLines(referencesMd)) {
726
755
  const line = raw.trim();
727
- if (!line.startsWith("- "))
756
+ if (HEADING_LINE_RE.test(line)) {
757
+ inSection = SKILLS_INDEX_HEADING_RE.test(line);
728
758
  continue;
729
- const pathMatch = ROUTING_PATH_RE.exec(line);
730
- if (!pathMatch)
731
- continue;
732
- const path = pathMatch[1] ?? "";
733
- const head = line.split(ARROW_SPLIT_RE)[0] ?? "";
734
- const keywords = (head.match(/"[^"]+"/g) ?? []).map((quoted) => quoted.slice(1, -1));
735
- let bucket = mapping.get(path);
736
- if (!bucket) {
737
- bucket = [];
738
- mapping.set(path, bucket);
739
759
  }
740
- for (const keyword of keywords) {
741
- if (!bucket.includes(keyword))
760
+ if (!inSection || !line.startsWith("|"))
761
+ continue;
762
+ const linkMatch = SKILL_LINK_RE.exec(line);
763
+ if (!linkMatch)
764
+ continue;
765
+ const path = normalizeSkillIndexPath(linkMatch[1] ?? "");
766
+ const cells = line
767
+ .split("|")
768
+ .map((cell) => cell.trim())
769
+ .filter((cell) => cell.length > 0);
770
+ const triggerCell = cells[cells.length - 1] ?? "";
771
+ const bucket = mapping.get(path) ?? [];
772
+ for (const match of triggerCell.matchAll(BACKTICK_TOKEN_RE)) {
773
+ const keyword = (match[1] ?? "").trim();
774
+ if (keyword && !bucket.includes(keyword))
742
775
  bucket.push(keyword);
743
776
  }
777
+ if (bucket.length > 0)
778
+ mapping.set(path, bucket);
744
779
  }
745
780
  return mapping;
746
781
  }
747
- function buildSkillEntry(skillMd, skillsDir, routing, captureBody) {
782
+ // Split on quoted phrases (single or double) or bare comma-delimited runs, so a
783
+ // quoted trigger containing a comma (`["what's next, please", other]`) is not
784
+ // mis-tokenised. All shipped skills use the block-list form today; this keeps
785
+ // the inline flow-list form correct for future skills.
786
+ const FLOW_LIST_TOKEN_RE = /(?:"([^"]*)")|(?:'([^']*)')|([^,]+)/g;
787
+ /** Split an inline YAML flow list (`[a, "b"]`) into trimmed, unquoted tokens. */
788
+ function parseFlowListTokens(value) {
789
+ const inner = value.replace(/^\[/, "").replace(/\]$/, "");
790
+ const out = [];
791
+ for (const match of inner.matchAll(FLOW_LIST_TOKEN_RE)) {
792
+ const token = (match[1] ?? match[2] ?? match[3] ?? "").trim();
793
+ if (token)
794
+ out.push(token);
795
+ }
796
+ return out;
797
+ }
798
+ /** Extract a `triggers:` list (block or inline flow form) from SKILL.md frontmatter. */
799
+ function parseFrontmatterTriggers(frontmatter) {
800
+ const lines = frontmatter.split("\n");
801
+ const n = lines.length;
802
+ for (let i = 0; i < n; i += 1) {
803
+ const line = lineAt(lines, i);
804
+ if (isIndented(line))
805
+ continue;
806
+ const match = KEY_RE.exec(line);
807
+ if (!match || (match[1] ?? "") !== "triggers")
808
+ continue;
809
+ const value = (match[2] ?? "").trim();
810
+ if (value.startsWith("["))
811
+ return parseFlowListTokens(value);
812
+ const out = [];
813
+ let j = i + 1;
814
+ while (j < n) {
815
+ const nxt = lineAt(lines, j);
816
+ if (nxt.trim() === "") {
817
+ j += 1;
818
+ continue;
819
+ }
820
+ if (!isIndented(nxt))
821
+ break;
822
+ const item = nxt.trim();
823
+ if (item.startsWith("- ")) {
824
+ const token = pyStrip(pyStrip(item.slice(2).trim(), '"'), "'");
825
+ if (token)
826
+ out.push(token);
827
+ }
828
+ j += 1;
829
+ }
830
+ return out;
831
+ }
832
+ return [];
833
+ }
834
+ function buildSkillEntry(skillMd, skillsDir, indexTriggers, captureBody) {
748
835
  const text = readFileSync(skillMd, "utf8");
749
836
  const [frontmatter, body] = splitFrontmatter(text);
750
837
  if (frontmatter === null)
@@ -754,7 +841,11 @@ function buildSkillEntry(skillMd, skillsDir, routing, captureBody) {
754
841
  if (!name)
755
842
  return null;
756
843
  const relPath = relPosix(dirname(resolve(skillsDir)), resolve(skillMd));
757
- const triggers = routing.get(relPath) ?? [];
844
+ // Prefer the skill's own frontmatter `triggers:` contract; fall back to the
845
+ // REFERENCES.md Skills Index (#838 single source of truth) so shipped skills
846
+ // that carry no frontmatter triggers still get a non-empty trigger list.
847
+ const frontmatterTriggers = parseFrontmatterTriggers(frontmatter);
848
+ const triggers = frontmatterTriggers.length > 0 ? frontmatterTriggers : (indexTriggers.get(relPath) ?? []);
758
849
  const version = (fields.version ?? "").trim() || DEFAULT_SKILL_VERSION;
759
850
  return {
760
851
  id: name,
@@ -766,22 +857,22 @@ function buildSkillEntry(skillMd, skillsDir, routing, captureBody) {
766
857
  frontmatter_extra: extractExtraFrontmatter(frontmatter),
767
858
  };
768
859
  }
769
- function buildSkillsPack(skillsDir, agentsMd, proofSkill) {
770
- const routing = parseRouting(readFileSync(agentsMd, "utf8"));
860
+ function buildSkillsPack(skillsDir, referencesMd, proofSkill) {
861
+ const indexTriggers = parseSkillsIndexTriggers(readFileSync(referencesMd, "utf8"));
771
862
  const captureAll = proofSkill === null;
772
863
  const proofPath = proofSkill !== null ? `skills/${proofSkill}/SKILL.md` : null;
773
864
  const base = dirname(resolve(skillsDir));
774
865
  const skills = [];
775
866
  for (const skillMd of globSkillMd(skillsDir)) {
776
867
  const relPath = relPosix(base, resolve(skillMd));
777
- const entry = buildSkillEntry(skillMd, skillsDir, routing, captureAll || relPath === proofPath);
868
+ const entry = buildSkillEntry(skillMd, skillsDir, indexTriggers, captureAll || relPath === proofPath);
778
869
  if (entry !== null)
779
870
  skills.push(entry);
780
871
  }
781
872
  return {
782
873
  pack: "skills-pack-0.1",
783
874
  version: PACK_VERSION,
784
- generated_from: "skills/*/SKILL.md + AGENTS.md (Skill Routing)",
875
+ generated_from: "skills/*/SKILL.md frontmatter triggers + REFERENCES.md (Skills Index)",
785
876
  skills,
786
877
  };
787
878
  }
@@ -988,24 +1079,24 @@ function parsePackArgs(argv, valueFlags, listFlags = []) {
988
1079
  }
989
1080
  function runPackMigrateSkills(argv, io) {
990
1081
  const contentRoot = resolveContentRoot();
991
- const parsed = parsePackArgs(argv, ["--skills-dir", "--agents-md", "--proof-skill", "--out"]);
1082
+ const parsed = parsePackArgs(argv, ["--skills-dir", "--references-md", "--proof-skill", "--out"]);
992
1083
  if (parsed.error !== undefined) {
993
1084
  io.writeErr(`error: ${parsed.error}\n`);
994
1085
  return 2;
995
1086
  }
996
1087
  const skillsDir = parsed.values["--skills-dir"] ?? join(contentRoot, "skills");
997
- const agentsMd = parsed.values["--agents-md"] ?? join(resolveDeftRoot(), "AGENTS.md");
1088
+ const referencesMd = parsed.values["--references-md"] ?? join(resolveDeftRoot(), "REFERENCES.md");
998
1089
  const proofSkill = parsed.values["--proof-skill"] ?? null;
999
1090
  const out = parsed.values["--out"] ?? join(contentRoot, "packs", "skills", "skills-pack-0.1.json");
1000
1091
  if (!isDirSafe(skillsDir)) {
1001
1092
  io.writeErr(`error: skills directory not found: ${skillsDir}\n`);
1002
1093
  return 1;
1003
1094
  }
1004
- if (!isFileSafe(agentsMd)) {
1005
- io.writeErr(`error: AGENTS.md not found: ${agentsMd}\n`);
1095
+ if (!isFileSafe(referencesMd)) {
1096
+ io.writeErr(`error: REFERENCES.md not found: ${referencesMd}\n`);
1006
1097
  return 1;
1007
1098
  }
1008
- const pack = buildSkillsPack(skillsDir, agentsMd, proofSkill);
1099
+ const pack = buildSkillsPack(skillsDir, referencesMd, proofSkill);
1009
1100
  if (pack.skills.length === 0) {
1010
1101
  io.writeErr(`error: no skills with frontmatter discovered under ${skillsDir}\n`);
1011
1102
  return 1;
@@ -1116,15 +1207,81 @@ function runPackMigrateSwarmSpec(argv, io) {
1116
1207
  return 0;
1117
1208
  }
1118
1209
  // ===========================================================================
1119
- // Native setup:ghx handler (#2022 Phase 1).
1210
+ // Native setup:ghx handler (#2022 Phase 1; #2178 download-verify-execute).
1120
1211
  //
1121
1212
  // Port of scripts/setup_ghx.py to native TypeScript: consent-gated ghx proxy
1122
1213
  // installer with three-state exit (0 ok / 1 install failure / 2 config error).
1214
+ //
1215
+ // #2178: the installer no longer pipes remote bytes straight into a shell
1216
+ // (`curl | bash` / `irm | iex`). Socket Security's AI-malware heuristic flags
1217
+ // exactly that live-pipe-with-no-integrity-check pattern and blocks every
1218
+ // consumer PR that bumps @deftai/directive (Socket scored the package ~65%
1219
+ // likely malicious, severity 0.78 -- seen on deftai/evolution#1046 / #1047).
1220
+ // Instead: download the installer script to memory, verify it against a
1221
+ // SHA-256 vendored below, write it to a private local temp file, and only
1222
+ // then execute that local file directly. The fetch URL also pins to the
1223
+ // immutable commit SHA that GHX_VERSION resolved to at vendor time (not the
1224
+ // mutable tag name), so a future tag force-move on the upstream repo cannot
1225
+ // swap the fetched bytes out from under the vendored hash without also
1226
+ // failing the hash check.
1227
+ //
1228
+ // Bumping GHX_VERSION (`.github/workflows/ci.yml` env.GHX_VERSION MUST stay
1229
+ // in lockstep):
1230
+ // 1. Resolve the new tag's commit SHA:
1231
+ // gh api repos/brunoborges/ghx/git/refs/tags/<new-version>
1232
+ // Use `object.sha`. If `object.type` is "tag" (an annotated tag, not a
1233
+ // lightweight one), resolve one level further:
1234
+ // gh api repos/brunoborges/ghx/git/tags/<object.sha>
1235
+ // and use THAT response's `object.sha` (the commit, not the tag object).
1236
+ // 2. Refetch both installers at the resolved commit and recompute hashes:
1237
+ // curl -fsSL https://raw.githubusercontent.com/brunoborges/ghx/<sha>/install.sh | sha256sum
1238
+ // curl -fsSL https://raw.githubusercontent.com/brunoborges/ghx/<sha>/install.ps1 | sha256sum
1239
+ // 3. Update GHX_VERSION, GHX_COMMIT_SHA, GHX_INSTALL_SH_SHA256, and
1240
+ // GHX_INSTALL_PS1_SHA256 below IN THE SAME COMMIT as the matching
1241
+ // `.github/workflows/ci.yml` env values -- never let the two drift.
1123
1242
  // ===========================================================================
1124
- /** Pinned ghx version — keep in lockstep with .github/workflows/ci.yml env.GHX_VERSION. */
1243
+ /** Pinned ghx version (display only) — keep in lockstep with .github/workflows/ci.yml env.GHX_VERSION. */
1125
1244
  export const GHX_VERSION = "v1.5.1";
1126
- export const INSTALL_PS1_URL = `https://raw.githubusercontent.com/brunoborges/ghx/${GHX_VERSION}/install.ps1`;
1127
- export const INSTALL_SH_URL = `https://raw.githubusercontent.com/brunoborges/ghx/${GHX_VERSION}/install.sh`;
1245
+ /**
1246
+ * Immutable commit SHA the GHX_VERSION tag resolved to at vendor time
1247
+ * (2026-07-02, via `gh api repos/brunoborges/ghx/git/refs/tags/v1.5.1`).
1248
+ * Fetch URLs pin to this SHA rather than the mutable tag name so a future
1249
+ * tag force-move on brunoborges/ghx cannot silently swap the fetched bytes
1250
+ * out from under the vendored SHA-256 hashes below (#2178).
1251
+ */
1252
+ export const GHX_COMMIT_SHA = "aa4a2786660e27392b0d3e8886f140e0a0261a0c";
1253
+ export const INSTALL_PS1_URL = `https://raw.githubusercontent.com/brunoborges/ghx/${GHX_COMMIT_SHA}/install.ps1`;
1254
+ export const INSTALL_SH_URL = `https://raw.githubusercontent.com/brunoborges/ghx/${GHX_COMMIT_SHA}/install.sh`;
1255
+ /**
1256
+ * SHA-256 of the installer scripts at GHX_COMMIT_SHA, vendored so the
1257
+ * download-verify-execute pipeline below can refuse to run tampered bytes.
1258
+ * Matches `.github/workflows/ci.yml` env.GHX_INSTALL_SH_SHA256 /
1259
+ * GHX_INSTALL_PS1_SHA256 (#1070 / #1328) — keep both in lockstep (#2178).
1260
+ */
1261
+ export const GHX_INSTALL_SH_SHA256 = "08c768feb6d2bc485079898f7e76c2b07576cbb1188a356acf99dac0fc55d1cb";
1262
+ export const GHX_INSTALL_PS1_SHA256 = "5f67eab68970ecc55bb0fc1b8399ba6f3ce4b2aadeee39255d628e96d187a5ed";
1263
+ async function defaultGhxDownload(url) {
1264
+ const res = await fetch(url);
1265
+ if (!res.ok) {
1266
+ throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
1267
+ }
1268
+ return Buffer.from(await res.arrayBuffer());
1269
+ }
1270
+ /** True when `buf`'s SHA-256 (hex) matches `expectedHex`, case- and whitespace-insensitive. */
1271
+ export function verifyGhxSha256(buf, expectedHex) {
1272
+ const actual = createHash("sha256").update(buf).digest("hex");
1273
+ return actual.toLowerCase() === expectedHex.trim().toLowerCase();
1274
+ }
1275
+ export function resolveGhxInstallerAsset(host) {
1276
+ if (host === "windows") {
1277
+ return { url: INSTALL_PS1_URL, sha256: GHX_INSTALL_PS1_SHA256, fileExt: "ps1" };
1278
+ }
1279
+ if (host === "darwin" || host === "linux") {
1280
+ return { url: INSTALL_SH_URL, sha256: GHX_INSTALL_SH_SHA256, fileExt: "sh" };
1281
+ }
1282
+ throw new Error(`no upstream ghx installer available for host '${host}'; ` +
1283
+ "see https://github.com/brunoborges/ghx#install for manual options");
1284
+ }
1128
1285
  function parseSetupGhxArgs(argv) {
1129
1286
  let yes = false;
1130
1287
  let check = false;
@@ -1174,34 +1331,97 @@ export function promptSetupGhxConsent(io, readLine = readConsentLineFromStdin) {
1174
1331
  const answer = readLine().trim().toLowerCase();
1175
1332
  return answer === "y" || answer === "yes";
1176
1333
  }
1177
- export function buildSetupGhxInstallCommand(host, whichFn = defaultWhich) {
1178
- if (host === "windows") {
1179
- const psBin = whichFn("pwsh") ?? whichFn("powershell") ?? "powershell";
1180
- return [
1181
- psBin,
1334
+ function ghxTempFileName(fileExt) {
1335
+ return `ghx-install-${GHX_VERSION}.${fileExt}`;
1336
+ }
1337
+ /**
1338
+ * Downloads `asset`, verifies it against its vendored SHA-256, and writes it
1339
+ * to a private local temp file. Returns the local path, ready for direct
1340
+ * local-file execution (never piped into a shell). Throws -- without
1341
+ * writing or executing anything -- on a hash mismatch (#2178). Split out
1342
+ * from `fetchAndVerifyGhxInstaller` so tests can exercise the download ->
1343
+ * verify -> write pipeline against a synthetic asset/hash without depending
1344
+ * on the real vendored constants or the network.
1345
+ */
1346
+ export async function fetchAndVerifyGhxInstallerAsset(asset, downloadFn = defaultGhxDownload) {
1347
+ const bytes = await downloadFn(asset.url);
1348
+ if (!verifyGhxSha256(bytes, asset.sha256)) {
1349
+ const actual = createHash("sha256").update(bytes).digest("hex");
1350
+ throw new Error(`ghx installer SHA-256 mismatch for ${asset.url} ` +
1351
+ `(expected ${asset.sha256}, got ${actual}); refusing to execute. ` +
1352
+ "The pinned commit's bytes may have changed, or the download was tampered with.");
1353
+ }
1354
+ const dir = mkdtempSync(join(tmpdir(), "deft-ghx-"));
1355
+ const installerPath = join(dir, ghxTempFileName(asset.fileExt));
1356
+ writeFileSync(installerPath, bytes, { mode: 0o700 });
1357
+ return installerPath;
1358
+ }
1359
+ /**
1360
+ * Downloads the pinned installer for `host`, verifies it against the
1361
+ * vendored SHA-256, and writes it to a private local temp file. Returns the
1362
+ * local path, ready for direct local-file execution (never piped into a
1363
+ * shell). Throws -- without writing or executing anything -- on a hash
1364
+ * mismatch (#2178).
1365
+ */
1366
+ export async function fetchAndVerifyGhxInstaller(host, downloadFn = defaultGhxDownload) {
1367
+ return fetchAndVerifyGhxInstallerAsset(resolveGhxInstallerAsset(host), downloadFn);
1368
+ }
1369
+ /**
1370
+ * Executes an already-downloaded, hash-verified installer from its local
1371
+ * temp path. No live pipe (`curl | bash` / `irm | iex`) and no
1372
+ * `-ExecutionPolicy Bypass` -- the file is written by Node, so it never
1373
+ * carries a Windows Mark-of-the-Web zone identifier the way a browser or
1374
+ * `Invoke-WebRequest` download would; `RemoteSigned` treats it as a local,
1375
+ * unsigned-but-trusted script (#2178).
1376
+ */
1377
+ export function executeVerifiedGhxInstaller(host, installerPath, whichFn = defaultWhich, runner = spawnSync) {
1378
+ const cmd = host === "windows"
1379
+ ? [
1380
+ whichFn("pwsh") ?? whichFn("powershell") ?? "powershell",
1182
1381
  "-NoProfile",
1183
1382
  "-ExecutionPolicy",
1184
- "Bypass",
1185
- "-Command",
1186
- `irm ${INSTALL_PS1_URL} | iex`,
1187
- ];
1188
- }
1189
- if (host === "darwin" || host === "linux") {
1190
- return ["bash", "-c", `curl -fsSL ${INSTALL_SH_URL} | bash`];
1191
- }
1192
- throw new Error(`no upstream ghx installer available for host '${host}'; ` +
1193
- "see https://github.com/brunoborges/ghx#install for manual options");
1194
- }
1195
- export function installSetupGhx(host, whichFn = defaultWhich, runner = spawnSync) {
1196
- const cmd = buildSetupGhxInstallCommand(host, whichFn);
1383
+ "RemoteSigned",
1384
+ "-File",
1385
+ installerPath,
1386
+ ]
1387
+ : ["bash", installerPath];
1197
1388
  const proc = runner(cmd[0] ?? "", cmd.slice(1), {
1198
1389
  env: { ...process.env, GHX_VERSION },
1199
1390
  stdio: "inherit",
1200
1391
  });
1201
1392
  return proc.status ?? 1;
1202
1393
  }
1394
+ /**
1395
+ * Downloads, hash-verifies, and executes `asset` for `host`. Cleans up the
1396
+ * temp file (and its containing directory) regardless of outcome. Split out
1397
+ * from `installSetupGhx` so tests can exercise the full download -> verify
1398
+ * -> execute -> cleanup pipeline against a synthetic asset without depending
1399
+ * on the real vendored constants or the network (#2178).
1400
+ */
1401
+ export async function installVerifiedGhxAsset(asset, host, whichFn = defaultWhich, runner = spawnSync, downloadFn = defaultGhxDownload) {
1402
+ const installerPath = await fetchAndVerifyGhxInstallerAsset(asset, downloadFn);
1403
+ try {
1404
+ return executeVerifiedGhxInstaller(host, installerPath, whichFn, runner);
1405
+ }
1406
+ finally {
1407
+ try {
1408
+ rmSync(dirname(installerPath), { recursive: true, force: true });
1409
+ }
1410
+ catch {
1411
+ // Best-effort cleanup; a leftover temp file is not fatal.
1412
+ }
1413
+ }
1414
+ }
1415
+ /**
1416
+ * Downloads, hash-verifies, and executes the ghx installer for `host`.
1417
+ * Cleans up the temp file (and its containing directory) regardless of
1418
+ * outcome (#2178).
1419
+ */
1420
+ export async function installSetupGhx(host, whichFn = defaultWhich, runner = spawnSync, downloadFn = defaultGhxDownload) {
1421
+ return installVerifiedGhxAsset(resolveGhxInstallerAsset(host), host, whichFn, runner, downloadFn);
1422
+ }
1203
1423
  /** Native `setup:ghx` handler (replaces scripts/setup_ghx.py shell-out, #2022 Phase 1). */
1204
- export function runSetupGhx(argv, io, deps = {}) {
1424
+ export async function runSetupGhx(argv, io, deps = {}) {
1205
1425
  const args = parseSetupGhxArgs(argv);
1206
1426
  if (args.error !== undefined) {
1207
1427
  io.writeErr(`setup-ghx: ${args.error}\n`);
@@ -1248,9 +1468,10 @@ export function runSetupGhx(argv, io, deps = {}) {
1248
1468
  return 0;
1249
1469
  }
1250
1470
  const host = detectSetupGhxHost();
1251
- const runInstall = deps.runInstall ?? ((h) => installSetupGhx(h, whichFn));
1471
+ const runInstall = deps.runInstall ??
1472
+ ((h) => installSetupGhx(h, whichFn, deps.runner, deps.downloadFn));
1252
1473
  try {
1253
- const rc = runInstall(host);
1474
+ const rc = await runInstall(host);
1254
1475
  if (rc !== 0) {
1255
1476
  io.writeErr(`[setup_ghx] error: upstream installer exited ${rc}. ` +
1256
1477
  "See https://github.com/brunoborges/ghx#install for manual options.\n");
@@ -1943,6 +2164,10 @@ async function loadCoreModuleHandler(verb, io) {
1943
2164
  const { completeCohortMain } = await import("@deftai/directive-core/dist/swarm/complete-cohort-cli.js");
1944
2165
  return completeCohortMain;
1945
2166
  }
2167
+ case "swarm-finalize-cohort": {
2168
+ const { finalizeCohortMain } = await import("@deftai/directive-core/dist/swarm/finalize-cohort-cli.js");
2169
+ return finalizeCohortMain;
2170
+ }
1946
2171
  case "swarm-readiness": {
1947
2172
  const { readinessMain } = await import("@deftai/directive-core/dist/swarm/readiness-cli.js");
1948
2173
  return readinessMain;
package/dist/doctor.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { parseDoctorFlags } from "@deftai/directive-core/dist/doctor/flags.js";
6
6
  import { cmdDoctor } from "@deftai/directive-core/dist/doctor/main.js";
7
7
  import { renderPrecutoverLine } from "@deftai/directive-core/dist/vbrief-validate/precutover.js";
8
- import { renderXbriefMigrationLine } from "@deftai/directive-core/xbrief-migrate";
8
+ import { renderStaleHeaderLine, renderXbriefMigrationLine, } from "@deftai/directive-core/xbrief-migrate";
9
9
  /** Advisory when a consumer deposit carries git-vendored framework source (#2142). */
10
10
  export function renderStrayPackagesAdvisoryLine(projectRoot) {
11
11
  const packagesDir = join(projectRoot, ".deft", "core", "packages");
@@ -26,6 +26,7 @@ export function run(argv) {
26
26
  const projectRoot = flags.projectRoot ?? process.cwd();
27
27
  process.stdout.write(`${renderPrecutoverLine(projectRoot)}\n`);
28
28
  process.stdout.write(`${renderXbriefMigrationLine(projectRoot)}\n`);
29
+ process.stdout.write(`${renderStaleHeaderLine(projectRoot)}\n`);
29
30
  process.stdout.write(`${renderStrayPackagesAdvisoryLine(projectRoot)}\n`);
30
31
  }
31
32
  return cmdDoctor(argv);
@@ -1,11 +1,41 @@
1
1
  #!/usr/bin/env node
2
- export interface ParsedInstallUpgradeArgs {
3
- projectRoot: string;
4
- frameworkRoot: string;
5
- migrate: boolean;
6
- force: boolean;
7
- error?: string;
2
+ import type { DispatchIo } from "./dispatch.js";
3
+ /**
4
+ * #2064: `deft install-upgrade` is now a thin redirect onto the SAME code path
5
+ * as `directive update` (`runRefreshDeposit`). The two verbs previously had
6
+ * overlapping-but-divergent semantics: `directive update` file-swaps the
7
+ * vendored `.deft/core` payload, rewrites the install manifest (#2056), and
8
+ * regenerates the `.deft-version` marker (#2055), whereas the old
9
+ * `install-upgrade` only wrote the marker/manifest and refreshed AGENTS.md
10
+ * WITHOUT swapping the payload -- so on a stale deposit it reported a confident
11
+ * false no-op ("Project already at X. Nothing to do.") that steered operators
12
+ * away from the command that actually works. Consolidating to one path removes
13
+ * that hazard and gives consumers a single upgrade mental model:
14
+ * npm i -g @deftai/directive@latest -> deft update -> deft migrate -> deft doctor
15
+ *
16
+ * The legacy `.deft/VERSION` cleanup that only `install-upgrade` used to perform
17
+ * is folded into the shared `runRefreshDeposit` path (see
18
+ * `migrateLegacyInstallManifest` in init-deposit/refresh.ts) so no manifest
19
+ * behavior is dropped. Layout migration (the old `--migrate` flag) is the
20
+ * separate `deft migrate` step in the canonical flow above.
21
+ */
22
+ /** One-line notice emitted on the redirect so operators learn the canonical verb. */
23
+ export declare const REDIRECT_NOTICE: string;
24
+ export interface InstallUpgradeDeps {
25
+ /** Injectable seam so tests can drive the shared update path with fixtures. */
26
+ readonly runUpdate?: (argv: readonly string[], io: DispatchIo) => Promise<number>;
8
27
  }
9
- export declare function parseArgs(argv: readonly string[]): ParsedInstallUpgradeArgs;
10
- export declare function run(argv: readonly string[]): number;
28
+ /**
29
+ * Translate the historical `install-upgrade` flag surface onto the
30
+ * `directive update` argv. `--project-root <p>` maps to `--repo-root <p>`;
31
+ * `--framework-root` is dropped (update resolves its own content root) and the
32
+ * legacy `--migrate` / `--force` flags are dropped (layout migration is now the
33
+ * separate `deft migrate` step). Any other argv passes through unchanged.
34
+ */
35
+ export declare function translateArgs(argv: readonly string[]): string[];
36
+ /**
37
+ * Redirect handler: emit the one-line notice, then delegate to the identical
38
+ * code path `directive update` uses so deposit state + stdout are identical.
39
+ */
40
+ export declare function run(argv: readonly string[], io?: DispatchIo, deps?: InstallUpgradeDeps): Promise<number>;
11
41
  //# sourceMappingURL=install-upgrade.d.ts.map