easy-coding-harness 0.5.0 → 0.5.2-beta.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/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@
6
6
  - `y`:常规功能升级
7
7
  - `z`:日常 bug 修复
8
8
 
9
+ ## 0.5.2-beta.0
10
+
11
+ - 修复 Claude Code hook 命令使用直接相对路径导致在子目录 cwd 下找不到 `.claude/hooks/*.py` 的问题;Claude、Codex、Qoder 的 hook 配置现在使用可共享的 portable relative launcher,并绑定 `.easy-coding/config.yaml` 的 `project.id`,避免把本机仓库绝对路径写入可提交配置,同时防止 supermodule 父仓 hook 被子仓 cwd 错路由。
12
+ - `easy-coding upgrade` 可直接刷新存量 0.5.0 项目和已安装 0.5.1 beta 绝对路径配置;同版本也会修复缺失、重复、事件错位或 stale 的托管 hook 注册。
13
+ - `easy-coding clear` 和 `install-manifest.json` 兼容 portable launcher、旧直接相对命令和旧绝对 hook 命令,清理时仍按项目相对 hook 路径识别托管注册项。
14
+ - npm `0.5.1` 已发布不可覆盖,本版本作为 beta 修复包发布,用于替代原 `0.5.1` beta。
15
+
9
16
  ## 0.5.0
10
17
 
11
18
  - 新增 supermodule 支持:`easy-coding init` 会识别 `.gitmodules`,父仓必装,已检出的一级子仓可按交互或参数选择分层安装。
package/dist/cli.js CHANGED
@@ -61,6 +61,7 @@ function colorizeBanner(art) {
61
61
  }
62
62
 
63
63
  // src/utils/config-yaml.ts
64
+ import { randomUUID } from "crypto";
64
65
  import { readFile as readFile2 } from "fs/promises";
65
66
  import YAML, { isScalar, isSeq, parseDocument } from "yaml";
66
67
 
