@kisev/skills-opencode 1.1.0 → 1.2.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/README.md CHANGED
@@ -16,7 +16,7 @@ npx --yes skills add kisev/skills --agent opencode --skill '*' --copy --yes
16
16
 
17
17
  Для одного skill укажите `--skill <name>`. Для воспроизводимой установки можно
18
18
  передать URL GitHub tag, например
19
- `https://github.com/kisev/skills/tree/v1.1.0`. Package никогда не устанавливает
19
+ `https://github.com/kisev/skills/tree/v1.1.1`. Package никогда не устанавливает
20
20
  и не обновляет skills. Если команда не нашла skill, она сообщает точную команду
21
21
  `npx skills add` для его установки.
22
22
 
@@ -25,16 +25,18 @@ npx --yes skills add kisev/skills --agent opencode --skill '*' --copy --yes
25
25
  Установите npm package там, где OpenCode сможет разрешить plugin:
26
26
 
27
27
  ```shell
28
- npm install @kisev/skills-opencode@1.1.0
28
+ npm install @kisev/skills-opencode@1.1.1
29
29
  ```
30
30
 
31
- Сначала покажите план installer. Эта команда не создаёт files:
31
+ Сначала покажите план installer. По умолчанию CLI выводит короткую сводку:
32
+ счётчики по группам, только изменяемые paths, conflicts, restart flag, digest и
33
+ готовую confirm-команду. Эта команда не меняет deployment:
32
34
 
33
35
  ```shell
34
36
  npm exec -- skills-opencode install --scope global --dry-run
35
37
  ```
36
38
 
37
- Проверьте exact operations и примените только показанный digest:
39
+ Проверьте сводку и примените только показанный digest:
38
40
 
39
41
  ```shell
40
42
  npm exec -- skills-opencode install --scope global --confirm <digest>
@@ -54,6 +56,14 @@ Project assets находятся в `.opencode/agents`, `.opencode/commands` и
54
56
  перезаписывает неизвестные или изменённые files и сохраняет ownership manifests
55
57
  только после confirmed apply.
56
58
 
59
+ Для automation добавьте `--json`. Этот режим сохраняет полный стабильный
60
+ machine-readable plan, включая `operations` и `requires_restart`:
61
+
62
+ ```shell
63
+ npm exec -- skills-opencode install --scope global --dry-run --json
64
+ npm exec -- skills-opencode agent list --scope global --json
65
+ ```
66
+
57
67
  Добавьте plugin в `opencode.json` вручную:
58
68
 
59
69
  ```json
@@ -81,10 +91,14 @@ npm exec -- skills-opencode agent model-set worker --scope global \
81
91
  npm exec -- skills-opencode agent reconcile --scope global --dry-run
82
92
  ```
83
93
 
84
- `agent configure` предлагает terminal selection в порядке provider, model,
85
- variant по cached output `opencode models`; refresh не выполняется. Если catalog
86
- недоступен, передайте exact `--provider <provider> --model <model>` или
87
- `--model <provider/model>`.
94
+ `agent configure` предлагает настоящий terminal wizard: стрелками выбираются
95
+ agent из inventory, затем provider, только его models и, если metadata выбранной
96
+ модели публикует variants, variant. Текущие model/variant и target показываются
97
+ перед выбором; доступны `keep`, `change`, `clear variant`, `back` и `cancel`.
98
+ Wizard не вызывает LLM, OpenCode Question или refresh catalog. Если catalog
99
+ недоступен, он завершается без записи и печатает инструкцию для exact
100
+ `--model <provider/model>` с optional `--variant`. Для модели без variants
101
+ дополнительный selector не показывается.
88
102
 
89
103
  Additional critic имеет имя `critic-<safe-suffix>`. Стандартный `critic` и fixed
90
104
  roles нельзя удалить или переименовать:
@@ -95,11 +109,12 @@ npm exec -- skills-opencode critic add security --scope global \
95
109
  npm exec -- skills-opencode critic remove security --scope global --dry-run
96
110
  ```
97
111
 
98
- Для любой mutation замените `--dry-run` на `--confirm <digest>` и повторите те же
99
- аргументы. Plan содержит TLDR operations без полного diff. Digest связан с
100
- одноразовым private receipt, действует 10 минут и повторно не применяется.
101
- Успешный machine-readable result содержит `requires_restart`; после `true`
102
- полностью перезапустите OpenCode.
112
+ Для любой mutation используйте готовую confirm-команду из preview либо замените
113
+ `--dry-run` на `--confirm <digest>` и повторите те же аргументы. Человекочитаемый
114
+ plan не печатает полный JSON и сворачивает длинные группы paths. Digest связан с
115
+ одноразовым private receipt, действует 10 минут и повторно не применяется. Для
116
+ machine-readable result добавьте `--json`; поле `requires_restart` сообщает о
117
+ необходимости полностью перезапустить OpenCode.
103
118
 
104
119
  В global scope profile configuration хранится в
105
120
  `~/.config/opencode/.skills-opencode/agent-profiles.json`, а semantic deployment
