cairn-ai 0.2.8 → 0.3.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 (37) hide show
  1. package/.agents/workflows/cairn-plan.md +11 -18
  2. package/.agents/workflows/cairn-work.md +14 -20
  3. package/.claude/commands/cairn-plan.md +1 -1
  4. package/.claude/commands/cairn-work.md +1 -1
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/README.ko.md +5 -3
  7. package/README.md +5 -3
  8. package/commands/cairn-install.md +1 -0
  9. package/commands/cairn-plan.md +9 -15
  10. package/commands/cairn-work.md +13 -21
  11. package/docs/model-guidance/README.md +2 -2
  12. package/package.json +1 -1
  13. package/scripts/cairn-goal-cli.mjs +41 -10
  14. package/scripts/cairn-goal-operations-evidence.mjs +97 -22
  15. package/scripts/cairn-goal-operations-mutations.mjs +42 -8
  16. package/scripts/cairn-goal-operations.mjs +28 -15
  17. package/scripts/cairn-goal-policy.mjs +22 -2
  18. package/scripts/cairn-goal-state-validation.mjs +113 -12
  19. package/scripts/cairn-goal-state.mjs +17 -5
  20. package/scripts/cairn-goal.mjs +7 -2
  21. package/scripts/cairn-lifecycle-claude.mjs +142 -0
  22. package/scripts/cairn-lifecycle-commands.mjs +41 -3
  23. package/scripts/cairn-lifecycle-integrity.mjs +15 -2
  24. package/scripts/cairn-lifecycle-staging.mjs +9 -2
  25. package/scripts/cairn-lifecycle.mjs +5 -2
  26. package/scripts/cairn-state-context.mjs +13 -4
  27. package/scripts/cairn-state.mjs +48 -6
  28. package/skills/cairn-plan/SKILL.md +34 -175
  29. package/skills/cairn-plan/delegation.md +43 -0
  30. package/skills/cairn-plan/heavy-path.md +26 -0
  31. package/skills/cairn-plan/planning-rules.md +68 -0
  32. package/skills/cairn-work/SKILL.md +37 -142
  33. package/skills/cairn-work/delegation.md +43 -0
  34. package/skills/cairn-work/heavy-path.md +16 -0
  35. package/skills/cairn-work/recovery.md +29 -0
  36. package/skills/cairn-work/verification.md +65 -0
  37. package/templates/work-plan.md +3 -1
