cairn-ai 0.4.1 → 0.6.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-memory.md +2 -2
- package/.agents/workflows/cairn-plan.md +3 -3
- package/.agents/workflows/cairn-review.md +1 -1
- package/.agents/workflows/cairn-work.md +2 -2
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/README.de.md +14 -3
- package/README.es.md +14 -3
- package/README.fr.md +14 -3
- package/README.ja.md +14 -3
- package/README.ko.md +17 -7
- package/README.md +17 -7
- package/README.pt.md +14 -3
- package/README.zh.md +14 -3
- package/agents/explorer.md +1 -1
- package/agents/worker.md +3 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +3 -3
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-work.md +2 -2
- package/docs/model-guidance/README.md +1 -1
- package/docs/model-guidance/claude.md +1 -1
- package/package.json +1 -1
- package/scripts/cairn-cleanup.mjs +142 -15
- package/scripts/cairn-goal-cli.mjs +57 -5
- package/scripts/cairn-goal-history.mjs +316 -0
- package/scripts/cairn-goal-operations-evidence.mjs +4 -2
- package/scripts/cairn-goal-operations.mjs +25 -8
- package/scripts/cairn-goal-state-validation.mjs +11 -0
- package/scripts/cairn-goal.mjs +3 -0
- package/scripts/cairn-index.mjs +1052 -0
- package/scripts/cairn-safe-fs.mjs +7 -2
- package/scripts/cairn-state-context.mjs +101 -10
- package/scripts/cairn-state-init.mjs +11 -68
- package/scripts/cairn-state.mjs +14 -3
- package/scripts/cairn.mjs +55 -18
- package/skills/cairn-memory/SKILL.md +4 -4
- package/skills/cairn-plan/SKILL.md +3 -3
- package/skills/cairn-plan/delegation.md +7 -4
- package/skills/cairn-plan/heavy-path.md +2 -1
- package/skills/cairn-plan/planning-rules.md +11 -1
- package/skills/cairn-review/SKILL.md +3 -1
- package/skills/cairn-work/SKILL.md +1 -1
- package/skills/cairn-work/delegation.md +7 -4
- package/skills/cairn-work/heavy-path.md +3 -2
- package/skills/cairn-work/verification.md +15 -1
- package/templates/MEMORY.md +4 -6
- package/templates/PLAN.md +2 -15
- package/templates/domain-memory.md +2 -0
- package/templates/work-plan.md +7 -5
|
@@ -51,7 +51,7 @@ The goal is not to make prompts long by model. The goal is to stabilize the same
|
|
|
51
51
|
## Model and Reasoning Effort Routing
|
|
52
52
|
|
|
53
53
|
- The plan decides each task's model from its nature and records `Requested model` and `Effective model`; models are not always inherited. Tiers: fast = read-only exploration, search, inventory, summarization (Claude Code `haiku`); balanced = Light Path implementation and routine verification (`sonnet`); stronger = planning, complexity triage, Heavy Path implementation, review, and final verification/review (`opus`); strongest = Heavy Path design only (`fable`), run as a separate `kind: "design"` task dispatched to that tier rather than part of planning. Antigravity `invoke_subagent` `Model` accepts only a tier enum (fast `flash_lite`, balanced `flash`, stronger `pro`, strongest `pro`; no tier above `pro`) and has no effort option, so Effective reasoning effort is `inherited`. Codex slugs: fast `gpt-6-luna`, balanced `gpt-6.1-terra`, stronger `gpt-6-sol`, strongest `gpt-6-astra`.
|
|
54
|
-
- Model Effective: passed
|
|
54
|
+
- Model Effective: the passed value plus the exact model ID the worker reports from its own system prompt (e.g. `fable (claude-fable-5-1)`; `<value> (unreported)` when absent, never guess; `goal assign --effective-model <id|unreported>`); not passed is `not-applied: <reason>`; `inherited` only when the host has no model option. If a lower-tier worker fails a gate, re-dispatch that task one tier up (fast, balanced, stronger, strongest) before recording a blocker. A subagent's model and effort are fixed at dispatch; any re-dispatch that changes either (one tier up, Light → Heavy promotion, route-change recalculation) MUST be a new dispatch (Claude Code: a new Agent call), never `SendMessage` to the existing subagent. A design model that is not the host's strongest tier (`--host claude-code` `fable`, `codex` `gpt-6-astra`, `antigravity` `pro`; any of the three when `--host` is omitted) fails completion unless `--allow-no-design-model <reason>`.
|
|
55
55
|
- Routing is per dispatch only; never change model or global/user config files. `Model routing: inherited` in `MEMORY.md` or the plan disables per-task selection.
|
|
56
56
|
- Light Path planning, implementation, and verification request `medium`.
|
|
57
57
|
- Heavy Path planning, review, and implementation request `high`; final verification and review request `xhigh`.
|
|
@@ -18,7 +18,7 @@ Claude-family models are strong at long-context retention, policy interpretation
|
|
|
18
18
|
- Do not skip the two verification gates even on Light Path.
|
|
19
19
|
- Do not approve a plan that lacks dry-run or check evidence for external-state-changing work.
|
|
20
20
|
- Do not allow open-ended verification loops. Require a blocker or sub-task split after two failed passes.
|
|
21
|
-
- Choose the model per task (fast `haiku`, balanced `sonnet`, stronger `opus`: planning, triage, Heavy implementation, review, final verification/review; strongest `fable`: Heavy design only, as a separate `kind: "design"` task dispatched to that tier rather than part of planning) and record `Requested model`/`Effective model`; Effective is the passed value, `not-applied: <reason>`, or `inherited` when the host has no model option. Re-dispatch one tier up (fast, balanced, stronger, strongest) if a lower-tier worker fails a gate. `Model routing: inherited` disables this. `Requested reasoning effort` is Cairn's desired profile; `Effective reasoning effort` is what the dispatch response confirms. Every new task/worker dispatch MUST pass the task's requested effort through the host option together with the chosen model; recording without passing is a defect. Effective is the requested value when the dispatch passed it and the host did not reject it, `not-applied: <reason>` when it was not passed, and `inherited` only when the host exposes no effort option. Infer capability only from the exposed interface or dispatch response, distinguish Cairn non-integration from host non-support, and change no global/user configuration.
|
|
21
|
+
- Choose the model per task (fast `haiku`, balanced `sonnet`, stronger `opus`: planning, triage, Heavy implementation, review, final verification/review; strongest `fable`: Heavy design only, as a separate `kind: "design"` task dispatched to that tier rather than part of planning) and record `Requested model`/`Effective model`; Effective is the passed value plus the exact model ID the worker reports from its own system prompt (`<value> (unreported)` when absent, never guess), `not-applied: <reason>`, or `inherited` when the host has no model option. Re-dispatch one tier up (fast, balanced, stronger, strongest) if a lower-tier worker fails a gate. A subagent's model and effort are fixed at dispatch; any re-dispatch that changes either (one tier up, Light → Heavy promotion, route-change recalculation) MUST be a new dispatch (Claude Code: a new Agent call), never `SendMessage` to the existing subagent. A design model that is not the host's strongest tier (`--host claude-code` `fable`, `codex` `gpt-6-astra`, `antigravity` `pro`; any of the three when `--host` is omitted) fails completion unless `--allow-no-design-model <reason>`. `Model routing: inherited` disables this. `Requested reasoning effort` is Cairn's desired profile; `Effective reasoning effort` is what the dispatch response confirms. Every new task/worker dispatch MUST pass the task's requested effort through the host option together with the chosen model; recording without passing is a defect. Effective is the requested value when the dispatch passed it and the host did not reject it, `not-applied: <reason>` when it was not passed, and `inherited` only when the host exposes no effort option. Infer capability only from the exposed interface or dispatch response, distinguish Cairn non-integration from host non-support, and change no global/user configuration.
|
|
22
22
|
- In Claude Code, pass model with the Agent tool `model` parameter (`haiku|sonnet|opus|fable`) and effort with `effort` (`low|medium|high|xhigh|max`, same as Cairn values), for example `{"description": "Implement task-1", "subagent_type": "general-purpose", "model": "sonnet", "effort": "medium", "prompt": "..."}`. `subagent_type: "fork"` ignores `model` and `effort`; never dispatch routed work as a fork.
|
|
23
23
|
- On Heavy Path, check plan gaps before implementation.
|
|
24
24
|
- Review all three complexity records: the provisional request checkpoint, post-exploration planning checkpoint, and code checkpoint after exact file/caller/test inspection immediately before the first edit. Before editing, evidence may change either route. Every change must synchronize the plan artifact, repository goal task roadmap through `goal replan`, and native UI plan, including reviews and required evidence. After editing begins, a new Heavy signal promotes Light Path to Heavy Path: stop further edits, mark affected evidence stale, synchronize all three roadmaps, and repeat the code checkpoint.
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { constants, readFileSync } from "node:fs";
|
|
3
|
+
import { lstat, open, readdir, rm, rmdir } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
3
5
|
import { join, resolve } from "node:path";
|
|
4
6
|
import { pathToFileURL } from "node:url";
|
|
5
7
|
import { reconcileWorktreeState } from "./cairn-goal.mjs";
|
|
6
|
-
import {
|
|
8
|
+
import { resolvePluginRoot, resolveRepoRoot } from "./cairn-paths.mjs";
|
|
9
|
+
import { lockOwnerIsDead, parseLockRecord } from "./cairn-safe-fs.mjs";
|
|
7
10
|
|
|
8
|
-
const
|
|
11
|
+
const staleLockPrefix = "state.lock.stale.";
|
|
12
|
+
const staleLockName = /^state\.lock\.stale\.(.+)\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
13
|
+
const MAX_LOCK_RECORD_BYTES = 4096;
|
|
14
|
+
const usage = "Usage: cairn cleanup [--root PATH] [--check | --yes]";
|
|
9
15
|
|
|
10
16
|
export async function cleanupProjectCairn({ root = process.cwd(), apply = false } = {}) {
|
|
11
17
|
const repoRoot = resolve(root);
|
|
@@ -14,7 +20,9 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
14
20
|
try {
|
|
15
21
|
rootStat = await lstat(legacyRoot);
|
|
16
22
|
} catch (error) {
|
|
17
|
-
if (error?.code === "ENOENT")
|
|
23
|
+
if (error?.code === "ENOENT") {
|
|
24
|
+
return { root: repoRoot, apply, known: [], unknown: [], removed: [], projectDirectoryRemoved: false, preservedReasons: {} };
|
|
25
|
+
}
|
|
18
26
|
throw error;
|
|
19
27
|
}
|
|
20
28
|
if (rootStat.isSymbolicLink()) throw new Error(`Legacy Cairn root must not be a symbolic link: ${legacyRoot}`);
|
|
@@ -23,21 +31,42 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
23
31
|
const entries = await readdir(legacyRoot, { withFileTypes: true });
|
|
24
32
|
const known = [];
|
|
25
33
|
const unknown = [];
|
|
34
|
+
const preservedReasons = {};
|
|
26
35
|
for (const entry of entries) {
|
|
27
|
-
|
|
36
|
+
if (entry.isFile() && isLockFileName(entry.name)) {
|
|
37
|
+
// Lock files are Cairn-owned only when their content is a lock record that may be removed.
|
|
38
|
+
const reason = await lockFileReason(join(legacyRoot, entry.name), entry.name);
|
|
39
|
+
if (reason === null) known.push(entry.name);
|
|
40
|
+
else {
|
|
41
|
+
unknown.push(entry.name);
|
|
42
|
+
preservedReasons[entry.name] = reason;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const isKnownFile = entry.isFile() && entry.name === "state.json";
|
|
28
47
|
const isKnownDirectory = entry.isDirectory() && entry.name === "tools";
|
|
29
48
|
(isKnownFile || isKnownDirectory ? known : unknown).push(entry.name);
|
|
30
49
|
}
|
|
31
|
-
if (!apply) return {
|
|
50
|
+
if (!apply) return cleanupResult({ repoRoot, apply, known, unknown, removed: [], projectDirectoryRemoved: false, preservedReasons });
|
|
32
51
|
|
|
33
52
|
if (known.includes("state.json")) await reconcileWorktreeState({ root: repoRoot });
|
|
34
53
|
const removed = [];
|
|
35
|
-
for (const name of known) {
|
|
54
|
+
for (const name of [...known]) {
|
|
36
55
|
const path = join(legacyRoot, name);
|
|
37
56
|
try {
|
|
38
57
|
const stat = await lstat(path);
|
|
39
58
|
const expectedType = name === "tools" ? stat.isDirectory() : stat.isFile();
|
|
40
59
|
if (!expectedType) continue;
|
|
60
|
+
if (isLockFileName(name)) {
|
|
61
|
+
// Judge the lock again right before removal; a lock that changed owner is preserved.
|
|
62
|
+
const reason = await lockFileReason(path, name);
|
|
63
|
+
if (reason !== null) {
|
|
64
|
+
known.splice(known.indexOf(name), 1);
|
|
65
|
+
unknown.push(name);
|
|
66
|
+
preservedReasons[name] = reason;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
41
70
|
await rm(path, { recursive: name === "tools", force: true });
|
|
42
71
|
removed.push(name);
|
|
43
72
|
} catch (error) {
|
|
@@ -51,17 +80,115 @@ export async function cleanupProjectCairn({ root = process.cwd(), apply = false
|
|
|
51
80
|
} catch (error) {
|
|
52
81
|
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error?.code)) throw error;
|
|
53
82
|
}
|
|
54
|
-
return {
|
|
83
|
+
return cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cleanupResult({ repoRoot, apply, known, unknown, removed, projectDirectoryRemoved, preservedReasons }) {
|
|
87
|
+
return {
|
|
88
|
+
root: repoRoot,
|
|
89
|
+
apply,
|
|
90
|
+
known: known.sort(),
|
|
91
|
+
unknown: unknown.sort(),
|
|
92
|
+
removed: removed.sort(),
|
|
93
|
+
projectDirectoryRemoved,
|
|
94
|
+
preservedReasons: Object.fromEntries(Object.keys(preservedReasons).sort().map((name) => [name, preservedReasons[name]])),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isLockFileName(name) {
|
|
99
|
+
return name === "state.lock" || name.startsWith(staleLockPrefix);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Returns null when the lock file may be removed, otherwise the reason it is preserved.
|
|
103
|
+
async function lockFileReason(path, name) {
|
|
104
|
+
let nonce = null;
|
|
105
|
+
if (name !== "state.lock") {
|
|
106
|
+
const match = staleLockName.exec(name);
|
|
107
|
+
if (!match) return "invalid-lock-record";
|
|
108
|
+
nonce = match[1];
|
|
109
|
+
}
|
|
110
|
+
const record = await readLockRecord(path);
|
|
111
|
+
if (!record) return "invalid-lock-record";
|
|
112
|
+
if (nonce !== null) return record.nonce === nonce ? null : "invalid-lock-record";
|
|
113
|
+
if (lockOwnerIsDead(record)) return null;
|
|
114
|
+
return record.hostname === hostname() ? "live-lock-owner" : "foreign-host-lock";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readLockRecord(path) {
|
|
118
|
+
let handle;
|
|
119
|
+
try {
|
|
120
|
+
// Never follow a link or block on a special file that replaced the inspected entry.
|
|
121
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
122
|
+
const stat = await handle.stat();
|
|
123
|
+
if (!stat.isFile() || stat.size > MAX_LOCK_RECORD_BYTES) return null;
|
|
124
|
+
const buffer = Buffer.alloc(MAX_LOCK_RECORD_BYTES + 1);
|
|
125
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
126
|
+
if (bytesRead > MAX_LOCK_RECORD_BYTES) return null;
|
|
127
|
+
return parseLockRecord(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
} finally {
|
|
131
|
+
await handle?.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class CleanupUsageError extends Error {}
|
|
136
|
+
|
|
137
|
+
function parseCleanupArgs(args) {
|
|
138
|
+
const provided = new Set();
|
|
139
|
+
let explicitRoot = null;
|
|
140
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
141
|
+
const argument = args[index];
|
|
142
|
+
if (["--help", "--version", "--check", "--yes"].includes(argument)) {
|
|
143
|
+
if (provided.has(argument)) throw new CleanupUsageError(`${argument} may only be provided once`);
|
|
144
|
+
provided.add(argument);
|
|
145
|
+
} else if (argument === "--root" || argument.startsWith("--root=")) {
|
|
146
|
+
if (explicitRoot !== null) throw new CleanupUsageError("--root may only be provided once");
|
|
147
|
+
const value = argument === "--root" ? args[index + 1] : argument.slice("--root=".length);
|
|
148
|
+
if (!value || value.startsWith("--")) throw new CleanupUsageError("--root requires a path");
|
|
149
|
+
explicitRoot = value;
|
|
150
|
+
if (argument === "--root") index += 1;
|
|
151
|
+
} else {
|
|
152
|
+
throw new CleanupUsageError(`unknown option: ${argument}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (provided.has("--help") && provided.has("--version")) throw new CleanupUsageError("--help and --version cannot be used together");
|
|
156
|
+
if (provided.has("--check") && provided.has("--yes")) throw new CleanupUsageError("--check and --yes cannot be used together");
|
|
157
|
+
const action = provided.has("--help") ? "help" : provided.has("--version") ? "version" : "cleanup";
|
|
158
|
+
return { action, explicitRoot, apply: provided.has("--yes") };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function main(args) {
|
|
162
|
+
let options;
|
|
163
|
+
try {
|
|
164
|
+
options = parseCleanupArgs(args);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(`Cairn cleanup error: ${firstLine(error)}`);
|
|
167
|
+
process.exitCode = 2;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (options.action === "help") {
|
|
171
|
+
console.log(usage);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (options.action === "version") {
|
|
175
|
+
const { version } = JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8"));
|
|
176
|
+
console.log(`cairn ${version}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const repoRoot = resolveRepoRoot({ explicitRoot: options.explicitRoot });
|
|
181
|
+
console.log(JSON.stringify(await cleanupProjectCairn({ root: repoRoot, apply: options.apply }), null, 2));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error(`Cairn cleanup error: ${firstLine(error)}`);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
}
|
|
55
186
|
}
|
|
56
187
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const apply = args.includes("--yes");
|
|
60
|
-
const unsupported = args.filter((value) => value !== "--yes" && value !== "--check");
|
|
61
|
-
if (unsupported.length > 0) throw new Error(`Unknown cleanup option: ${unsupported[0]}`);
|
|
62
|
-
console.log(JSON.stringify(await cleanupProjectCairn({ root: repoRoot, apply }), null, 2));
|
|
188
|
+
function firstLine(error) {
|
|
189
|
+
return String(error instanceof Error ? error.message : error).split(/\r?\n/)[0];
|
|
63
190
|
}
|
|
64
191
|
|
|
65
192
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
66
|
-
await main();
|
|
193
|
+
await main(process.argv.slice(2));
|
|
67
194
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { memoryPromotionReminder } from "./cairn-goal-history.mjs";
|
|
4
|
+
import { resolvePluginRoot, resolveRepoRoot } from "./cairn-paths.mjs";
|
|
5
|
+
|
|
6
|
+
const GOAL_COMMANDS = ["start", "status", "ready", "stats", "task", "replan", "claim", "assign", "receipt", "verify", "finish", "pause", "resume", "block", "cancel", "complete"];
|
|
7
|
+
const GOAL_USAGE = [
|
|
8
|
+
`Usage: cairn goal <${GOAL_COMMANDS.join("|")}> [options] [--root PATH]`,
|
|
9
|
+
"Note: --criteria (completionCriteria) is advisory and is not enforced; completion is gated only by required evidence.",
|
|
10
|
+
].join("\n");
|
|
2
11
|
|
|
3
12
|
export async function runGoalCli(args, {
|
|
4
13
|
stdout = console.log,
|
|
@@ -8,10 +17,31 @@ export async function runGoalCli(args, {
|
|
|
8
17
|
}) {
|
|
9
18
|
const [firstCommand = "help", ...remaining] = args;
|
|
10
19
|
const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
|
|
20
|
+
// Help and version are answered before parsing options or touching state; arguments after -- are passed through.
|
|
21
|
+
const leading = [command, ...rest];
|
|
22
|
+
const separator = leading.indexOf("--");
|
|
23
|
+
const beforeSeparator = separator === -1 ? leading : leading.slice(0, separator);
|
|
24
|
+
const help = command === "help" || beforeSeparator.includes("--help");
|
|
25
|
+
const version = beforeSeparator.includes("--version");
|
|
26
|
+
if (help && version) throw new Error("--help and --version cannot be used together");
|
|
27
|
+
if (help) {
|
|
28
|
+
stdout(GOAL_USAGE);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (version) {
|
|
32
|
+
stdout(`cairn ${JSON.parse(readFileSync(join(resolvePluginRoot(import.meta.url), "package.json"), "utf8")).version}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!GOAL_COMMANDS.includes(command)) throw new Error(unknownCommandMessage(command));
|
|
11
36
|
const { options, positional, passthrough } = parseArgs(rest, command);
|
|
12
37
|
// Without --root, resolve the repository (HARNESS_REPO_ROOT or the nearest .git ancestor), not a subdirectory cwd.
|
|
13
38
|
const root = resolveRepoRoot({ explicitRoot: options.root ?? null });
|
|
14
39
|
const emit = options.quiet ? () => {} : stdout;
|
|
40
|
+
// Terminal notices go to stderr only; stdout and the exit code stay those of the transition.
|
|
41
|
+
const onTerminal = (record, { historyError } = {}) => {
|
|
42
|
+
if (historyError) stderr(`Cairn warning: goal history was not recorded: ${firstLine(historyError)}`);
|
|
43
|
+
if (record?.status === "completed" && record.memoryPromotion === "missing") stderr(memoryPromotionReminder(record.planId));
|
|
44
|
+
};
|
|
15
45
|
if (command === "start") {
|
|
16
46
|
const tasks = parseTasks(options.tasks ?? positional.slice(0));
|
|
17
47
|
const state = await operations.startGoal({
|
|
@@ -82,11 +112,17 @@ export async function runGoalCli(args, {
|
|
|
82
112
|
model: options.model,
|
|
83
113
|
modelSource: options["model-source"],
|
|
84
114
|
designWaiver: options["allow-no-design-model"],
|
|
115
|
+
effectiveModel: options["effective-model"],
|
|
116
|
+
modelHost: options.host,
|
|
85
117
|
});
|
|
86
118
|
const task = state.tasks.find((item) => item.id === String(options.task ?? positional[0]).trim());
|
|
87
119
|
if (task?.route === "heavy" && task.assignedAgentId && (!task.effort || task.effortSource === "not-applied")) {
|
|
88
120
|
stderr(`Cairn warning: Heavy task ${task.id} is assigned without an applied effort (effort ${task.effort ?? "none"}, source ${task.effortSource ?? "none"}); dispatch with the plan's effort and record --effort <level> --effort-source host-option.`);
|
|
89
121
|
}
|
|
122
|
+
// A worker-reported ID that does not contain the passed alias hints at a silent fallback; warn, never block.
|
|
123
|
+
if (task?.model && task.effectiveModel && task.effectiveModel !== "unreported" && !task.effectiveModel.toLowerCase().includes(task.model.toLowerCase())) {
|
|
124
|
+
stderr(`Cairn warning: Task ${task.id} effective model ${task.effectiveModel} does not match the passed model ${task.model}; check the dispatch result.`);
|
|
125
|
+
}
|
|
90
126
|
emit(JSON.stringify(state));
|
|
91
127
|
return state;
|
|
92
128
|
}
|
|
@@ -131,6 +167,7 @@ export async function runGoalCli(args, {
|
|
|
131
167
|
if (command === "finish") {
|
|
132
168
|
const state = await operations.finishGoal({
|
|
133
169
|
root,
|
|
170
|
+
onTerminal,
|
|
134
171
|
watchPaths: splitValues(options.watch),
|
|
135
172
|
argv: passthrough,
|
|
136
173
|
timeoutMs: options["timeout-ms"] === undefined
|
|
@@ -148,6 +185,12 @@ export async function runGoalCli(args, {
|
|
|
148
185
|
return summary;
|
|
149
186
|
}
|
|
150
187
|
if (command === "stats") {
|
|
188
|
+
if (options.limit !== undefined && !options.history) throw new Error("--limit requires --history");
|
|
189
|
+
if (options.history) {
|
|
190
|
+
const history = await operations.goalHistoryStats({ root, limit: options.limit });
|
|
191
|
+
stdout(JSON.stringify(history));
|
|
192
|
+
return history;
|
|
193
|
+
}
|
|
151
194
|
const stats = operations.goalStats(await operations.readGoalState({ root }), root);
|
|
152
195
|
stdout(JSON.stringify(stats));
|
|
153
196
|
return stats;
|
|
@@ -157,18 +200,23 @@ export async function runGoalCli(args, {
|
|
|
157
200
|
root,
|
|
158
201
|
status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
|
|
159
202
|
blocker: options.reason,
|
|
203
|
+
onTerminal,
|
|
160
204
|
});
|
|
161
205
|
emit(JSON.stringify(state));
|
|
162
206
|
return state;
|
|
163
207
|
}
|
|
164
|
-
throw new Error(
|
|
208
|
+
throw new Error(unknownCommandMessage(command));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function unknownCommandMessage(command) {
|
|
212
|
+
return `unknown goal command: ${command} (run cairn goal --help)`;
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
function parseArgs(args, command) {
|
|
168
216
|
const options = {};
|
|
169
217
|
const positional = [];
|
|
170
218
|
const passthrough = [];
|
|
171
|
-
const booleanOptions = new Set(["quiet"]);
|
|
219
|
+
const booleanOptions = new Set(["quiet", "history"]);
|
|
172
220
|
const allowed = allowedOptions(command);
|
|
173
221
|
const seenCanonicalOptions = new Set();
|
|
174
222
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -222,11 +270,11 @@ function allowedOptions(command) {
|
|
|
222
270
|
task: ["task", "status", "reason", "allow-inherited-effort", "allow-no-design-model"],
|
|
223
271
|
replan: ["tasks"],
|
|
224
272
|
claim: ["session"],
|
|
225
|
-
assign: ["task", "agent", "effort", "effort-source", "route", "allow-inherited-effort", "kind", "model", "model-source", "allow-no-design-model"],
|
|
273
|
+
assign: ["task", "agent", "effort", "effort-source", "route", "allow-inherited-effort", "kind", "model", "model-source", "allow-no-design-model", "effective-model", "host"],
|
|
226
274
|
receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
|
|
227
275
|
verify: ["task", "kind", "scope", "watch", "timeout-ms"],
|
|
228
276
|
finish: ["watch", "timeout-ms"],
|
|
229
|
-
stats: [],
|
|
277
|
+
stats: ["history", "limit"],
|
|
230
278
|
ready: [],
|
|
231
279
|
pause: [],
|
|
232
280
|
resume: [],
|
|
@@ -251,6 +299,10 @@ function parseTasks(value) {
|
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
302
|
+
function firstLine(error) {
|
|
303
|
+
return String(error?.message ?? error).split(/\r?\n/)[0];
|
|
304
|
+
}
|
|
305
|
+
|
|
254
306
|
function splitValues(value) {
|
|
255
307
|
if (!value) return [];
|
|
256
308
|
return String(value).split("|").map((item) => item.trim()).filter(Boolean);
|