@@ -719,10 +719,12 @@ export async function availableModelVariants(model) {
719
719
  document += `${lines[index]}\n`;
720
720
  try {
721
721
  const metadata = JSON.parse(document);
722
+ if (metadata.variants === undefined)
723
+ return [];
722
724
  if (!metadata.variants ||
723
725
  typeof metadata.variants !== "object" ||
724
726
  Array.isArray(metadata.variants))
725
- throw new Error("variants missing");
727
+ throw new Error("invalid variants metadata");
726
728
  const variants = Object.keys(metadata.variants);
727
729
  if (!variants.every((variant) => VARIANT_PATTERN.test(variant)))
728
730
  throw new Error("unsafe variant");
@@ -0,0 +1,11 @@
1
+ import type { AgentInventory, AgentProfilePlan } from "./agent-profiles.js";
2
+ import type { Plan as InstallerPlan } from "./installer.js";
3
+ type DisplayPlan = InstallerPlan | AgentProfilePlan;
4
+ export declare function terminalSafe(value: string): string;
5
+ export declare function renderPlan(plan: DisplayPlan, options: {
6
+ applied: boolean;
7
+ confirmationCommand?: string;
8
+ }): string;
9
+ export declare function renderInventory(inventory: AgentInventory): string;
10
+ export declare function shellCommand(arguments_: readonly string[]): string;
11
+ export {};
@@ -0,0 +1,149 @@
1
+ const GROUPS = ["Agents", "Commands", "Plugins", "State"];
2
+ const OPERATIONS = ["create", "update", "remove", "conflict", "missing", "unchanged"];
3
+ const UNSAFE_TERMINAL_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
4
+ export function terminalSafe(value) {
5
+ return [...value]
6
+ .map((character) => {
7
+ const code = character.codePointAt(0);
8
+ const unsafe = character === "\\" || UNSAFE_TERMINAL_CHARACTER.test(character);
9
+ if (!unsafe)
10
+ return character;
11
+ if (character === "\\")
12
+ return "\\\\";
13
+ if (character === "\n")
14
+ return "\\n";
15
+ if (character === "\r")
16
+ return "\\r";
17
+ if (character === "\t")
18
+ return "\\t";
19
+ if (code <= 0xff)
20
+ return `\\x${code.toString(16).padStart(2, "0")}`;
21
+ if (code <= 0xffff)
22
+ return `\\u${code.toString(16).padStart(4, "0")}`;
23
+ return `\\u{${code.toString(16)}}`;
24
+ })
25
+ .join("");
26
+ }
27
+ function groupFor(path) {
28
+ if (path.startsWith("agents/"))
29
+ return "Agents";
30
+ if (path.startsWith("commands/"))
31
+ return "Commands";
32
+ if (path.startsWith("plugins/"))
33
+ return "Plugins";
34
+ return "State";
35
+ }
36
+ function actionName(action) {
37
+ return {
38
+ install: "Install",
39
+ uninstall: "Uninstall",
40
+ "model-set": "Configure agent model",
41
+ "critic-add": "Add critic",
42
+ "critic-remove": "Remove critic",
43
+ reconcile: "Reconcile agents",
44
+ }[action];
45
+ }
46
+ function operationSummary(operations) {
47
+ return OPERATIONS.map((operation) => {
48
+ const count = operations.filter((item) => item.operation === operation).length;
49
+ return count ? `${operation} ${count}` : undefined;
50
+ })
51
+ .filter(Boolean)
52
+ .join(", ");
53
+ }
54
+ function shortPath(path, group) {
55
+ if (group === "State")
56
+ return terminalSafe(path);
57
+ const value = path.slice(path.indexOf("/") + 1);
58
+ return terminalSafe(value.endsWith(".md") || value.endsWith(".js") ? value.slice(0, -3) : value);
59
+ }
60
+ function detailLines(operations) {
61
+ const lines = [];
62
+ for (const group of GROUPS) {
63
+ const grouped = operations.filter((item) => groupFor(item.path) === group && item.operation !== "unchanged");
64
+ for (const operation of OPERATIONS.filter((value) => value !== "unchanged" && grouped.some((item) => item.operation === value))) {
65
+ const values = grouped
66
+ .filter((item) => item.operation === operation)
67
+ .map((item) => operation === "conflict"
68
+ ? `${shortPath(item.path, group)} (${terminalSafe(item.reason ?? "conflict")})`
69
+ : shortPath(item.path, group));
70
+ const visible = operation === "conflict" ? values : values.slice(0, 8);
71
+ const rest = values.length - visible.length;
72
+ lines.push(` ${group}/${operation}: ${visible.join(", ")}${rest ? ` (+${rest} more)` : ""}`);
73
+ }
74
+ }
75
+ return lines;
76
+ }
77
+ function migrationSummary(operations) {
78
+ const count = operations.filter((item) => item.reason === "v1.0.0 ownership transfer").length;
79
+ return count
80
+ ? ` Ownership migration: ${count} agent${count === 1 ? "" : "s"} from v1.0.0`
81
+ : undefined;
82
+ }
83
+ export function renderPlan(plan, options) {
84
+ const version = "package_version" in plan ? ` @kisev/skills-opencode ${plan.package_version}` : "";
85
+ const lines = [
86
+ `${actionName(plan.action)}${version} (${plan.scope})`,
87
+ `Target: ${terminalSafe(plan.root)}`,
88
+ "",
89
+ options.applied ? "Applied changes:" : "Planned changes:",
90
+ ];
91
+ for (const group of GROUPS) {
92
+ const operations = plan.operations.filter((item) => groupFor(item.path) === group);
93
+ if (operations.length)
94
+ lines.push(` ${group}: ${operationSummary(operations)}`);
95
+ }
96
+ const migration = migrationSummary(plan.operations);
97
+ if (migration)
98
+ lines.push(migration);
99
+ const details = detailLines(plan.operations);
100
+ if (details.length)
101
+ lines.push("", "Details:", ...details);
102
+ const conflicts = plan.operations.filter((item) => item.operation === "conflict").length;
103
+ lines.push("", `Conflicts: ${conflicts || "none"}`);
104
+ lines.push(options.applied
105
+ ? `Restart required: ${plan.requires_restart ? "yes" : "no"}`
106
+ : `Restart after apply: ${plan.requires_restart ? "yes" : "no"}`);
107
+ if (!options.applied) {
108
+ if (plan.receipt_expires_at)
109
+ lines.push(`Confirmation expires: ${plan.receipt_expires_at}`);
110
+ lines.push(`Digest: ${plan.digest}`);
111
+ if (options.confirmationCommand)
112
+ lines.push("", "Apply:", ` ${options.confirmationCommand}`);
113
+ }
114
+ return `${lines.join("\n")}\n`;
115
+ }
116
+ function table(rows) {
117
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0)));
118
+ return rows.map((row) => row
119
+ .map((value, column) => value.padEnd(widths[column]))
120
+ .join(" ")
121
+ .trimEnd());
122
+ }
123
+ export function renderInventory(inventory) {
124
+ const rows = [
125
+ ["NAME", "MODEL", "VARIANT", "OWNER", "STATE"],
126
+ ...inventory.profiles.map((profile) => [
127
+ terminalSafe(profile.name),
128
+ terminalSafe(profile.model ?? "default"),
129
+ terminalSafe(profile.variant ?? "-"),
130
+ profile.ownership,
131
+ profile.state,
132
+ ]),
133
+ ];
134
+ return `${[
135
+ `OpenCode agents (${inventory.scope})`,
136
+ `Target: ${terminalSafe(inventory.root)}`,
137
+ "",
138
+ ...table(rows),
139
+ "",
140
+ `Critic pool: ${inventory.critic_pool.map(terminalSafe).join(", ")}`,
141
+ `Collisions: ${inventory.collisions.length ? inventory.collisions.map(terminalSafe).join(", ") : "none"}`,
142
+ `Drift: ${inventory.drift.length ? inventory.drift.map(terminalSafe).join(", ") : "none"}`,
143
+ ].join("\n")}\n`;
144
+ }
145
+ export function shellCommand(arguments_) {
146
+ return ["npm", "exec", "--", "skills-opencode", ...arguments_]
147
+ .map((value) => /^[A-Za-z0-9_./:@=-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`)
148
+ .join(" ");
149
+ }
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { createInterface } from "node:readline/promises";
3
2
  import { AgentProfileError, applyAgentProfileChange, availableModels, availableModelVariants, FIXED_AGENT_ROLES, listAgentProfiles, previewAgentProfileChange, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
4
- import { apply, InstallerError, preview, result } from "./installer.js";
3
+ import { renderInventory, renderPlan, shellCommand, terminalSafe } from "./cli-output.js";
4
+ import { apply, InstallerError, preview } from "./installer.js";
5
5
  import { LifecycleError } from "./lifecycle.js";
6
+ import { promptText, selectOption } from "./terminal-wizard.js";
6
7
  function parseOptions(values) {
7
- const options = { dryRun: false };
8
+ const options = { dryRun: false, json: false };
8
9
  for (let index = 0; index < values.length; index += 1) {
9
10
  const value = values[index];
10
11
  if (!value.startsWith("--")) {
@@ -52,6 +53,11 @@ function parseOptions(values) {
52
53
  throw new InstallerError("invalid_input", "Use only one variant option");
53
54
  options.variant = null;
54
55
  }
56
+ else if (value === "--json") {
57
+ if (options.json)
58
+ throw new InstallerError("invalid_input", "--json may be supplied once");
59
+ options.json = true;
60
+ }
55
61
  else {
56
62
  throw new InstallerError("invalid_input", `Unknown argument: ${value}`);
57
63
  }
@@ -77,68 +83,156 @@ function exactModel(options) {
77
83
  throw new InstallerError("invalid_input", "--provider is required when --model is not provider/model");
78
84
  return validateModel(`${options.provider}/${options.model}`);
79
85
  }
80
- async function choose(label, values, input) {
81
- process.stderr.write(`${label}:\n${values.map((value, index) => ` ${index + 1}. ${value}`).join("\n")}\n`);
82
- const answer = await input.question("> ");
83
- const index = Number(answer) - 1;
84
- if (!Number.isSafeInteger(index) || !values[index])
85
- throw new InstallerError("invalid_input", `Invalid ${label.toLowerCase()} selection`);
86
- return values[index];
87
- }
88
- async function interactiveSelection(options) {
89
- if (!process.stdin.isTTY || !process.stderr.isTTY)
86
+ async function interactiveSelection(options, action = "model-set") {
87
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
90
88
  throw new InstallerError("terminal_required", "agent configure requires a terminal or explicit --provider and --model");
91
- const input = createInterface({ input: process.stdin, output: process.stderr });
92
- try {
93
- const inventory = await listAgentProfiles(options.scope);
94
- const configurable = inventory.profiles.filter((item) => item.ownership !== "user-owned").map((item) => item.name);
95
- const name = options.name ? validateAgentName(options.name) : await choose("Agent", configurable.length ? configurable : FIXED_AGENT_ROLES, input);
96
- if (options.provider && options.model)
97
- return { name, model: exactModel(options), ...(validateVariant(options.variant) ? { variant: validateVariant(options.variant) } : {}) };
98
- let models;
99
- try {
100
- models = await availableModels();
101
- }
102
- catch (error) {
103
- if (!(error instanceof AgentProfileError) || error.code !== "catalog_unavailable")
89
+ }
90
+ const inventory = await listAgentProfiles(options.scope);
91
+ const configurable = inventory.profiles.filter((item) => item.ownership !== "user-owned");
92
+ let name;
93
+ if (options.name) {
94
+ name = action === "critic-add" && !options.name.startsWith("critic-")
95
+ ? validateAgentName(`critic-${options.name}`)
96
+ : validateAgentName(options.name);
97
+ }
98
+ else if (action === "critic-add") {
99
+ const raw = await promptText("Critic name (critic-<suffix>):");
100
+ if (raw === null)
101
+ throw new InstallerError("cancelled", "Wizard cancelled");
102
+ name = raw.startsWith("critic-") ? raw : `critic-${raw}`;
103
+ validateAgentName(name);
104
+ }
105
+ else {
106
+ const agentNames = configurable.map((item) => item.name);
107
+ if (agentNames.length === 0)
108
+ agentNames.push(...FIXED_AGENT_ROLES);
109
+ const index = await selectOption("Select agent:", agentNames);
110
+ if (index === null)
111
+ throw new InstallerError("cancelled", "Wizard cancelled");
112
+ name = agentNames[index];
113
+ }
114
+ const currentProfile = configurable.find((item) => item.name === name);
115
+ const currentModel = currentProfile?.model;
116
+ const currentVariant = currentProfile?.variant;
117
+ const showTarget = (model, variant) => {
118
+ process.stderr.write(`Target: ${model}${variant ? ` / ${variant}` : ""}\n`);
119
+ };
120
+ if (options.provider && options.model) {
121
+ const model = exactModel(options);
122
+ const variant = validateVariant(options.variant);
123
+ return { name, model, ...(variant ? { variant } : {}) };
124
+ }
125
+ while (true) {
126
+ const actions = [];
127
+ if (currentModel) {
128
+ actions.push(`Keep current (${currentModel}${currentVariant ? ` / ${currentVariant}` : ""})`);
129
+ }
130
+ actions.push("Change model");
131
+ if (currentVariant)
132
+ actions.push("Clear variant");
133
+ if (!options.name && action === "model-set")
134
+ actions.push("Back");
135
+ actions.push("Cancel");
136
+ const currentLabel = `Agent: ${name}${currentModel ? ` (current: ${currentModel}${currentVariant ? ` / ${currentVariant}` : ""})` : ""}`;
137
+ const actionIndex = await selectOption(currentLabel, actions);
138
+ if (actionIndex === null)
139
+ throw new InstallerError("cancelled", "Wizard cancelled");
140
+ const chosen = actions[actionIndex];
141
+ if (chosen.startsWith("Keep current")) {
142
+ if (!currentModel)
143
+ throw new InstallerError("invalid_state", "No current model to keep");
144
+ showTarget(currentModel, currentVariant);
145
+ return { name, model: currentModel, ...(currentVariant ? { variant: currentVariant } : {}) };
146
+ }
147
+ if (chosen === "Change model") {
148
+ let models;
149
+ try {
150
+ models = await availableModels();
151
+ }
152
+ catch (error) {
153
+ if (error instanceof AgentProfileError && error.code === "catalog_unavailable") {
154
+ process.stderr.write("\nModel catalog is unavailable.\nUse direct CLI with exact --model provider/model and optional --variant.\n\n");
155
+ throw new InstallerError("catalog_unavailable", "Use direct CLI with exact --model provider/model");
156
+ }
104
157
  throw error;
105
- const provider = options.provider ?? (await input.question("Provider: ")).trim();
106
- const model = options.model ?? (await input.question("Model: ")).trim();
107
- const variant = options.variant === undefined ? (await input.question("Variant (optional): ")).trim() : options.variant;
108
- return { name, model: validateModel(model.includes("/") ? model : `${provider}/${model}`), ...(validateVariant(variant) ? { variant: validateVariant(variant) } : {}) };
109
- }
110
- const providers = [...new Set(models.map((model) => model.split("/", 1)[0]))].sort();
111
- const provider = options.provider ?? (await choose("Provider", providers, input));
112
- const selectedModel = options.model
113
- ? exactModel({ ...options, provider })
114
- : await choose("Model", models.filter((model) => model.startsWith(`${provider}/`)), input);
115
- let variants = [];
116
- try {
117
- variants = await availableModelVariants(selectedModel);
118
- }
119
- catch (error) {
120
- if (!(error instanceof AgentProfileError) || error.code !== "catalog_unavailable")
158
+ }
159
+ const providers = [...new Set(models.map((m) => m.split("/", 1)[0]))].sort();
160
+ const providerIndex = await selectOption("Select provider:", providers);
161
+ if (providerIndex === null)
162
+ continue;
163
+ const provider = providers[providerIndex];
164
+ const filteredModels = models.filter((m) => m.startsWith(`${provider}/`));
165
+ const modelIndex = await selectOption("Select model:", filteredModels);
166
+ if (modelIndex === null)
167
+ continue;
168
+ const selectedModel = filteredModels[modelIndex];
169
+ let selectedVariant;
170
+ try {
171
+ const variants = await availableModelVariants(selectedModel);
172
+ if (variants.length) {
173
+ const variantOptions = ["(none)", ...variants];
174
+ const variantIndex = await selectOption("Select variant:", variantOptions);
175
+ if (variantIndex === null)
176
+ continue;
177
+ if (variantIndex > 0)
178
+ selectedVariant = variantOptions[variantIndex];
179
+ }
180
+ }
181
+ catch (error) {
182
+ if (error instanceof AgentProfileError && error.code === "catalog_unavailable") {
183
+ process.stderr.write("\nModel variant metadata is unavailable.\nUse direct CLI with exact --model provider/model and optional --variant.\n\n");
184
+ throw new InstallerError("catalog_unavailable", "Use direct CLI with exact --model provider/model");
185
+ }
121
186
  throw error;
187
+ }
188
+ showTarget(selectedModel, selectedVariant);
189
+ return { name, model: selectedModel, ...(selectedVariant ? { variant: selectedVariant } : {}) };
190
+ }
191
+ if (chosen === "Clear variant") {
192
+ if (!currentModel)
193
+ throw new InstallerError("invalid_state", "No model to clear variant for");
194
+ showTarget(currentModel);
195
+ return { name, model: currentModel };
196
+ }
197
+ if (chosen === "Back") {
198
+ return interactiveSelection({ ...options, name: undefined }, action);
199
+ }
200
+ if (chosen === "Cancel") {
201
+ throw new InstallerError("cancelled", "Wizard cancelled");
122
202
  }
123
- const variant = options.variant === null
124
- ? undefined
125
- : options.variant ?? (variants.length ? await choose("Variant", ["none", ...variants.filter((value) => value !== "none")], input) : (await input.question("Variant (optional): ")).trim());
126
- return { name, model: selectedModel, ...(variant && variant !== "none" ? { variant: validateVariant(variant) } : {}) };
127
- }
128
- finally {
129
- input.close();
130
203
  }
131
204
  }
132
205
  async function runProfile(request, options) {
133
206
  requireConfirmationMode(options);
134
207
  if (options.dryRun) {
135
208
  const plan = await previewAgentProfileChange(request, options.scope);
136
- process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, requires_restart: false, plan }, null, 2)}\n`);
209
+ if (options.json)
210
+ process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, requires_restart: false, plan }, null, 2)}\n`);
211
+ else
212
+ process.stdout.write(renderPlan(plan, { applied: false, confirmationCommand: shellCommand(profileConfirmationArguments(request, options.scope, plan.digest)) }));
137
213
  }
138
214
  else {
139
- process.stdout.write(`${JSON.stringify(await applyAgentProfileChange(request, options.scope, options.confirm), null, 2)}\n`);
215
+ const applied = await applyAgentProfileChange(request, options.scope, options.confirm);
216
+ if (options.json)
217
+ process.stdout.write(`${JSON.stringify(applied, null, 2)}\n`);
218
+ else
219
+ process.stdout.write(renderPlan(applied.plan, { applied: true }));
140
220
  }
141
221
  }
222
+ function profileConfirmationArguments(request, scope, digest) {
223
+ if (request.action === "reconcile")
224
+ return ["agent", "reconcile", "--scope", scope, "--confirm", digest];
225
+ if (request.action === "critic-remove")
226
+ return ["critic", "remove", request.name, "--scope", scope, "--confirm", digest];
227
+ const command = request.action === "critic-add" ? ["critic", "add", request.name] : ["agent", "model-set", request.name];
228
+ command.push("--scope", scope, "--model", request.model);
229
+ if (request.variant)
230
+ command.push("--variant", request.variant);
231
+ else if (request.action === "model-set")
232
+ command.push("--clear-variant");
233
+ command.push("--confirm", digest);
234
+ return command;
235
+ }
142
236
  async function run(arguments_) {
143
237
  const [domain, operation, ...rest] = arguments_;
144
238
  if (domain === "install" || domain === "uninstall") {
@@ -151,14 +245,31 @@ async function run(arguments_) {
151
245
  throw new InstallerError("invalid_input", "Installer accepts only scope and confirmation options");
152
246
  requireConfirmationMode(options);
153
247
  const action = domain;
154
- process.stdout.write(`${result(options.dryRun ? await preview(action, options.scope) : await apply(action, options.scope, options.confirm), !options.dryRun)}\n`);
248
+ if (options.dryRun) {
249
+ const plan = await preview(action, options.scope);
250
+ if (options.json)
251
+ process.stdout.write(`${JSON.stringify({ status: "ok", applied: false, requires_restart: false, plan }, null, 2)}\n`);
252
+ else
253
+ process.stdout.write(renderPlan(plan, { applied: false, confirmationCommand: shellCommand([action, "--scope", options.scope, "--confirm", plan.digest]) }));
254
+ }
255
+ else {
256
+ const plan = await apply(action, options.scope, options.confirm);
257
+ if (options.json)
258
+ process.stdout.write(`${JSON.stringify({ status: "ok", applied: true, requires_restart: plan.requires_restart, plan }, null, 2)}\n`);
259
+ else
260
+ process.stdout.write(renderPlan(plan, { applied: true }));
261
+ }
155
262
  return;
156
263
  }
157
264
  if (domain === "agent" && operation === "list") {
158
265
  const options = parseOptions(rest);
159
266
  if (options.dryRun || options.confirm || options.name || options.provider || options.model || options.variant !== undefined)
160
267
  throw new InstallerError("invalid_input", "agent list accepts only --scope");
161
- process.stdout.write(`${JSON.stringify({ status: "ok", inventory: await listAgentProfiles(options.scope) }, null, 2)}\n`);
268
+ const inventory = await listAgentProfiles(options.scope);
269
+ if (options.json)
270
+ process.stdout.write(`${JSON.stringify({ status: "ok", inventory }, null, 2)}\n`);
271
+ else
272
+ process.stdout.write(renderInventory(inventory));
162
273
  return;
163
274
  }
164
275
  if (domain === "agent" && operation === "configure") {
@@ -166,7 +277,7 @@ async function run(arguments_) {
166
277
  requireConfirmationMode(options);
167
278
  const selected = options.provider && options.model && options.name
168
279
  ? { name: validateAgentName(options.name), model: exactModel(options), ...(validateVariant(options.variant) ? { variant: validateVariant(options.variant) } : {}) }
169
- : await interactiveSelection(options);
280
+ : await interactiveSelection(options, "model-set");
170
281
  await runProfile({ action: "model-set", ...selected, variant: selected.variant ?? null }, options);
171
282
  return;
172
283
  }
@@ -193,9 +304,13 @@ async function run(arguments_) {
193
304
  : `critic-${options.name ?? ""}`;
194
305
  if (operation === "add") {
195
306
  const model = exactModel(options);
196
- if (!model)
197
- throw new InstallerError("invalid_input", "critic add requires --provider and --model, or exact --model provider/model");
198
- await runProfile({ action: "critic-add", name, model, variant: options.variant ?? null }, options);
307
+ if (!model) {
308
+ const selected = await interactiveSelection(options, "critic-add");
309
+ await runProfile({ action: "critic-add", name: selected.name, model: selected.model, variant: selected.variant ?? null }, options);
310
+ }
311
+ else {
312
+ await runProfile({ action: "critic-add", name, model, variant: options.variant ?? null }, options);
313
+ }
199
314
  }
200
315
  else {
201
316
  if (options.provider || options.model || options.variant !== undefined)
@@ -212,7 +327,10 @@ async function main() {
212
327
  }
213
328
  catch (error) {
214
329
  const known = error instanceof LifecycleError ? error : new InstallerError("internal_error", error instanceof Error ? error.message : String(error));
215
- process.stdout.write(`${JSON.stringify({ status: "error", error: { code: known.code, message: known.message } })}\n`);
330
+ if (process.argv.includes("--json"))
331
+ process.stdout.write(`${JSON.stringify({ status: "error", error: { code: known.code, message: known.message } })}\n`);
332
+ else
333
+ process.stderr.write(`Error [${known.code}]: ${terminalSafe(known.message)}\n`);
216
334
  process.exitCode = 2;
217
335
  }
218
336
  }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,8 @@ import rtk, { type RtkOptions } from "./plugins/rtk.js";
7
7
  import zedBell, { type ZedBellOptions } from "./plugins/zed-bell.js";
8
8
  import zedClickablePaths, { type ZedClickablePathsOptions } from "./plugins/zed-clickable-paths.js";
9
9
  export { COMMAND_REGISTRY, renderCommand } from "./registry.js";
10
- export { CATEGORIES, resolveRouting, RoutingGate } from "./routing.js";
10
+ export { CATEGORIES, resolveRouting, RoutingGate, ExecutionCardLifecycle, validateExecutionCard } from "./routing.js";
11
+ export type { ExecutionCard, ExecutionCardStatus } from "./routing.js";
11
12
  export { AgentProfileError, FIXED_AGENT_ROLES, applyAgentProfileChange, availableModels, availableModelVariants, listAgentProfiles, previewAgentProfileChange, renderAgentProfile, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
12
13
  export type { AgentInventory, AgentModelSelection, AgentOwnership, AgentProfileAction, AgentProfileConfig, AgentProfileOperation, AgentProfilePlan, AgentProfileRecord, AgentProfileRequest, AgentProfileResult, AgentProfileScope, AgentState, DeploymentManifest, DeploymentRecord, FixedAgentRole, } from "./agent-profiles.js";
13
14
  export { backgroundAttempts, goalLoop, scheduler, autonomyPolicy, rulesInjector, rtk, zedBell, zedClickablePaths };
@@ -48,6 +49,7 @@ declare const plugin: (input: {
48
49
  capabilities: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
49
50
  tools: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
50
51
  }, import("zod/v4/core").$strip>>;
52
+ execution_card: import("zod").ZodOptional<import("zod").ZodAny>;
51
53
  override: import("zod").ZodOptional<import("zod").ZodString>;
52
54
  budget: import("zod").ZodOptional<import("zod").ZodObject<{
53
55
  cost_class: import("zod").ZodOptional<import("zod").ZodString>;
@@ -66,6 +68,7 @@ declare const plugin: (input: {
66
68
  capabilities?: string[] | undefined;
67
69
  tools?: string[] | undefined;
68
70
  }[];
71
+ execution_card?: any;
69
72
  override?: string | undefined;
70
73
  budget?: {
71
74
  cost_class?: string | undefined;
@@ -151,6 +154,7 @@ export declare const server: (input: {
151
154
  capabilities: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
152
155
  tools: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
153
156
  }, import("zod/v4/core").$strip>>;
157
+ execution_card: import("zod").ZodOptional<import("zod").ZodAny>;
154
158
  override: import("zod").ZodOptional<import("zod").ZodString>;
155
159
  budget: import("zod").ZodOptional<import("zod").ZodObject<{
156
160
  cost_class: import("zod").ZodOptional<import("zod").ZodString>;
@@ -169,6 +173,7 @@ export declare const server: (input: {
169
173
  capabilities?: string[] | undefined;
170
174
  tools?: string[] | undefined;
171
175
  }[];
176
+ execution_card?: any;
172
177
  override?: string | undefined;
173
178
  budget?: {
174
179
  cost_class?: string | undefined;
package/dist/index.js CHANGED
@@ -10,14 +10,14 @@ import zedBell from "./plugins/zed-bell.js";
10
10
  import zedClickablePaths from "./plugins/zed-clickable-paths.js";
11
11
  import { applyAgentProfileChange, listAgentProfiles, previewAgentProfileChange, } from "./agent-profiles.js";
12
12
  export { COMMAND_REGISTRY, renderCommand } from "./registry.js";
13
- export { CATEGORIES, resolveRouting, RoutingGate } from "./routing.js";
13
+ export { CATEGORIES, resolveRouting, RoutingGate, ExecutionCardLifecycle, validateExecutionCard } from "./routing.js";
14
14
  export { AgentProfileError, FIXED_AGENT_ROLES, applyAgentProfileChange, availableModels, availableModelVariants, listAgentProfiles, previewAgentProfileChange, renderAgentProfile, validateAgentName, validateModel, validateVariant, } from "./agent-profiles.js";
15
15
  export { backgroundAttempts, goalLoop, scheduler, autonomyPolicy, rulesInjector, rtk, zedBell, zedClickablePaths };
16
16
  const CATALOG = {
17
17
  skills: ["attempt", "goal", "schedule", "multi-run", "usage", "overview", "lsp-report"],
18
18
  plugins: ["background-attempts", "goal-loop", "schedule", "autonomy-policy", "rules-injector", "rtk", "zed-bell", "zed-clickable-paths"],
19
19
  replacements: ["capabilities", "route", "doctor", "agent_profiles"],
20
- version: "1.1.0",
20
+ version: "1.1.1",
21
21
  };
22
22
  const plugin = (async (input) => {
23
23
  const gate = new RoutingGate();
@@ -29,16 +29,17 @@ const plugin = (async (input) => {
29
29
  task: tool.schema.string(),
30
30
  requirements: tool.schema.array(tool.schema.string()).default([]),
31
31
  agents: tool.schema.array(tool.schema.object({ agent: tool.schema.string(), available: tool.schema.boolean().optional(), capabilities: tool.schema.array(tool.schema.string()).optional(), tools: tool.schema.array(tool.schema.string()).optional() })),
32
+ execution_card: tool.schema.any().optional(),
32
33
  override: tool.schema.string().optional(),
33
34
  budget: tool.schema.object({ cost_class: tool.schema.string().optional(), latency_class: tool.schema.string().optional() }).optional(),
34
35
  decision: tool.schema.any().optional()
35
36
  },
36
37
  async execute(args, context) {
37
- const input = { category: args.category, requirements: args.requirements, agents: args.agents, override: args.override, budget: args.budget };
38
+ const input = { category: args.category, task: args.task, requirements: args.requirements, agents: args.agents, execution_card: args.execution_card, override: args.override, budget: args.budget };
38
39
  if (args.action === "preview")
39
40
  return JSON.stringify(gate.preview(input));
40
41
  const decision = gate.dispatch(input, args.decision);
41
- gate.grant(context.sessionID, decision);
42
+ gate.grant(context.sessionID, decision, { task: args.task, requirements: args.requirements, card: args.execution_card });
42
43
  return JSON.stringify({ decision, status: "routed" });
43
44
  }
44
45
  });
@@ -92,7 +93,14 @@ const plugin = (async (input) => {
92
93
  const agent = typeof args.agent === "string" ? args.agent : typeof args.subagent_type === "string" ? args.subagent_type : undefined;
93
94
  if (!agent)
94
95
  throw new Error("Native Task requires an explicit agent and an active routing receipt");
95
- gate.consume(input.sessionID, agent);
96
+ const hasBinding = "task" in args || "requirements" in args || "execution_card" in args;
97
+ if (!hasBinding)
98
+ gate.consume(input.sessionID, agent);
99
+ else {
100
+ const task = typeof args.task === "string" ? args.task : "";
101
+ const requirements = Array.isArray(args.requirements) ? args.requirements : [];
102
+ gate.consume(input.sessionID, agent, { task, requirements, card: args.execution_card });
103
+ }
96
104
  }
97
105
  };
98
106
  });
package/dist/installer.js CHANGED
@@ -179,7 +179,7 @@ async function build(action, scope, cwd = process.cwd(), home = homedir()) {
179
179
  mutations.push({ path: MANIFEST_NAME, operation: "remove", expected: { sha256: sha256(owned.raw) } });
180
180
  }
181
181
  const sorted = operations.sort((left, right) => left.path.localeCompare(right.path) || left.operation.localeCompare(right.operation));
182
- const base = { schema_version: 1, action, scope, root, package_version: packageVersion(), operations: sorted, requires_restart: profiles.plan.requires_restart || sorted.some((item) => item.path.startsWith("commands/") || item.path.startsWith("plugins/")) };
182
+ const base = { schema_version: 1, action, scope, root, package_version: packageVersion(), operations: sorted, requires_restart: (action === "install" && owned.manifest?.version !== packageVersion()) || profiles.plan.requires_restart || mutations.some((item) => item.path.startsWith("agents/") || item.path.startsWith("commands/") || item.path.startsWith("plugins/")) };
183
183
  return {
184
184
  plan: { ...base, digest: digest(base) },
185
185
  mutations,
@@ -22,12 +22,23 @@ export async function backgroundAttempts({ client, directory, cwd }, options = {
22
22
  await appendState(join(root, "receipts.jsonl"), root, { attempt_id: item.attempt_id, event, status: item.status, revision: item.revision, at: new Date().toISOString() });
23
23
  };
24
24
  const transition = async (item, status, event) => {
25
- if (!TERMINAL.has(item.status)) {
26
- item.status = status;
27
- item.revision += 1;
28
- }
25
+ const allowed = {
26
+ queued: ["running", "failed", "cancelled"],
27
+ running: ["waiting", "completed", "failed", "cancelled", "orphaned"],
28
+ waiting: ["running", "completed", "failed", "cancelled", "orphaned"],
29
+ completed: [], failed: [], cancelled: [], orphaned: [],
30
+ };
31
+ if (!allowed[item.status].includes(status))
32
+ throw new Error(`Invalid background-attempt transition: ${item.status} -> ${status}`);
33
+ item.status = status;
34
+ item.revision += 1;
29
35
  await save(item, event);
30
36
  };
37
+ for (const item of await all()) {
38
+ if (item.project === project && (item.status === "running" || item.status === "waiting")) {
39
+ await transition(item, "orphaned", "recovery.orphaned");
40
+ }
41
+ }
31
42
  const pump = async () => {
32
43
  const records = await all();
33
44
  const running = records.filter((item) => item.status === "running" || item.status === "waiting");
@@ -1,3 +1,9 @@
1
+ type WorkItem = {
2
+ contract_version: "work-item/v1";
3
+ acceptance_criteria: Array<{
4
+ id: string;
5
+ }>;
6
+ };
1
7
  type Goal = {
2
8
  schema_version: 1;
3
9
  goal_id: string;
@@ -14,6 +20,11 @@ type Goal = {
14
20
  };
15
21
  receipts: Array<Record<string, unknown>>;
16
22
  updated_at: string;
23
+ work_item?: WorkItem;
24
+ completion_evidence?: Array<{
25
+ criterion_id: string;
26
+ evidence: string;
27
+ }>;
17
28
  };
18
29
  type Client = {
19
30
  session: {
@@ -12,6 +12,14 @@ function tokens(messages) {
12
12
  const value = last?.info?.tokens?.total ?? last?.info?.tokens?.output;
13
13
  return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
14
14
  }
15
+ function validWorkItem(goal) {
16
+ return goal.work_item?.contract_version === "work-item/v1" && Array.isArray(goal.work_item.acceptance_criteria) && goal.work_item.acceptance_criteria.length > 0;
17
+ }
18
+ function hasCompletionEvidence(goal) {
19
+ const criteria = goal.work_item?.acceptance_criteria ?? [];
20
+ const evidence = goal.completion_evidence ?? [];
21
+ return criteria.every((criterion) => evidence.some((item) => item.criterion_id === criterion.id && item.evidence.trim().length > 0));
22
+ }
15
23
  export async function goalLoop({ client }, options = {}) {
16
24
  if (!options.enabled)
17
25
  return {};
@@ -29,6 +37,8 @@ export async function goalLoop({ client }, options = {}) {
29
37
  const item = await lookup(session);
30
38
  if (!item || item.state.status !== "running")
31
39
  return;
40
+ if (!validWorkItem(item.state))
41
+ return settle(session, "blocked", "invalid work item");
32
42
  item.state.status = status;
33
43
  item.state.revision += 1;
34
44
  item.state.updated_at = new Date().toISOString();
@@ -53,6 +63,8 @@ export async function goalLoop({ client }, options = {}) {
53
63
  if (item.state.limits.token_budget > 0 && item.state.usage.tokens >= item.state.limits.token_budget)
54
64
  return settle(session, "blocked", "token budget reached");
55
65
  const verdict = await (options.audit?.(item.state) ?? Promise.resolve("continue"));
66
+ if (verdict === "complete" && !hasCompletionEvidence(item.state))
67
+ return settle(session, "blocked", "completion evidence is incomplete");
56
68
  if (verdict === "complete" || verdict === "blocked")
57
69
  return settle(session, verdict, "audit verdict");
58
70
  await client.session.prompt({ path: { id: session }, body: { parts: [{ type: "text", text: "Continue the current goal within its declared constraints and boundaries." }] } });
package/dist/routing.d.ts CHANGED
@@ -8,8 +8,10 @@ export type AvailableAgent = {
8
8
  };
9
9
  export type RoutingInput = {
10
10
  category: Category;
11
+ task?: string;
11
12
  requirements: string[];
12
13
  agents: AvailableAgent[];
14
+ execution_card?: unknown;
13
15
  override?: string;
14
16
  budget?: {
15
17
  cost_class?: string;
@@ -27,13 +29,68 @@ export type RoutingDecision = {
27
29
  excluded_reasons: string[];
28
30
  }[];
29
31
  matrix_revision: string;
32
+ task_digest: string;
33
+ requirements_digest: string;
34
+ execution_card_digest?: string;
35
+ execution_card_revision?: number;
30
36
  decision_digest: string;
31
37
  };
38
+ export type ControlMarker = {
39
+ path: string;
40
+ expected?: string;
41
+ expected_absent?: boolean;
42
+ };
43
+ export type ExecutionCard = {
44
+ status: "READY";
45
+ card_id: string;
46
+ revision: number;
47
+ objective: string;
48
+ changed_behavior: string[];
49
+ risks: string[];
50
+ write_set: string[];
51
+ control_markers: ControlMarker[];
52
+ decisions: string[];
53
+ steps: Array<{
54
+ path: string;
55
+ operation: string;
56
+ }>;
57
+ acceptance_criteria: string[];
58
+ checks: string[];
59
+ boundaries: {
60
+ forbidden_paths: string[];
61
+ };
62
+ };
63
+ export type ExecutionCardStatus = ExecutionCard["status"] | "RUNNING" | "COMPLETED" | "BLOCKED" | "FAILED" | "REJECTED_PLAN" | "APPROVED" | "CHANGES_REQUIRED" | "CANCELLED";
32
64
  export declare function resolveRouting(input: RoutingInput): RoutingDecision;
65
+ export declare function validateExecutionCard(card: unknown): {
66
+ valid: true;
67
+ card: ExecutionCard;
68
+ } | {
69
+ valid: false;
70
+ failedField: string;
71
+ };
72
+ type ReceiptContext = {
73
+ task?: string;
74
+ requirements?: string[];
75
+ card?: unknown;
76
+ };
77
+ export declare class ExecutionCardLifecycle {
78
+ #private;
79
+ constructor(card: unknown);
80
+ get status(): ExecutionCardStatus;
81
+ get card_id(): string;
82
+ get revision(): number;
83
+ transition(status: ExecutionCardStatus, identity: {
84
+ card_id: string;
85
+ revision: number;
86
+ }): ExecutionCardStatus;
87
+ }
33
88
  export declare class RoutingGate {
34
89
  #private;
35
90
  preview(input: RoutingInput): RoutingDecision;
36
91
  dispatch(input: RoutingInput, provided: unknown): RoutingDecision;
37
- grant(sessionID: string, decision: RoutingDecision): void;
38
- consume(sessionID: string, agent: string): void;
92
+ grant(sessionID: string, decision: RoutingDecision, context?: ReceiptContext): void;
93
+ cancel(sessionID: string): void;
94
+ consume(sessionID: string, agent: string, context?: ReceiptContext): void;
39
95
  }
96
+ export {};
package/dist/routing.js CHANGED
@@ -58,33 +58,173 @@ export function resolveRouting(input) {
58
58
  eligible.push(agent);
59
59
  }
60
60
  const selected = input.override ? eligible.find((agent) => agent.agent === input.override) : eligible[0];
61
+ const card = input.execution_card === undefined ? undefined : validateExecutionCard(input.execution_card);
62
+ if (card && !card.valid)
63
+ throw new Error(`Invalid execution card: ${card.failedField}`);
61
64
  const base = selected
62
- ? { schema_version: 1, status: "selected", category: input.category, agent: selected.agent, reason_codes: [input.override ? "explicit_override" : selected.agent === category.profiles[0] ? "primary_available" : "fallback_selected", "capabilities_match"], alternatives, matrix_revision: revision }
63
- : { schema_version: 1, status: "escalate", category: input.category, reason_codes: [input.override ? "override_unavailable" : "no_eligible_profile"], alternatives, matrix_revision: revision };
65
+ ? { schema_version: 1, status: "selected", category: input.category, agent: selected.agent, reason_codes: [input.override ? "explicit_override" : selected.agent === category.profiles[0] ? "primary_available" : "fallback_selected", "capabilities_match"], alternatives, matrix_revision: revision, task_digest: digest(input.task ?? ""), requirements_digest: digest(input.requirements), ...(card?.valid ? { execution_card_digest: digest(card.card), execution_card_revision: card.card.revision } : {}) }
66
+ : { schema_version: 1, status: "escalate", category: input.category, reason_codes: [input.override ? "override_unavailable" : "no_eligible_profile"], alternatives, matrix_revision: revision, task_digest: digest(input.task ?? ""), requirements_digest: digest(input.requirements), ...(card?.valid ? { execution_card_digest: digest(card.card), execution_card_revision: card.card.revision } : {}) };
64
67
  return { ...base, decision_digest: digest(base) };
65
68
  }
69
+ function isNonEmptyStringArray(value) {
70
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string" && item.length > 0);
71
+ }
72
+ export function validateExecutionCard(card) {
73
+ if (!card || typeof card !== "object" || Array.isArray(card)) {
74
+ return { valid: false, failedField: "root" };
75
+ }
76
+ const c = card;
77
+ const expectedFields = [
78
+ "status", "card_id", "revision", "objective", "changed_behavior", "risks",
79
+ "write_set", "control_markers", "decisions", "steps", "acceptance_criteria",
80
+ "checks", "boundaries",
81
+ ];
82
+ if (Object.keys(c).sort().join(",") !== expectedFields.slice().sort().join(","))
83
+ return { valid: false, failedField: "schema" };
84
+ if (c.status !== "READY")
85
+ return { valid: false, failedField: "status" };
86
+ if (typeof c.card_id !== "string" || !c.card_id)
87
+ return { valid: false, failedField: "card_id" };
88
+ if (typeof c.revision !== "number" || !Number.isInteger(c.revision) || c.revision < 1)
89
+ return { valid: false, failedField: "revision" };
90
+ if (typeof c.objective !== "string" || !c.objective)
91
+ return { valid: false, failedField: "objective" };
92
+ if (!isNonEmptyStringArray(c.changed_behavior))
93
+ return { valid: false, failedField: "changed_behavior" };
94
+ if (!isNonEmptyStringArray(c.risks))
95
+ return { valid: false, failedField: "risks" };
96
+ if (!isNonEmptyStringArray(c.write_set))
97
+ return { valid: false, failedField: "write_set" };
98
+ if (new Set(c.write_set).size !== c.write_set.length)
99
+ return { valid: false, failedField: "write_set" };
100
+ if (!Array.isArray(c.control_markers) || c.control_markers.length === 0)
101
+ return { valid: false, failedField: "control_markers" };
102
+ for (const marker of c.control_markers) {
103
+ if (!marker || typeof marker !== "object" || Array.isArray(marker))
104
+ return { valid: false, failedField: "control_markers" };
105
+ const m = marker;
106
+ if (typeof m.path !== "string" || !m.path)
107
+ return { valid: false, failedField: "control_markers" };
108
+ const hasExpected = typeof m.expected === "string" && m.expected.length > 0;
109
+ const hasExpectedAbsent = m.expected_absent === true;
110
+ if (hasExpected === hasExpectedAbsent)
111
+ return { valid: false, failedField: "control_markers" };
112
+ if (Object.keys(m).some((k) => k !== "path" && k !== "expected" && k !== "expected_absent"))
113
+ return { valid: false, failedField: "control_markers" };
114
+ }
115
+ if (!isNonEmptyStringArray(c.decisions))
116
+ return { valid: false, failedField: "decisions" };
117
+ if (!Array.isArray(c.steps) || c.steps.length === 0)
118
+ return { valid: false, failedField: "steps" };
119
+ for (const step of c.steps) {
120
+ if (!step || typeof step !== "object" || Array.isArray(step))
121
+ return { valid: false, failedField: "steps" };
122
+ const s = step;
123
+ if (typeof s.path !== "string" || !s.path)
124
+ return { valid: false, failedField: "steps" };
125
+ if (typeof s.operation !== "string" || !s.operation)
126
+ return { valid: false, failedField: "steps" };
127
+ if (Object.keys(s).some((k) => k !== "path" && k !== "operation"))
128
+ return { valid: false, failedField: "steps" };
129
+ }
130
+ if (!isNonEmptyStringArray(c.acceptance_criteria))
131
+ return { valid: false, failedField: "acceptance_criteria" };
132
+ if (!isNonEmptyStringArray(c.checks))
133
+ return { valid: false, failedField: "checks" };
134
+ if (!c.boundaries || typeof c.boundaries !== "object" || Array.isArray(c.boundaries))
135
+ return { valid: false, failedField: "boundaries" };
136
+ const b = c.boundaries;
137
+ if (!isNonEmptyStringArray(b.forbidden_paths))
138
+ return { valid: false, failedField: "boundaries" };
139
+ if (Object.keys(b).some((k) => k !== "forbidden_paths"))
140
+ return { valid: false, failedField: "boundaries" };
141
+ const writeSet = c.write_set;
142
+ const forbiddenPaths = b.forbidden_paths;
143
+ const stepPaths = c.steps.map((s) => s.path);
144
+ const markerPaths = c.control_markers.map((m) => m.path);
145
+ const referenced = new Set([...stepPaths, ...markerPaths]);
146
+ if (![...referenced].every((p) => writeSet.includes(p)))
147
+ return { valid: false, failedField: "steps" };
148
+ if (writeSet.some((p) => forbiddenPaths.includes(p)))
149
+ return { valid: false, failedField: "boundaries" };
150
+ return { valid: true, card: c };
151
+ }
152
+ export class ExecutionCardLifecycle {
153
+ #status = "READY";
154
+ #cardID;
155
+ #revision;
156
+ constructor(card) {
157
+ const result = validateExecutionCard(card);
158
+ if (!result.valid)
159
+ throw new Error(`Invalid execution card: ${result.failedField}`);
160
+ this.#cardID = result.card.card_id;
161
+ this.#revision = result.card.revision;
162
+ }
163
+ get status() { return this.#status; }
164
+ get card_id() { return this.#cardID; }
165
+ get revision() { return this.#revision; }
166
+ transition(status, identity) {
167
+ if (identity.card_id !== this.#cardID || identity.revision !== this.#revision)
168
+ throw new Error("Execution card identity is stale");
169
+ const allowed = {
170
+ READY: ["RUNNING", "CANCELLED"],
171
+ RUNNING: ["COMPLETED", "BLOCKED", "FAILED", "REJECTED_PLAN", "CANCELLED"],
172
+ COMPLETED: ["APPROVED", "CHANGES_REQUIRED"],
173
+ BLOCKED: [], FAILED: [], REJECTED_PLAN: [], APPROVED: [], CHANGES_REQUIRED: [], CANCELLED: [],
174
+ };
175
+ if (!allowed[this.#status].includes(status))
176
+ throw new Error(`Invalid execution-card transition: ${this.#status} -> ${status}`);
177
+ this.#status = status;
178
+ return this.#status;
179
+ }
180
+ }
66
181
  export class RoutingGate {
67
182
  #receipts = new Map();
183
+ #ttlMs = 10 * 60 * 1000;
68
184
  preview(input) {
69
185
  return resolveRouting(input);
70
186
  }
71
187
  dispatch(input, provided) {
72
188
  const decision = resolveRouting(input);
73
- if (!provided || typeof provided !== "object" || provided.decision_digest !== decision.decision_digest || provided.matrix_revision !== decision.matrix_revision)
189
+ if (!provided || typeof provided !== "object" || stable(provided) !== stable(decision))
74
190
  throw new Error("Routing decision is stale; request a new preview");
75
191
  if (decision.status !== "selected" || !decision.agent)
76
192
  throw new Error("Routing escalated; no agent was dispatched");
77
193
  return decision;
78
194
  }
79
- grant(sessionID, decision) {
80
- this.#receipts.set(sessionID, decision);
195
+ grant(sessionID, decision, context) {
196
+ const now = Date.now();
197
+ const taskDigest = context?.task === undefined ? decision.task_digest : digest(context.task);
198
+ const requirementsDigest = context?.requirements === undefined ? decision.requirements_digest : digest(context.requirements);
199
+ const cardDigest = context?.card === undefined ? decision.execution_card_digest : digest(context.card);
200
+ this.#receipts.set(sessionID, {
201
+ decision,
202
+ taskDigest,
203
+ requirementsDigest,
204
+ cardDigest,
205
+ expiresAt: now + this.#ttlMs,
206
+ });
81
207
  }
