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.
- package/.agents/workflows/cairn-plan.md +11 -18
- package/.agents/workflows/cairn-work.md +14 -20
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/commands/cairn-install.md +1 -0
- package/commands/cairn-plan.md +9 -15
- package/commands/cairn-work.md +13 -21
- package/docs/model-guidance/README.md +2 -2
- package/package.json +1 -1
- package/scripts/cairn-goal-cli.mjs +41 -10
- package/scripts/cairn-goal-operations-evidence.mjs +97 -22
- package/scripts/cairn-goal-operations-mutations.mjs +42 -8
- package/scripts/cairn-goal-operations.mjs +28 -15
- package/scripts/cairn-goal-policy.mjs +22 -2
- package/scripts/cairn-goal-state-validation.mjs +113 -12
- package/scripts/cairn-goal-state.mjs +17 -5
- package/scripts/cairn-goal.mjs +7 -2
- package/scripts/cairn-lifecycle-claude.mjs +142 -0
- package/scripts/cairn-lifecycle-commands.mjs +41 -3
- package/scripts/cairn-lifecycle-integrity.mjs +15 -2
- package/scripts/cairn-lifecycle-staging.mjs +9 -2
- package/scripts/cairn-lifecycle.mjs +5 -2
- package/scripts/cairn-state-context.mjs +13 -4
- package/scripts/cairn-state.mjs +48 -6
- package/skills/cairn-plan/SKILL.md +34 -175
- package/skills/cairn-plan/delegation.md +43 -0
- package/skills/cairn-plan/heavy-path.md +26 -0
- package/skills/cairn-plan/planning-rules.md +68 -0
- package/skills/cairn-work/SKILL.md +37 -142
- package/skills/cairn-work/delegation.md +43 -0
- package/skills/cairn-work/heavy-path.md +16 -0
- package/skills/cairn-work/recovery.md +29 -0
- package/skills/cairn-work/verification.md +65 -0
- 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,
|
|
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")
|
|
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
|
-
|
|
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,
|
|
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
|
|
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) {
|
package/scripts/cairn-state.mjs
CHANGED
|
@@ -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
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.`;
|