@@ -0,0 +1,142 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { quoteShellArg, renderMirrorFile, writeLocator } from "./cairn-lifecycle-mirror.mjs";
5
+
6
+ // Claude Code loads a plugin's default hooks/hooks.json and merges any manifest hooks, and it only expands
7
+ // ${CLAUDE_PLUGIN_ROOT}. The shared package hooks use Codex's ${PLUGIN_ROOT}, so Claude gets a separate thin
8
+ // plugin whose hooks call the installed versioned runtime by absolute path. Commands and agents stay in the
9
+ // existing ~/.claude mirrors so they are not listed twice.
10
+ export async function stageClaudePlugin({
11
+ stageRoot, runtimeStage, destinationRoot, versionedPluginRoot, marketplaceName, pluginName,
12
+ releaseVersion, skillNames, platform = process.platform,
13
+ }) {
14
+ const staged = join(stageRoot, "claude-plugin");
15
+ const pluginDir = join(staged, pluginName);
16
+ const packageJson = JSON.parse(await readFile(join(runtimeStage, "package.json"), "utf8"));
17
+ const description = packageJson.description ?? "Cairn agent harness";
18
+
19
+ await writeJson(join(staged, ".claude-plugin", "marketplace.json"), {
20
+ name: marketplaceName,
21
+ description: "Local Cairn marketplace managed by `cairn install`",
22
+ owner: { name: packageJson.author?.name ?? "Cairn" },
23
+ plugins: [{ name: pluginName, source: `./${pluginName}`, description, version: releaseVersion }],
24
+ });
25
+ await writeJson(join(pluginDir, ".claude-plugin", "plugin.json"), {
26
+ name: pluginName,
27
+ version: releaseVersion,
28
+ description,
29
+ author: { name: packageJson.author?.name ?? "Cairn" },
30
+ ...(packageJson.homepage ? { homepage: packageJson.homepage } : {}),
31
+ ...(packageJson.license ? { license: packageJson.license } : {}),
32
+ });
33
+ await writeJson(join(pluginDir, "hooks", "hooks.json"), await claudeHooks(runtimeStage, versionedPluginRoot, platform));
34
+
35
+ for (const name of skillNames) {
36
+ const skillStage = join(pluginDir, "skills", name);
37
+ await cp(join(runtimeStage, "skills", name), skillStage, { recursive: true });
38
+ const locatorPath = join(destinationRoot, pluginName, "skills", name, "references", "cairn-runtime.json");
39
+ await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), join(skillStage, "SKILL.md"), {
40
+ locatorPath, runtimeRoot: versionedPluginRoot, includeLocatorNotice: true,
41
+ });
42
+ await writeLocator(join(skillStage, "references", "cairn-runtime.json"), versionedPluginRoot);
43
+ }
44
+ return staged;
45
+ }
46
+
47
+ async function claudeHooks(runtimeStage, versionedPluginRoot, platform) {
48
+ const source = JSON.parse(await readFile(join(runtimeStage, "hooks", "hooks.json"), "utf8"));
49
+ const stateScript = quoteShellArg(join(versionedPluginRoot, "scripts", "cairn-state.mjs"), platform);
50
+ const hooks = {};
51
+ for (const [eventName, groups] of Object.entries(source.hooks ?? {})) {
52
+ hooks[eventName] = groups.map((group) => ({
53
+ ...group,
54
+ hooks: (group.hooks ?? []).map((handler) => {
55
+ const event = String(handler.command ?? "").match(/cairn-state\.mjs"?\s+([a-z-]+)\s*$/)?.[1];
56
+ if (!event) throw new Error(`Unsupported Cairn hook command for Claude Code: ${handler.command}`);
57
+ return { ...handler, command: `node ${stateScript} ${event}` };
58
+ }),
59
+ }));
60
+ }
61
+ return { hooks };
62
+ }
63
+
64
+ async function writeJson(path, value) {
65
+ await mkdir(dirname(path), { recursive: true });
66
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
67
+ }
68
+
69
+ // Registration goes through the documented `claude plugin` CLI. CLAUDE_CONFIG_DIR is only set when the caller
70
+ // overrode the Claude home, because setting it for the default home would move Claude's global config file.
71
+ export function createClaudeRegistration({ claudeHome, claudeHomeOverridden, marketplaceRoot, marketplaceName, pluginName }) {
72
+ const cli = process.env.CAIRN_CLAUDE_CLI ?? "claude";
73
+ const pluginId = `${pluginName}@${marketplaceName}`;
74
+ const env = claudeHomeOverridden ? { ...process.env, CLAUDE_CONFIG_DIR: claudeHome } : process.env;
75
+
76
+ // A .mjs/.js override runs through the current Node binary so tests can supply a portable stand-in CLI.
77
+ function run(args) {
78
+ if (/\.m?js$/.test(cli)) return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", env, timeout: 120_000 });
79
+ return spawnSync(cli, args, { encoding: "utf8", env, timeout: 120_000, shell: process.platform === "win32" });
80
+ }
81
+
82
+ function available() {
83
+ if (cli === "none") return false;
84
+ const result = run(["--version"]);
85
+ return !result.error && result.status === 0;
86
+ }
87
+
88
+ function json(args) {
89
+ const result = run([...args, "--json"]);
90
+ if (result.error || result.status !== 0) {
91
+ throw new Error(`claude ${args.join(" ")} failed: ${(result.stderr || result.stdout || result.error?.message || "").trim().split("\n").at(-1)}`);
92
+ }
93
+ const text = result.stdout.trim();
94
+ try {
95
+ return JSON.parse(text);
96
+ } catch {
97
+ return JSON.parse(text.split("\n").filter(Boolean).at(-1));
98
+ }
99
+ }
100
+
101
+ const ownsMarketplace = (entry) => entry?.name === marketplaceName
102
+ && typeof entry.path === "string" && resolve(entry.path) === resolve(marketplaceRoot);
103
+ const ownedPlugins = (list) => (Array.isArray(list) ? list : [])
104
+ .filter((entry) => entry?.id === pluginId && entry.scope === "user");
105
+
106
+ function status({ checked = false } = {}) {
107
+ if (!checked && !available()) return { available: false, marketplace: false, installed: false, enabled: false };
108
+ const marketplace = json(["plugin", "marketplace", "list"]).find((entry) => entry?.name === marketplaceName) ?? null;
109
+ const plugin = ownedPlugins(json(["plugin", "list"]))[0] ?? null;
110
+ return {
111
+ available: true,
112
+ marketplace: ownsMarketplace(marketplace),
113
+ foreignMarketplace: Boolean(marketplace) && !ownsMarketplace(marketplace),
114
+ installed: Boolean(plugin),
115
+ enabled: Boolean(plugin?.enabled),
116
+ };
117
+ }
118
+
119
+ function register() {
120
+ if (!available()) return { registered: false, reason: "claude CLI not found" };
121
+ const current = status({ checked: true });
122
+ if (current.foreignMarketplace) {
123
+ return { registered: false, reason: `a different Claude Code marketplace named "${marketplaceName}" already exists` };
124
+ }
125
+ if (!current.marketplace) json(["plugin", "marketplace", "add", marketplaceRoot, "--scope", "user"]);
126
+ if (!current.installed) json(["plugin", "install", pluginId, "--scope", "user"]);
127
+ else if (!current.enabled) json(["plugin", "enable", pluginId, "--scope", "user"]);
128
+ return { registered: true };
129
+ }
130
+
131
+ function unregister() {
132
+ if (!available()) return { unregistered: false, reason: "claude CLI not found" };
133
+ const current = status({ checked: true });
134
+ // Only remove a registration that points at Cairn's own marketplace directory.
135
+ if (!current.marketplace) return { unregistered: false, reason: "no Cairn-owned Claude Code marketplace is registered" };
136
+ if (current.installed) json(["plugin", "uninstall", pluginId, "--scope", "user"]);
137
+ json(["plugin", "marketplace", "remove", marketplaceName]);
138
+ return { unregistered: true };
139
+ }
140
+
141
+ return { available, register, status, unregister, pluginId };
142
+ }
@@ -5,11 +5,12 @@ import { hasSetting, readSharedConfigSnapshot, removeCairnConfig, splitSections
5
5
  import {
6
6
  installedSkillLocatorPath, validRuntimeLocator, validateAntigravitySkills,
7
7
  } from "./cairn-lifecycle-mirror.mjs";
8
+ import { createClaudeRegistration } from "./cairn-lifecycle-claude.mjs";
8
9
 
9
10
  export function createCommands(context) {
10
11
  const {
11
- antigravityCliHome, antigravityHome, claudeRuntimeLocatorPath,
12
- codexHome, configPath, installedPluginRoot, installedRuntimeLocatorPath,
12
+ antigravityCliHome, antigravityHome, claudeHome, claudeHomeOverridden, claudePluginMarketplaceRoot,
13
+ claudeRuntimeLocatorPath, codexHome, marketplaceName, configPath, installedPluginRoot, installedRuntimeLocatorPath,
13
14
  marketplaceJsonPath, ownershipPath, phases, pluginName, pluginRoot,
14
15
  pruneUninstallScaffolds, releaseVersion, samePath, skillNames, t, transaction,
15
16
  versionedPluginRoot, staging, integrity,
@@ -19,6 +20,9 @@ export function createCommands(context) {
19
20
  removeManagedTarget, replaceTarget, rollback,
20
21
  } = transaction;
21
22
  const { legacyMigrationTargets, stageInstall } = staging;
23
+ const claudeRegistration = createClaudeRegistration({
24
+ claudeHome, claudeHomeOverridden, marketplaceRoot: claudePluginMarketplaceRoot, marketplaceName, pluginName,
25
+ });
22
26
  const {
23
27
  adoptLegacy, anyManagedArtifactExists, assertOwnershipDigests,
24
28
  assertTargetsReplaceable, ownershipDigestsValid, readOwnership, targetDigest,
@@ -80,6 +84,24 @@ async function install(mode) {
80
84
  if (transaction.state !== "committed") await rollback(transaction);
81
85
  throw error;
82
86
  }
87
+ reportClaudeRegistration();
88
+ }
89
+
90
+ // Files are committed first; registering with Claude Code is idempotent, and a failure leaves the files
91
+ // installed with a manual command to finish, instead of rolling back a working Codex/Antigravity install.
92
+ function reportClaudeRegistration() {
93
+ const manual = `claude plugin marketplace add ${JSON.stringify(claudePluginMarketplaceRoot)} && claude plugin install ${claudeRegistration.pluginId} --scope user`;
94
+ try {
95
+ const result = claudeRegistration.register();
96
+ if (result.registered) {
97
+ console.log(`Claude Code plugin: ${claudeRegistration.pluginId} (${claudePluginMarketplaceRoot})`);
98
+ return;
99
+ }
100
+ console.log(`Claude Code plugin not registered: ${result.reason}. To register: ${manual}`);
101
+ } catch (error) {
102
+ console.log(`Claude Code plugin registration failed: ${error.message}. To register: ${manual}`);
103
+ process.exitCode = 1;
104
+ }
83
105
  }
84
106
 
85
107
  async function uninstall() {
@@ -92,6 +114,8 @@ async function uninstall() {
92
114
  if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
93
115
  }
94
116
  if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
117
+ // Unregister before removing files so Claude Code never points at a deleted plugin; a failure aborts with nothing changed.
118
+ const unregistered = claudeRegistration.unregister().unregistered;
95
119
  const transaction = await createTransaction();
96
120
  try {
97
121
  const configRecord = ownership.targets.find((record) => record.type === "config");
@@ -114,7 +138,13 @@ async function uninstall() {
114
138
  await finishTransaction(transaction);
115
139
  injectFailure("uninstall-after-commit");
116
140
  } catch (error) {
117
- if (transaction.state !== "committed") await rollback(transaction);
141
+ if (transaction.state !== "committed") {
142
+ await rollback(transaction);
143
+ // The rollback restored the plugin files, so restore the Claude Code registration that preceded them.
144
+ if (unregistered) {
145
+ try { claudeRegistration.register(); } catch (registrationError) { console.error(`Claude Code plugin re-registration failed: ${registrationError.message}`); }
146
+ }
147
+ }
118
148
  throw error;
119
149
  }
120
150
  await pruneUninstallScaffolds();
@@ -141,6 +171,14 @@ async function doctor() {
141
171
  checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
142
172
  checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
143
173
  checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath, versionedPluginRoot)]);
174
+ const claudePluginManifest = await readJson(join(claudePluginMarketplaceRoot, pluginName, ".claude-plugin", "plugin.json"));
175
+ checks.push(["Claude plugin files", claudePluginManifest?.version === releaseVersion
176
+ && await exists(join(claudePluginMarketplaceRoot, ".claude-plugin", "marketplace.json"))
177
+ && await exists(join(claudePluginMarketplaceRoot, pluginName, "hooks", "hooks.json"))]);
178
+ let claudeStatus = null;
179
+ try { claudeStatus = claudeRegistration.status(); } catch { claudeStatus = null; }
180
+ checks.push(["Claude plugin registered", Boolean(claudeStatus?.marketplace && claudeStatus.installed)]);
181
+ checks.push(["Claude plugin enabled", Boolean(claudeStatus?.enabled)]);
144
182
  checks.push(["Antigravity IDE skills", await exists(join(antigravityHome, "skills", "cairn-plan", "SKILL.md"))]);
145
183
  checks.push(["Antigravity CLI flat skills", await exists(join(antigravityCliHome, "skills", "cairn-plan.md"))]);
146
184
 
@@ -7,7 +7,7 @@ import { antigravityRuntimeLocatorPath } from "./cairn-lifecycle-mirror.mjs";
7
7
 
8
8
  export function createIntegrity(context) {
9
9
  const {
10
- antigravityCliHome, antigravityHome, claudeHome, claudeRuntimeLocatorPath,
10
+ antigravityCliHome, antigravityHome, claudeHome, claudePluginMarketplaceRoot, claudeRuntimeLocatorPath,
11
11
  commandNames, configPath, installedPluginRoot, legacyAntigravityHome,
12
12
  marketplaceJsonPath, marketplaceRoot, ownershipPath, phases, pluginName,
13
13
  releaseVersion, skillNames,
@@ -97,13 +97,25 @@ function validateOwnershipShape(ownership, { required = false } = {}) {
97
97
  paths.add(record.path);
98
98
  }
99
99
  const requiredTargets = requiredOwnershipTargets(ownership.version);
100
- if (ids.size !== requiredTargets.size || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
100
+ const optionalIds = new Set(requiredTargets.has("claude-plugin") ? [] : ["claude-plugin"]);
101
+ if ([...ids].some((id) => !requiredTargets.has(id) && !optionalIds.has(id)) || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
101
102
  throw new Error("Ownership manifest does not contain the complete managed target set.");
102
103
  }
103
104
  }
104
105
 
106
+ const CLAUDE_PLUGIN_SINCE = "0.2.9";
107
+
108
+ function compareVersions(left, right) {
109
+ const parse = (value) => String(value).split("-")[0].split(".").map(Number);
110
+ const [a, b] = [parse(left), parse(right)];
111
+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] - b[index];
112
+ return 0;
113
+ }
114
+
105
115
  function requiredOwnershipTargets(version = releaseVersion) {
106
116
  const ids = ["codex-source", "codex-runtime", "marketplace", "claude-runtime", "ide-runtime", "cli-runtime", "codex-config"];
117
+ // Manifests written before the Claude Code plugin existed must stay valid so those installs can upgrade.
118
+ if (compareVersions(version, CLAUDE_PLUGIN_SINCE) >= 0) ids.push("claude-plugin");
107
119
  for (const name of commandNames()) ids.push(`claude-command-${name}`, `ide-workflow-${name}`, `cli-workflow-${name}`);
108
120
  for (const name of ["explorer", "worker"]) ids.push(`claude-agent-${name}`);
109
121
  for (const name of skillNames()) ids.push(`ide-skill-${name}`, `cli-skill-${name}`);
@@ -116,6 +128,7 @@ function expectedManagedTarget(id, version = releaseVersion) {
116
128
  "codex-runtime": { phase: "codex", path: versionedRuntimeRoot(version), type: "tree" },
117
129
  marketplace: { phase: "codex", path: marketplaceJsonPath, type: "file" },
118
130
  "claude-runtime": { phase: "claude", path: claudeRuntimeLocatorPath, type: "file" },
131
+ "claude-plugin": { phase: "claude", path: claudePluginMarketplaceRoot, type: "tree" },
119
132
  "ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityHome), type: "file" },
120
133
  "cli-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityCliHome), type: "file" },
121
134
  "legacy-ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file" },
@@ -4,11 +4,12 @@ import { readSharedConfigSnapshot, trustedHookStates } from "./cairn-lifecycle-c
4
4
  import {
5
5
  antigravityRuntimeLocatorPath, antigravitySkillLocatorPath, renderMirrorFile, writeLocator,
6
6
  } from "./cairn-lifecycle-mirror.mjs";
7
+ import { stageClaudePlugin } from "./cairn-lifecycle-claude.mjs";
7
8
 
8
9
  export function createStaging(context) {
9
10
  const {
10
- agentNames, antigravityCliHome, antigravityHome, claudeHome,
11
- claudeRuntimeLocatorPath, commandNames, configPath, installedPluginRoot,
11
+ agentNames, antigravityCliHome, antigravityHome, claudeHome, claudePluginMarketplaceRoot,
12
+ claudeRuntimeLocatorPath, commandNames, releaseVersion, configPath, installedPluginRoot,
12
13
  legacyAntigravityHome, marketplaceJsonPath, marketplaceName, pluginName,
13
14
  pluginRoot, skillNames, updateConfig, versionedPluginRoot, assertRegularTree,
14
15
  } = context;
@@ -44,6 +45,12 @@ async function stageInstall(stageRoot) {
44
45
  add(`claude-agent-${name}`, "claude", join(claudeHome, "agents", `cairn-${name}.md`), staged, "file");
45
46
  }
46
47
 
48
+ const claudePluginStage = await stageClaudePlugin({
49
+ stageRoot, runtimeStage, destinationRoot: claudePluginMarketplaceRoot, versionedPluginRoot,
50
+ marketplaceName, pluginName, releaseVersion, skillNames: skillNames(),
51
+ });
52
+ add("claude-plugin", "claude", claudePluginMarketplaceRoot, claudePluginStage);
53
+
47
54
  await stageAntigravity(targets, stageRoot, runtimeStage, antigravityHome, "ide", false);
48
55
  await stageAntigravity(targets, stageRoot, runtimeStage, antigravityCliHome, "cli", true);
49
56
 
@@ -30,6 +30,7 @@ export {
30
30
  const pluginRoot = resolvePluginRoot(import.meta.url);
31
31
  const homeRoot = process.env.HOME ?? process.env.USERPROFILE ?? homedir() ?? ".";
32
32
  const codexHome = resolve(process.env.CODEX_HOME ?? join(homeRoot, ".codex"));
33
+ const claudeHomeOverridden = typeof process.env.CLAUDE_HOME === "string" && process.env.CLAUDE_HOME.length > 0;
33
34
  const claudeHome = resolve(process.env.CLAUDE_HOME ?? join(homeRoot, ".claude"));
34
35
  const antigravityHome = resolve(process.env.ANTIGRAVITY_HOME ?? join(homeRoot, ".gemini", "config"));
35
36
  const legacyAntigravityHome = resolve(process.env.CAIRN_LEGACY_ANTIGRAVITY_HOME ?? join(homeRoot, ".agents"));
@@ -45,6 +46,7 @@ const transactionPath = join(marketplaceRoot, ".cairn", "transaction.json");
45
46
  const transactionRoot = join(marketplaceRoot, ".cairn", "transactions");
46
47
  const lifecycleLockPath = join(marketplaceRoot, ".cairn", "lifecycle.lock");
47
48
  const claudeRuntimeLocatorPath = join(claudeHome, "cairn", "runtime.json");
49
+ const claudePluginMarketplaceRoot = join(marketplaceRoot, "claude");
48
50
  const sourceManifest = JSON.parse(await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"));
49
51
  const releaseVersion = sourceManifest.version;
50
52
  const versionedPluginRoot = join(marketplaceRoot, pluginName, releaseVersion);
@@ -53,7 +55,8 @@ const phases = ["codex", "claude", "antigravity", "config", "cleanup"];
53
55
  const MAX_LIFECYCLE_LOCK_TIMEOUT_MS = 2_147_483_647;
54
56
 
55
57
  const lifecycleContext = {
56
- agentNames, antigravityCliHome, antigravityHome, claudeHome, claudeRuntimeLocatorPath,
58
+ agentNames, antigravityCliHome, antigravityHome, claudeHome, claudeHomeOverridden,
59
+ claudePluginMarketplaceRoot, claudeRuntimeLocatorPath,
57
60
  codexHome, commandNames, configPath, installedPluginRoot, installedRuntimeLocatorPath,
58
61
  legacyAntigravityHome, lifecycleLockPath, marketplaceJsonPath, marketplaceName,
59
62
  marketplaceRoot, ownershipPath, phases, pluginName, pluginRoot, releaseVersion,
@@ -105,7 +108,7 @@ function lifecycleLockTimeout() {
105
108
  async function assertLifecycleMutationPaths() {
106
109
  const roots = [
107
110
  [codexHome, lifecycleLockPath], [codexHome, installedPluginRoot], [codexHome, versionedPluginRoot],
108
- [codexHome, marketplaceJsonPath], [isInside(codexHome, configPath) ? codexHome : dirname(configPath), configPath],
111
+ [codexHome, marketplaceJsonPath], [codexHome, claudePluginMarketplaceRoot], [isInside(codexHome, configPath) ? codexHome : dirname(configPath), configPath],
109
112
  [claudeHome, join(claudeHome, "commands")], [claudeHome, join(claudeHome, "agents")],
110
113
  [claudeHome, claudeRuntimeLocatorPath], [antigravityHome, join(antigravityHome, "skills")],
111
114
  ...skillNames().map((name) => [antigravityHome, join(antigravityHome, "skills", name)]),
@@ -3,6 +3,7 @@ const contextLimits = {
3
3
  roadmapTaskId: 24,
4
4
  taskTitle: 40,
5
5
  roadmapWindow: 3,
6
+ parallelTitle: 16,
6
7
  };
7
8
 
8
9
  export function prependContext(result, notice) {
@@ -21,6 +22,8 @@ export function prependContext(result, notice) {
21
22
  };
22
23
  }
23
24
 
25
+ import { currentTask } from "./cairn-goal-policy.mjs";
26
+
24
27
  export function contextResult({ ko, state, event }) {
25
28
  const base = kernelBase(ko);
26
29
  const idlePolicy = event === "user-prompt-submit"
@@ -37,9 +40,7 @@ export function contextResult({ ko, state, event }) {
37
40
  return interruptedContextResult(event, ko, state);
38
41
  }
39
42
  if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
40
- const task = state.tasks.find((item) => item.status === "active")
41
- ?? state.tasks.find((item) => item.status === "pending")
42
- ?? state.tasks.find((item) => item.status === "blocked");
43
+ const task = currentTask(state);
43
44
  const planId = state.goal.planId;
44
45
  const phase = task ? "cairn-work" : "cairn-review";
45
46
  const activeHeader = ko
@@ -69,7 +70,15 @@ export function contextResult({ ko, state, event }) {
69
70
  ? `곁가지 질문·조향 뒤 ${taskId}를 재개하고 남은 task를 최종 검토까지 진행하세요(일시정지·중단·전환 요청 제외).`
70
71
  : `After a side question or steer, resume ${taskId}, then advance remaining tasks to final review unless asked to pause, stop, or switch.`))
71
72
  : "";
72
- return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
73
+ return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, parallelLine(ko, state), resume].filter(Boolean).join("\n") });
74
+ }
75
+
76
+ // Only when several tasks run at once: their ids and clipped titles, never the dependency graph.
77
+ function parallelLine(ko, state) {
78
+ const active = state.tasks.filter((task) => task.status === "active");
79
+ if (active.length < 2) return "";
80
+ const list = active.map((task) => `${clipText(task.id, contextLimits.roadmapTaskId)} (${clipText(task.title, contextLimits.parallelTitle)})`).join(", ");
81
+ return `${ko ? "병렬 active" : "Parallel active"}: ${list}`;
73
82
  }
74
83
 
75
84
  function kernelBase(ko) {
@@ -5,7 +5,7 @@ import { contextResult, foreignSessionContextResult, prependContext } from "./ca
5
5
  import { continuationReason, stopAllowedMessage, stopHookOutput } from "./cairn-state-hook.mjs";
6
6
  import { initializeProject } from "./cairn-state-init.mjs";
7
7
  import { evaluateStop, isGoalOwnedBySession, reconcileWorktreeState } from "./cairn-goal.mjs";
8
- import { readStopGuard, writeStopGuard } from "./cairn-goal-state.mjs";
8
+ import { readHookGuard, readStopGuard, writeHookGuard, writeStopGuard } from "./cairn-goal-state.mjs";
9
9
  import { resolveRepoRoot } from "./cairn-paths.mjs";
10
10
 
11
11
  const recoverableGoalStatuses = new Set(["active", "paused", "blocked"]);
@@ -52,7 +52,10 @@ export async function runStateResult(event = "manual", {
52
52
  if (recoverableGoalStatuses.has(state?.goal?.status) && !isGoalOwnedBySession(state, payload?.session_id)) {
53
53
  return foreignSessionContextResult({ ko, event, sessionId: payload?.session_id, root: resolvedRoot });
54
54
  }
55
- const result = contextResult({ ko, state, event });
55
+ const context = contextResult({ ko, state, event });
56
+ // SessionStart (also after compaction) carries a literal CLI prefix so agents do not rebuild or store it in a
57
+ // shell variable, which does not survive between tool calls.
58
+ const result = event === "session-start" ? appendContext(context, cliPrefixLine()) : context;
56
59
  if (!reconciled.removed) return result;
57
60
  const notice = ko
58
61
  ? "Cairn worktree 점검: 현재 worktree에 속하지 않는 stale Cairn 상태를 제거했습니다."
@@ -67,6 +70,19 @@ export async function runStateResult(event = "manual", {
67
70
  if (state?.goal?.status !== "active" || !isGoalOwnedBySession(state, payload?.session_id)) {
68
71
  return { status: 0, message: "", hookOutput: {} };
69
72
  }
73
+ // Remind once per goal, task, and session; repeating it after every edit only grows the cached prefix.
74
+ const reminderKey = {
75
+ goalId: state.goal.id,
76
+ taskId: state.tasks.find((task) => task.status === "active")?.id ?? null,
77
+ sessionId: payload?.session_id ?? null,
78
+ };
79
+ const previousReminder = await readHookGuard(resolvedRoot, "reminder");
80
+ if (previousReminder?.goalId === reminderKey.goalId
81
+ && previousReminder?.taskId === reminderKey.taskId
82
+ && previousReminder?.sessionId === reminderKey.sessionId) {
83
+ return { status: 0, message: "", hookOutput: {} };
84
+ }
85
+ await writeHookGuard(resolvedRoot, "reminder", reminderKey);
70
86
  const message = ko
71
87
  ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
72
88
  : "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
@@ -106,14 +122,40 @@ export async function runStateResult(event = "manual", {
106
122
 
107
123
  async function recordStopProgress(root, state, event, payload) {
108
124
  const scope = event === "subagent-stop" ? `subagent:${payload?.agent_id ?? ""}` : "turn";
125
+ // Parallel workers share this guard: keep one entry per scope, and measure a worker's progress by its own task
126
+ // so sibling writes (which bump the goal revision) do not look like progress.
127
+ const assigned = event === "subagent-stop"
128
+ ? state.tasks.find((task) => task.status === "active" && task.assignedAgentId === payload?.agent_id)
129
+ : null;
130
+ const marker = assigned ? `${assigned.id}:${assigned.status}:${assigned.updatedAt}` : state.revision;
109
131
  const previous = await readStopGuard(root);
110
- const progressed = previous?.goalId !== state.goal.id
111
- || previous?.scope !== scope
112
- || previous?.revision !== state.revision;
113
- if (progressed) await writeStopGuard(root, { goalId: state.goal.id, scope, revision: state.revision });
132
+ const scopes = previous?.goalId === state.goal.id && previous?.scopes && typeof previous.scopes === "object" ? previous.scopes : {};
133
+ const progressed = scopes[scope] !== marker;
134
+ if (progressed) {
135
+ const entries = Object.entries({ ...scopes, [scope]: marker }).slice(-16);
136
+ await writeStopGuard(root, { goalId: state.goal.id, scopes: Object.fromEntries(entries) });
137
+ }
114
138
  return progressed;
115
139
  }
116
140
 
141
+ // Only the CLI path: goal commands resolve the repository root themselves, and a second absolute path would let
142
+ // long install or checkout paths push the SessionStart capsule past its budget.
143
+ function cliPrefixLine() {
144
+ const cli = fileURLToPath(new URL("./cairn.mjs", import.meta.url));
145
+ return `Cairn CLI: node ${JSON.stringify(cli)} goal <command>`;
146
+ }
147
+
148
+ function appendContext(result, line) {
149
+ const specific = result.hookOutput?.hookSpecificOutput;
150
+ return {
151
+ ...result,
152
+ message: `${result.message}\n${line}`,
153
+ hookOutput: specific
154
+ ? { ...result.hookOutput, hookSpecificOutput: { ...specific, additionalContext: `${specific.additionalContext}\n${line}` } }
155
+ : result.hookOutput,
156
+ };
157
+ }
158
+
117
159
  function hookFailureResult(event, error) {
118
160
  const detail = String(error?.message ?? error).split("\n")[0].slice(0, 300);
119
161
  const message = `Cairn ${event} hook could not read goal state: ${detail}. Run cairn goal status to inspect it; do not treat an active goal as finished.`;