82
- consume(sessionID, agent) {
208
+ cancel(sessionID) { this.#receipts.delete(sessionID); }
209
+ consume(sessionID, agent, context) {
83
210
  const receipt = this.#receipts.get(sessionID);
84
211
  if (!receipt)
85
212
  throw new Error("Native Task requires an active routing receipt; use the route tool");
86
- if (receipt.agent !== agent)
213
+ if (receipt.decision.agent !== agent)
87
214
  throw new Error("Native Task agent does not match the active routing receipt");
215
+ if (Date.now() > receipt.expiresAt)
216
+ throw new Error("Routing receipt has expired; request a new preview");
217
+ if (context) {
218
+ const taskDigest = digest(context.task ?? "");
219
+ if (taskDigest !== receipt.taskDigest)
220
+ throw new Error("Routing receipt task does not match the active routing receipt");
221
+ const requirementsDigest = digest(context.requirements ?? []);
222
+ if (requirementsDigest !== receipt.requirementsDigest)
223
+ throw new Error("Routing receipt requirements do not match the active routing receipt");
224
+ const cardDigest = context.card !== undefined ? digest(context.card) : undefined;
225
+ if (cardDigest !== receipt.cardDigest)
226
+ throw new Error("Routing receipt execution card does not match the active routing receipt");
227
+ }
88
228
  this.#receipts.delete(sessionID);
89
229
  }
90
230
  }
@@ -4,4 +4,5 @@ export declare function readState<T>(path: string, boundary: string): Promise<T
4
4
  export declare function listState(boundary: string, suffix?: string): Promise<string[]>;
5
5
  export declare function writeState(path: string, boundary: string, value: unknown): Promise<void>;
6
6
  export declare function appendState(path: string, boundary: string, value: unknown): Promise<void>;
7
+ export declare function withStateLock<T>(boundary: string, callback: () => Promise<T>): Promise<T>;
7
8
  export declare function digest(value: unknown): string;
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { lstat, mkdir, readFile, readdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { appendPrivate, writeAtomic } from "../lifecycle.js";
5
+ import { appendPrivate, withLifecycleLock, writeAtomic } from "../lifecycle.js";
6
6
  function home(value = process.env.HOME ?? homedir()) {
7
7
  if (!isAbsolute(value) || value.split(sep).includes(".."))
8
8
  throw new Error("HOME must be an absolute safe path");
@@ -94,14 +94,21 @@ export async function listState(boundary, suffix = ".json") {
94
94
  export async function writeState(path, boundary, value) {
95
95
  if (!inside(boundary, path))
96
96
  throw new Error("state path escapes its boundary");
97
- await safeDirectory(dirname(path), boundary, true);
98
- await writeAtomic(path, Buffer.from(`${JSON.stringify(value)}\n`), 0o600);
97
+ await withLifecycleLock(boundary, async () => {
98
+ await safeDirectory(dirname(path), boundary, true);
99
+ await writeAtomic(path, Buffer.from(`${JSON.stringify(value)}\n`), 0o600);
100
+ });
99
101
  }
100
102
  export async function appendState(path, boundary, value) {
101
103
  if (!inside(boundary, path))
102
104
  throw new Error("state path escapes its boundary");
103
- await safeDirectory(dirname(path), boundary, true);
104
- await appendPrivate(path, value);
105
+ await withLifecycleLock(boundary, async () => {
106
+ await safeDirectory(dirname(path), boundary, true);
107
+ await appendPrivate(path, value);
108
+ });
109
+ }
110
+ export async function withStateLock(boundary, callback) {
111
+ return withLifecycleLock(boundary, callback);
105
112
  }
106
113
  export function digest(value) {
107
114
  return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex");
@@ -0,0 +1,2 @@
1
+ export declare function selectOption(label: string, options: readonly string[], stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream, initial?: number): Promise<number | null>;
2
+ export declare function promptText(label: string, stdin?: NodeJS.ReadStream, stderr?: NodeJS.WriteStream): Promise<string | null>;
@@ -0,0 +1,125 @@
1
+ import { InstallerError } from "./installer.js";
2
+ function parseKey(buffer) {
3
+ const seq = buffer.toString();
4
+ if (seq === "\r" || seq === "\n")
5
+ return { name: "return", sequence: seq };
6
+ if (seq === "\x03")
7
+ return { name: "ctrl+c", sequence: seq };
8
+ if (seq === "\x04")
9
+ return { name: "ctrl+d", sequence: seq };
10
+ if (seq === "\x1b" || seq === "\x1b\x1b" || seq === "\x1b\x1b\x1b")
11
+ return { name: "escape", sequence: seq };
12
+ if (seq === "\x1b[A" || seq === "\x1b[OA")
13
+ return { name: "up", sequence: seq };
14
+ if (seq === "\x1b[B" || seq === "\x1b[OB")
15
+ return { name: "down", sequence: seq };
16
+ if (seq === "\x1b[C" || seq === "\x1b[OC")
17
+ return { name: "right", sequence: seq };
18
+ if (seq === "\x1b[D" || seq === "\x1b[OD")
19
+ return { name: "left", sequence: seq };
20
+ return { name: "unknown", sequence: seq };
21
+ }
22
+ function clearLines(stderr, count) {
23
+ for (let i = 0; i < count; i++) {
24
+ stderr.write("\x1b[A\x1b[K");
25
+ }
26
+ }
27
+ export async function selectOption(label, options, stdin = process.stdin, stderr = process.stderr, initial = 0) {
28
+ if (!stdin.isTTY || !stderr.isTTY) {
29
+ throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
30
+ }
31
+ let selected = Math.max(0, Math.min(initial, options.length - 1));
32
+ const lineCount = options.length + 1; // label + options
33
+ function render() {
34
+ stderr.write(`${label}\n`);
35
+ for (let i = 0; i < options.length; i++) {
36
+ const cursor = i === selected ? "> " : " ";
37
+ stderr.write(`${cursor}${options[i]}\n`);
38
+ }
39
+ }
40
+ render();
41
+ return new Promise((resolve) => {
42
+ stdin.setRawMode(true);
43
+ stdin.resume();
44
+ function cleanup() {
45
+ stdin.setRawMode(false);
46
+ stdin.pause();
47
+ stdin.removeListener("data", onData);
48
+ }
49
+ function onData(data) {
50
+ let remaining = data.toString();
51
+ while (remaining) {
52
+ const sequence = remaining.startsWith("\x1b[") ? remaining.slice(0, 3) : remaining[0];
53
+ remaining = remaining.slice(sequence.length);
54
+ const key = parseKey(Buffer.from(sequence));
55
+ if (key.name === "ctrl+c" || key.name === "ctrl+d" || key.name === "escape") {
56
+ cleanup();
57
+ clearLines(stderr, lineCount);
58
+ resolve(null);
59
+ return;
60
+ }
61
+ if (key.name === "return") {
62
+ cleanup();
63
+ clearLines(stderr, lineCount);
64
+ resolve(selected);
65
+ return;
66
+ }
67
+ if (key.name === "up") {
68
+ clearLines(stderr, lineCount);
69
+ selected = (selected - 1 + options.length) % options.length;
70
+ render();
71
+ }
72
+ else if (key.name === "down") {
73
+ clearLines(stderr, lineCount);
74
+ selected = (selected + 1) % options.length;
75
+ render();
76
+ }
77
+ }
78
+ }
79
+ stdin.on("data", onData);
80
+ });
81
+ }
82
+ export async function promptText(label, stdin = process.stdin, stderr = process.stderr) {
83
+ if (!stdin.isTTY || !stderr.isTTY) {
84
+ throw new InstallerError("terminal_required", "This operation requires an interactive terminal");
85
+ }
86
+ stderr.write(`${label} `);
87
+ return new Promise((resolve) => {
88
+ let buffer = "";
89
+ stdin.setRawMode(true);
90
+ stdin.resume();
91
+ function cleanup() {
92
+ stdin.setRawMode(false);
93
+ stdin.pause();
94
+ stdin.removeListener("data", onData);
95
+ }
96
+ function onData(data) {
97
+ for (const seq of data.toString()) {
98
+ if (seq === "\r" || seq === "\n") {
99
+ cleanup();
100
+ stderr.write("\n");
101
+ resolve(buffer.trim() || null);
102
+ return;
103
+ }
104
+ if (seq === "\x03" || seq === "\x04" || seq === "\x1b") {
105
+ cleanup();
106
+ stderr.write("\n");
107
+ resolve(null);
108
+ return;
109
+ }
110
+ if (seq === "\x7f" || seq === "\b") {
111
+ if (buffer.length > 0) {
112
+ buffer = buffer.slice(0, -1);
113
+ stderr.write("\x1b[D \x1b[D");
114
+ }
115
+ continue;
116
+ }
117
+ if (seq >= " ") {
118
+ buffer += seq;
119
+ stderr.write(seq);
120
+ }
121
+ }
122
+ }
123
+ stdin.on("data", onData);
124
+ });
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kisev/skills-opencode",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "OpenCode integration and opt-in installer for portable Agent Skills.",
5
5
  "license": "MIT",
6
6
  "repository": {