@@ -108,6 +109,7 @@ function createDefaultConfig(params) {
108
109
  harness_version: params.harnessVersion,
109
110
  agents: params.agents,
110
111
  project: {
112
+ id: params.projectId ?? createProjectId(),
111
113
  name: params.projectName
112
114
  },
113
115
  memory: {
@@ -138,6 +140,14 @@ async function readConfigYaml(filePath) {
138
140
  const content = await readFile2(filePath, "utf8");
139
141
  return YAML.parse(content);
140
142
  }
143
+ async function readProjectIdIfExists(filePath) {
144
+ try {
145
+ const config = await readConfigYaml(filePath);
146
+ return typeof config.project?.id === "string" && config.project.id.trim() ? config.project.id : null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
141
151
  async function updateConfigYaml(filePath, updater) {
142
152
  const content = await readFile2(filePath, "utf8");
143
153
  const document = parseDocument(content);
@@ -160,11 +170,27 @@ async function updateHarnessVersion(filePath, version) {
160
170
  config.harness_version = version;
161
171
  });
162
172
  }
173
+ async function ensureProjectId(filePath) {
174
+ let projectId = "";
175
+ await updateConfigYaml(filePath, (config) => {
176
+ if (!config.project || typeof config.project !== "object") {
177
+ config.project = { id: createProjectId(), name: "" };
178
+ }
179
+ if (typeof config.project.id !== "string" || !config.project.id.trim()) {
180
+ config.project.id = createProjectId();
181
+ }
182
+ projectId = config.project.id;
183
+ });
184
+ return projectId;
185
+ }
163
186
  async function updateSupermoduleConfig(filePath, supermodule) {
164
187
  return updateConfigYaml(filePath, (config) => {
165
188
  config.supermodule = supermodule;
166
189
  });
167
190
  }
191
+ function createProjectId() {
192
+ return `ec-${randomUUID()}`;
193
+ }
168
194
 
169
195
  // src/utils/gitignore.ts
170
196
  import path3 from "path";
@@ -350,7 +376,7 @@ async function writeInstallManifest(cwd, params) {
350
376
  const registration = {
351
377
  config_path: configPath2,
352
378
  command: artifact.command,
353
- hook_path: artifact.hookPath,
379
+ hook_path: artifact.hookPath ? normalizeHookPath(cwd, artifact.hookPath) : null,
354
380
  platform: artifact.platform
355
381
  };
356
382
  hookRegistrations.set(`${configPath2}\0${normalizeCommand(artifact.command)}`, registration);
@@ -465,9 +491,75 @@ function collectHookCommands(hooks) {
465
491
  return commands;
466
492
  }
467
493
  function extractHookPathFromCommand(command) {
468
- const match = normalizeCommand(command).match(/(?:^|\s)(\S+\/hooks\/\S+\.py)(?:\s|$)/);
494
+ return extractQuotedHookPath(command) ?? extractUnquotedHookPath(command);
495
+ }
496
+ function extractQuotedHookPath(command) {
497
+ for (let index = 0; index < command.length; index += 1) {
498
+ const quote = command[index];
499
+ if (quote !== '"' && quote !== "'") {
500
+ continue;
501
+ }
502
+ if (index > 0 && !/\s/.test(command[index - 1])) {
503
+ continue;
504
+ }
505
+ const parsed = parseQuotedToken(command, index, quote);
506
+ if (!parsed) {
507
+ continue;
508
+ }
509
+ const suffix = readPathSuffix(command, parsed.endIndex);
510
+ for (const candidate of [parsed.value + suffix, parsed.value]) {
511
+ if (isHookPythonPath(candidate)) {
512
+ return candidate;
513
+ }
514
+ }
515
+ index = parsed.endIndex;
516
+ }
517
+ return null;
518
+ }
519
+ function parseQuotedToken(command, startIndex, quote) {
520
+ let value = "";
521
+ for (let index = startIndex + 1; index < command.length; index += 1) {
522
+ const char = command[index];
523
+ if (quote === '"' && char === "\\" && index + 1 < command.length) {
524
+ value += command[index + 1];
525
+ index += 1;
526
+ continue;
527
+ }
528
+ if (char === quote) {
529
+ return { value, endIndex: index + 1 };
530
+ }
531
+ value += char;
532
+ }
533
+ return null;
534
+ }
535
+ function readPathSuffix(command, startIndex) {
536
+ if (command[startIndex] !== "/") {
537
+ return "";
538
+ }
539
+ let suffix = "";
540
+ for (let index = startIndex; index < command.length; index += 1) {
541
+ const char = command[index];
542
+ if (/\s/.test(char)) {
543
+ break;
544
+ }
545
+ suffix += char;
546
+ }
547
+ return suffix;
548
+ }
549
+ function extractUnquotedHookPath(command) {
550
+ const match = command.match(/(?:^|\s)(\S+\/hooks\/\S+\.py)(?:\s|$)/);
469
551
  return match?.[1] ?? null;
470
552
  }
553
+ function isHookPythonPath(candidate) {
554
+ return /\/hooks\/\S+\.py$/.test(candidate);
555
+ }
556
+ function normalizeHookPath(cwd, hookPath) {
557
+ const normalized = hookPath.replace(/\\/g, "/").replace(/^\.\//, "");
558
+ if (path4.isAbsolute(hookPath) || path4.posix.isAbsolute(normalized) || path4.win32.isAbsolute(hookPath)) {
559
+ return toProjectPath(cwd, hookPath);
560
+ }
561
+ return normalized;
562
+ }
471
563
  function byPath(a, b) {
472
564
  return a.path.localeCompare(b.path);
473
565
  }
@@ -502,17 +594,21 @@ async function writeRuntimeScaffold(cwd, agents, opts = {}) {
502
594
  const easyCodingDir = path6.join(cwd, EASY_CODING_DIR);
503
595
  await ensureDir(easyCodingDir);
504
596
  const configPath2 = path6.join(easyCodingDir, CONFIG_FILE);
597
+ let projectId = opts.projectId ?? createProjectId();
505
598
  if (!await pathExists(configPath2)) {
506
599
  const projectName = path6.basename(cwd);
507
600
  await writeConfigYaml(
508
601
  configPath2,
509
602
  createDefaultConfig({
510
603
  projectName,
604
+ projectId,
511
605
  harnessVersion: VERSION,
512
606
  agents,
513
607
  supermodule: opts.supermodule
514
608
  })
515
609
  );
610
+ } else {
611
+ projectId = await ensureProjectId(configPath2);
516
612
  }
517
613
  await ensureDir(path6.join(easyCodingDir, "tasks"));
518
614
  await ensureDir(path6.join(easyCodingDir, SESSIONS_DIR));
@@ -520,6 +616,7 @@ async function writeRuntimeScaffold(cwd, agents, opts = {}) {
520
616
  await ensureDir(path6.join(easyCodingDir, SPEC_DIR, DEV_SPEC_DIR));
521
617
  await writeMemoryScaffold(easyCodingDir);
522
618
  await writeTemplatesScaffold(easyCodingDir);
619
+ return projectId;
523
620
  }
524
621
  async function writeTemplatesScaffold(easyCodingDir) {
525
622
  const templatesDir = path6.join(easyCodingDir, TEMPLATES_DIR);
@@ -696,6 +793,89 @@ function resolvePlaceholders(content, ctx, contentName = "template") {
696
793
  }
697
794
  return resolved;
698
795
  }
796
+ async function withProjectInstallPaths(cwd, ctx, projectId) {
797
+ const resolvedProjectId = projectId ?? await readProjectIdIfExists(path8.join(cwd, EASY_CODING_DIR, CONFIG_FILE));
798
+ return withInstallPaths(cwd, ctx, resolvedProjectId ?? void 0);
799
+ }
800
+ function withInstallPaths(cwd, ctx, projectId) {
801
+ return {
802
+ ...ctx,
803
+ platform_hook_session_start_command: jsonStringContent(
804
+ renderHookCommand(cwd, ctx, "session-start.py", process.platform, projectId)
805
+ ),
806
+ platform_hook_inject_workflow_state_command: jsonStringContent(
807
+ renderHookCommand(cwd, ctx, "inject-workflow-state.py", process.platform, projectId)
808
+ ),
809
+ platform_hook_inject_subagent_context_command: jsonStringContent(
810
+ renderHookCommand(cwd, ctx, "inject-subagent-context.py", process.platform, projectId)
811
+ )
812
+ };
813
+ }
814
+ function renderHookCommand(_cwd, ctx, scriptName, platform = process.platform, projectId) {
815
+ const launcher = `import base64;exec(base64.b64decode('${Buffer.from(HOOK_LAUNCHER_PYTHON).toString("base64")}').decode())`;
816
+ const rootIdArg = projectId ? ` ${shellDoubleQuoteArg(projectId, platform)}` : "";
817
+ return `${ctx.python_cmd} -c ${shellDoubleQuoteArg(launcher, platform)}${rootIdArg} ${ctx.platform_config_dir}/hooks/${scriptName}`;
818
+ }
819
+ function shellDoubleQuoteArg(value, platform = process.platform) {
820
+ if (platform === "win32") {
821
+ if (value.includes('"')) {
822
+ throw new Error("Windows hook paths cannot contain double quotes.");
823
+ }
824
+ return `"${value}"`;
825
+ }
826
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
827
+ }
828
+ function jsonStringContent(value) {
829
+ return JSON.stringify(value).slice(1, -1);
830
+ }
831
+ var HOOK_LAUNCHER_PYTHON = [
832
+ "import io,json,os,re,runpy,sys",
833
+ "from pathlib import Path",
834
+ "payload=sys.stdin.read()",
835
+ "try:",
836
+ " data=json.loads(payload or '{}')",
837
+ "except Exception:",
838
+ " data={}",
839
+ "expected_id=sys.argv[1] if len(sys.argv) > 2 else None",
840
+ "script_arg=sys.argv[2] if len(sys.argv) > 2 else (sys.argv[1] if len(sys.argv) > 1 else None)",
841
+ "def project_id(root):",
842
+ " try:",
843
+ " lines=(root / '.easy-coding' / 'config.yaml').read_text(encoding='utf-8').splitlines()",
844
+ " except Exception:",
845
+ " return None",
846
+ " in_project=False",
847
+ " for line in lines:",
848
+ " if not in_project:",
849
+ " if re.match(r'^project\\s*:\\s*(?:#.*)?$', line):",
850
+ " in_project=True",
851
+ " continue",
852
+ " if not line.strip() or line.lstrip().startswith('#'):",
853
+ " continue",
854
+ " if not line.startswith((' ', '\\t')):",
855
+ " return None",
856
+ ` match=re.match(r"\\s+id\\s*:\\s*['\\"]?([^'\\"#\\s]+)", line)`,
857
+ " if match:",
858
+ " return match.group(1)",
859
+ " return None",
860
+ "def roots_from(raw):",
861
+ " if not raw:",
862
+ " return []",
863
+ " start=Path(raw).resolve()",
864
+ " return [candidate for candidate in (start, *start.parents) if (candidate / '.easy-coding').is_dir()]",
865
+ "sources=[os.environ.get('CLAUDE_PROJECT_DIR'), os.environ.get('QODER_PROJECT_DIR'), data.get('cwd'), os.getcwd()]",
866
+ "root=None",
867
+ "if expected_id:",
868
+ " root=next((candidate for raw in sources for candidate in roots_from(raw) if project_id(candidate) == expected_id), None)",
869
+ "else:",
870
+ " root=next((candidate for raw in sources for candidate in roots_from(raw)), None)",
871
+ "target=(root / script_arg) if root and script_arg else None",
872
+ "if target and target.is_file():",
873
+ " os.chdir(root)",
874
+ " data['cwd']=str(root)",
875
+ " sys.stdin=io.StringIO(json.dumps(data))",
876
+ " sys.path.insert(0, str(target.parent))",
877
+ " runpy.run_path(str(target), run_name='__main__')"
878
+ ].join("\n");
699
879
  async function resolveSkills(ctx) {
700
880
  const skillsRoot = getTemplatePath("common", "skills");
701
881
  const entries = await readdir2(skillsRoot);
@@ -851,7 +1031,7 @@ function stripTemplateExtension(fileName) {
851
1031
  async function configureClaude(cwd, opts = {}) {
852
1032
  const platform = "claude-code";
853
1033
  const meta = PLATFORM_META[platform];
854
- const ctx = meta.templateContext;
1034
+ const ctx = await withProjectInstallPaths(cwd, meta.templateContext, opts.projectId);
855
1035
  const dest = path9.join(cwd, ".claude");
856
1036
  const hookConfigPath = path9.join(cwd, meta.hookConfigFile);
857
1037
  const artifacts = [];
@@ -889,7 +1069,7 @@ import path10 from "path";
889
1069
  async function configureCodex(cwd, opts = {}) {
890
1070
  const platform = "codex";
891
1071
  const meta = PLATFORM_META[platform];
892
- const ctx = meta.templateContext;
1072
+ const ctx = await withProjectInstallPaths(cwd, meta.templateContext, opts.projectId);
893
1073
  const hookConfigPath = path10.join(cwd, meta.hookConfigFile);
894
1074
  const artifacts = [];
895
1075
  artifacts.push(
@@ -1005,7 +1185,7 @@ async function existingManagedSkillArtifacts(skillsDir, skillNames, platform) {
1005
1185
  async function configureQoder(cwd, opts = {}) {
1006
1186
  const platform = "qoder";
1007
1187
  const meta = resolvePlatformMeta(cwd, platform);
1008
- const ctx = meta.templateContext;
1188
+ const ctx = await withProjectInstallPaths(cwd, meta.templateContext, opts.projectId);
1009
1189
  const dest = path12.join(cwd, ctx.platform_config_dir);
1010
1190
  const hookConfigPath = path12.join(cwd, meta.hookConfigFile);
1011
1191
  const artifacts = [];
@@ -1056,17 +1236,22 @@ var CONFIGURATORS = {
1056
1236
  };
1057
1237
 
1058
1238
  // src/commands/install-harness.ts
1059
- async function configurePlatformsForDir(targetDir, platforms, boundary) {
1239
+ async function configurePlatformsForDir(targetDir, platforms, opts = {}) {
1060
1240
  const artifacts = [];
1061
1241
  for (const platform of platforms) {
1062
- artifacts.push(...await CONFIGURATORS[platform](targetDir, { supermodule: boundary }));
1242
+ artifacts.push(...await CONFIGURATORS[platform](targetDir, opts));
1063
1243
  }
1064
1244
  return artifacts;
1065
1245
  }
1066
1246
  async function installHarnessToDir(targetDir, platforms, ctx) {
1067
- const artifacts = await configurePlatformsForDir(targetDir, platforms, boundaryFromContext(ctx));
1247
+ const projectId = createProjectId();
1248
+ const artifacts = await configurePlatformsForDir(targetDir, platforms, {
1249
+ supermodule: boundaryFromContext(ctx),
1250
+ projectId
1251
+ });
1068
1252
  await writeRuntimeScaffold(targetDir, platforms, {
1069
- supermodule: supermoduleConfigFromContext(ctx)
1253
+ supermodule: supermoduleConfigFromContext(ctx),
1254
+ projectId
1070
1255
  });
1071
1256
  await writeInstallManifest(targetDir, {
1072
1257
  harnessVersion: VERSION,
@@ -1560,14 +1745,13 @@ async function addAgent(opts) {
1560
1745
  const toInstall = platforms.filter((platform) => !config.agents.includes(platform));
1561
1746
  const agents = [...config.agents, ...toInstall];
1562
1747
  if (toInstall.length > 0) {
1563
- const artifacts = await configurePlatformsForDir(
1564
- target.dir,
1565
- toInstall,
1566
- target.boundary
1567
- );
1568
- await writeRuntimeScaffold(target.dir, agents, {
1748
+ const projectId = await writeRuntimeScaffold(target.dir, agents, {
1569
1749
  supermodule: target.supermodule
1570
1750
  });
1751
+ const artifacts = await configurePlatformsForDir(target.dir, toInstall, {
1752
+ supermodule: target.boundary,
1753
+ projectId
1754
+ });
1571
1755
  await writeInstallManifest(target.dir, {
1572
1756
  harnessVersion: VERSION,
1573
1757
  agents: toInstall,
@@ -1770,11 +1954,12 @@ async function buildManifestClearPlan(cwd, agents, manifest) {
1770
1954
  if (!agentSet.has(registration.platform)) {
1771
1955
  continue;
1772
1956
  }
1957
+ const managedHookPaths = registration.hook_path ? managedHookPathsForRegistration(cwd, registration.hook_path) : [];
1773
1958
  addHookConfigPrune(
1774
1959
  plan.pruneHookConfigs,
1775
1960
  plan.pruneHookCommands,
1776
1961
  manifestPath(cwd, registration.config_path),
1777
- registration.hook_path ? [registration.hook_path] : [],
1962
+ managedHookPaths,
1778
1963
  [registration.command]
1779
1964
  );
1780
1965
  }
@@ -1822,7 +2007,7 @@ async function addTemplateClearEntries(plan, cwd, platform, metas, managedSkills
1822
2007
  plan.pruneHookConfigs,
1823
2008
  plan.pruneHookCommands,
1824
2009
  path16.join(cwd, meta.hookConfigFile),
1825
- hookFileNames.map((name) => `${meta.templateContext.platform_config_dir}/hooks/${name}`),
2010
+ managedHookPathsForTemplate(cwd, meta, hookFileNames),
1826
2011
  []
1827
2012
  );
1828
2013
  plan.constraints.add(path16.join(cwd, meta.mainConstraint));
@@ -1873,6 +2058,51 @@ function addHookConfigPrune(pruneHookConfigs, pruneHookCommands, filePath, manag
1873
2058
  }
1874
2059
  pruneHookCommands.set(filePath, existingCommands);
1875
2060
  }
2061
+ function managedHookPathsForRegistration(cwd, hookPath) {
2062
+ const paths = [hookPath];
2063
+ try {
2064
+ paths.push(...managedHookPathTokens(manifestPath(cwd, hookPath)));
2065
+ } catch {
2066
+ if (path16.isAbsolute(hookPath)) {
2067
+ paths.push(...managedHookPathTokens(hookPath));
2068
+ }
2069
+ }
2070
+ return paths;
2071
+ }
2072
+ function managedHookPathsForTemplate(cwd, meta, hookFileNames) {
2073
+ const relativePaths = hookFileNames.map(
2074
+ (name) => `${meta.templateContext.platform_config_dir}/hooks/${name}`
2075
+ );
2076
+ const absolutePaths = hookFileNames.flatMap(
2077
+ (name) => managedHookPathTokens(path16.join(cwd, meta.hooksDir, name))
2078
+ );
2079
+ return [...relativePaths, ...absolutePaths];
2080
+ }
2081
+ function managedHookPathTokens(filePath) {
2082
+ const tokens = /* @__PURE__ */ new Set();
2083
+ for (const equivalentPath of equivalentAbsolutePaths(filePath)) {
2084
+ const normalized = equivalentPath.replace(/\\/g, "/");
2085
+ const dir = path16.posix.dirname(normalized);
2086
+ const basename = path16.posix.basename(normalized);
2087
+ tokens.add(normalized);
2088
+ tokens.add(shellDoubleQuoteArg2(normalized));
2089
+ tokens.add(`${shellDoubleQuoteArg2(dir)}/${basename}`);
2090
+ }
2091
+ return [...tokens];
2092
+ }
2093
+ function equivalentAbsolutePaths(filePath) {
2094
+ const normalized = path16.resolve(filePath).replace(/\\/g, "/");
2095
+ const equivalents = [normalized];
2096
+ if (normalized.startsWith("/private/var/")) {
2097
+ equivalents.push(normalized.replace(/^\/private\/var\//, "/var/"));
2098
+ } else if (normalized.startsWith("/var/")) {
2099
+ equivalents.push(normalized.replace(/^\/var\//, "/private/var/"));
2100
+ }
2101
+ return equivalents;
2102
+ }
2103
+ function shellDoubleQuoteArg2(value) {
2104
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
2105
+ }
1876
2106
  function createClearPlan() {
1877
2107
  return {
1878
2108
  remove: /* @__PURE__ */ new Set(),
@@ -2057,7 +2287,7 @@ function isManagedHook(hook, managedHookPaths, managedHookCommands) {
2057
2287
  if (normalizedManagedCommands.includes(normalizeCommand(command))) {
2058
2288
  return true;
2059
2289
  }
2060
- const normalizedCommand = command.replace(/\\/g, "/");
2290
+ const normalizedCommand = command;
2061
2291
  return managedHookPaths.some(
2062
2292
  (hookPath) => commandContainsPathToken(normalizedCommand, hookPath.replace(/\\/g, "/"))
2063
2293
  );
@@ -2543,8 +2773,35 @@ async function update(opts) {
2543
2773
  }
2544
2774
 
2545
2775
  // src/commands/upgrade.ts
2776
+ import path22 from "path";
2546
2777
  import { cancel as cancel5, confirm as confirm4, outro as outro5 } from "@clack/prompts";
2547
2778
  import chalk7 from "chalk";
2779
+ var EXPECTED_HOOK_REGISTRATION_SCRIPTS = {
2780
+ "claude-code": [
2781
+ { event: "SessionStart", scriptName: "session-start.py" },
2782
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2783
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" },
2784
+ { event: "PreToolUse", scriptName: "inject-subagent-context.py" }
2785
+ ],
2786
+ codex: [
2787
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2788
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" }
2789
+ ],
2790
+ qoder: [
2791
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2792
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" },
2793
+ { event: "PreToolUse", scriptName: "inject-subagent-context.py" }
2794
+ ]
2795
+ };
2796
+ var MANAGED_HOOK_SCRIPT_NAMES = {
2797
+ "claude-code": [
2798
+ ...new Set(
2799
+ EXPECTED_HOOK_REGISTRATION_SCRIPTS["claude-code"].map(({ scriptName }) => scriptName)
2800
+ )
2801
+ ],
2802
+ codex: [...new Set(EXPECTED_HOOK_REGISTRATION_SCRIPTS.codex.map(({ scriptName }) => scriptName))],
2803
+ qoder: [...new Set(EXPECTED_HOOK_REGISTRATION_SCRIPTS.qoder.map(({ scriptName }) => scriptName))]
2804
+ };
2548
2805
  async function upgrade(opts) {
2549
2806
  renderBanner();
2550
2807
  const cwd = process.cwd();
@@ -2591,14 +2848,13 @@ async function upgrade(opts) {
2591
2848
  }
2592
2849
  }
2593
2850
  for (const { target, config } of pending) {
2594
- const artifacts = await configurePlatformsForDir(
2595
- target.dir,
2596
- config.agents,
2597
- target.boundary
2598
- );
2599
- await writeRuntimeScaffold(target.dir, config.agents, {
2851
+ const projectId = await writeRuntimeScaffold(target.dir, config.agents, {
2600
2852
  supermodule: target.supermodule
2601
2853
  });
2854
+ const artifacts = await configurePlatformsForDir(target.dir, config.agents, {
2855
+ supermodule: target.boundary,
2856
+ projectId
2857
+ });
2602
2858
  await writeInstallManifest(target.dir, {
2603
2859
  harnessVersion: VERSION,
2604
2860
  agents: config.agents,
@@ -2645,12 +2901,153 @@ async function resolvePendingUpgradeTargets(targets) {
2645
2901
  `${target.label} harness version ${installedVersion} is newer than CLI ${VERSION}. Update the CLI first.`
2646
2902
  );
2647
2903
  }
2648
- if (relation === -1) {
2904
+ if (relation === -1 || relation === 0 && await needsHookConfigRefresh(target, config)) {
2649
2905
  pending.push({ target, config });
2650
2906
  }
2651
2907
  }
2652
2908
  return pending;
2653
2909
  }
2910
+ async function needsHookConfigRefresh(target, config) {
2911
+ const projectId = typeof config.project?.id === "string" ? config.project.id.trim() : "";
2912
+ if (!projectId) {
2913
+ return true;
2914
+ }
2915
+ const manifest = await readInstallManifest(target.dir);
2916
+ for (const agent of config.agents) {
2917
+ const meta = resolvePlatformMeta(target.dir, agent);
2918
+ const configPath2 = path22.join(target.dir, meta.hookConfigFile);
2919
+ const content = await readTextIfExists(configPath2);
2920
+ if (content === null) {
2921
+ return true;
2922
+ }
2923
+ let parsed;
2924
+ try {
2925
+ parsed = JSON.parse(content);
2926
+ } catch {
2927
+ return true;
2928
+ }
2929
+ const commandsByEvent = collectHookCommandsByEvent(parsed.hooks);
2930
+ const commands = [...commandsByEvent.values()].flat();
2931
+ const expectedRegistrations = expectedHookRegistrations(target.dir, meta, agent, projectId);
2932
+ const expectedCommandSet = new Set(
2933
+ expectedRegistrations.map((registration) => registration.command)
2934
+ );
2935
+ if (!hasExpectedHookRegistrations(commandsByEvent, expectedRegistrations)) {
2936
+ return true;
2937
+ }
2938
+ const manifestManagedCommands = new Set(
2939
+ manifest?.hook_registrations.filter((registration) => registration.platform === agent).map((registration) => registration.command) ?? []
2940
+ );
2941
+ if (commands.some(
2942
+ (command) => isUnexpectedManagedHookCommand(
2943
+ command,
2944
+ expectedCommandSet,
2945
+ target.dir,
2946
+ meta,
2947
+ agent,
2948
+ manifestManagedCommands
2949
+ )
2950
+ )) {
2951
+ return true;
2952
+ }
2953
+ }
2954
+ return false;
2955
+ }
2956
+ function expectedHookRegistrations(cwd, meta, platform, projectId) {
2957
+ return EXPECTED_HOOK_REGISTRATION_SCRIPTS[platform].map(({ event, scriptName }) => ({
2958
+ event,
2959
+ command: renderHookCommand(cwd, meta.templateContext, scriptName, process.platform, projectId)
2960
+ }));
2961
+ }
2962
+ function hasExpectedHookRegistrations(commandsByEvent, expectedRegistrations) {
2963
+ const actualCounts = countRegistrations(
2964
+ [...commandsByEvent.entries()].flatMap(
2965
+ ([event, commands]) => commands.map((command) => ({ event, command }))
2966
+ )
2967
+ );
2968
+ const expectedCounts = countRegistrations(expectedRegistrations);
2969
+ return [...expectedCounts.entries()].every(
2970
+ ([registrationKey, expectedCount]) => (actualCounts.get(registrationKey) ?? 0) >= expectedCount
2971
+ );
2972
+ }
2973
+ function countRegistrations(registrations) {
2974
+ const counts = /* @__PURE__ */ new Map();
2975
+ for (const registration of registrations) {
2976
+ const key = hookRegistrationKey(registration);
2977
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2978
+ }
2979
+ return counts;
2980
+ }
2981
+ function hookRegistrationKey(registration) {
2982
+ return `${registration.event}\0${registration.command}`;
2983
+ }
2984
+ function collectHookCommandsByEvent(hooks) {
2985
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
2986
+ return /* @__PURE__ */ new Map();
2987
+ }
2988
+ const commandsByEvent = /* @__PURE__ */ new Map();
2989
+ for (const [event, value] of Object.entries(hooks)) {
2990
+ if (!Array.isArray(value)) {
2991
+ continue;
2992
+ }
2993
+ for (const group of value) {
2994
+ if (!group || typeof group !== "object") {
2995
+ continue;
2996
+ }
2997
+ const hookItems = group.hooks;
2998
+ if (!Array.isArray(hookItems)) {
2999
+ continue;
3000
+ }
3001
+ for (const hook of hookItems) {
3002
+ if (!hook || typeof hook !== "object") {
3003
+ continue;
3004
+ }
3005
+ const command = hook.command;
3006
+ if (typeof command === "string") {
3007
+ const eventCommands = commandsByEvent.get(event) ?? [];
3008
+ eventCommands.push(command);
3009
+ commandsByEvent.set(event, eventCommands);
3010
+ }
3011
+ }
3012
+ }
3013
+ }
3014
+ return commandsByEvent;
3015
+ }
3016
+ function isUnexpectedManagedHookCommand(command, expectedCommands, cwd, meta, platform, manifestManagedCommands) {
3017
+ if (expectedCommands.has(command)) {
3018
+ return false;
3019
+ }
3020
+ if (manifestManagedCommands.has(command)) {
3021
+ return true;
3022
+ }
3023
+ const hookPath = extractHookPathFromCommand(command);
3024
+ return hookPath === null ? false : isCurrentProjectManagedHookPath(cwd, hookPath, meta, platform);
3025
+ }
3026
+ function isCurrentProjectManagedHookPath(cwd, hookPath, meta, platform) {
3027
+ const normalizedHookPath = normalizePathForHookComparison(hookPath);
3028
+ const configDir = normalizePathForHookComparison(meta.templateContext.platform_config_dir);
3029
+ return MANAGED_HOOK_SCRIPT_NAMES[platform].some((scriptName) => {
3030
+ const relativeHookPath = `${configDir}/hooks/${scriptName}`;
3031
+ if (normalizedHookPath === relativeHookPath) {
3032
+ return true;
3033
+ }
3034
+ return pathAliases(
3035
+ normalizePathForHookComparison(path22.resolve(cwd, relativeHookPath))
3036
+ ).includes(normalizedHookPath);
3037
+ });
3038
+ }
3039
+ function normalizePathForHookComparison(value) {
3040
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
3041
+ }
3042
+ function pathAliases(value) {
3043
+ const aliases = /* @__PURE__ */ new Set([value]);
3044
+ if (value.startsWith("/private/var/")) {
3045
+ aliases.add(value.replace(/^\/private\/var\//, "/var/"));
3046
+ } else if (value.startsWith("/var/")) {
3047
+ aliases.add(`/private${value}`);
3048
+ }
3049
+ return [...aliases];
3050
+ }
2654
3051
  async function resolveParentTopologyRefresh(targets, pending) {
2655
3052
  const parent = targets.find(
2656
3053
  (target) => target.label === "." && target.supermodule.role === "super-parent"