@kisev/skills-opencode 1.1.1 → 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
@@ -91,10 +91,14 @@ npm exec -- skills-opencode agent model-set worker --scope global \
91
91
  npm exec -- skills-opencode agent reconcile --scope global --dry-run
92
92
  ```
93
93
 
94
- `agent configure` предлагает terminal selection в порядке provider, model,
95
- variant по cached output `opencode models`; refresh не выполняется. Если catalog
96
- недоступен, передайте exact `--provider <provider> --model <model>` или
97
- `--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 не показывается.
98
102
 
99
103
  Additional critic имеет имя `critic-<safe-suffix>`. Стандартный `critic` и fixed
100
104
  roles нельзя удалить или переименовать:
@@ -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");
package/dist/cli.js CHANGED
@@ -1,9 +1,9 @@
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
3
  import { renderInventory, renderPlan, shellCommand, terminalSafe } from "./cli-output.js";
5
4
  import { apply, InstallerError, preview } from "./installer.js";
6
5
  import { LifecycleError } from "./lifecycle.js";
6
+ import { promptText, selectOption } from "./terminal-wizard.js";
7
7
  function parseOptions(values) {
8
8
  const options = { dryRun: false, json: false };
9
9
  for (let index = 0; index < values.length; index += 1) {
@@ -83,56 +83,123 @@ function exactModel(options) {
83
83
  throw new InstallerError("invalid_input", "--provider is required when --model is not provider/model");
84
84
  return validateModel(`${options.provider}/${options.model}`);
85
85
  }
86
- async function choose(label, values, input) {
87
- process.stderr.write(`${label}:\n${values.map((value, index) => ` ${index + 1}. ${value}`).join("\n")}\n`);
88
- const answer = await input.question("> ");
89
- const index = Number(answer) - 1;
90
- if (!Number.isSafeInteger(index) || !values[index])
91
- throw new InstallerError("invalid_input", `Invalid ${label.toLowerCase()} selection`);
92
- return values[index];
93
- }
94
- async function interactiveSelection(options) {
95
- if (!process.stdin.isTTY || !process.stderr.isTTY)
86
+ async function interactiveSelection(options, action = "model-set") {
87
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
96
88
  throw new InstallerError("terminal_required", "agent configure requires a terminal or explicit --provider and --model");
97
- const input = createInterface({ input: process.stdin, output: process.stderr });
98
- try {
99
- const inventory = await listAgentProfiles(options.scope);
100
- const configurable = inventory.profiles.filter((item) => item.ownership !== "user-owned").map((item) => item.name);
101
- const name = options.name ? validateAgentName(options.name) : await choose("Agent", configurable.length ? configurable : FIXED_AGENT_ROLES, input);
102
- if (options.provider && options.model)
103
- return { name, model: exactModel(options), ...(validateVariant(options.variant) ? { variant: validateVariant(options.variant) } : {}) };
104
- let models;
105
- try {
106
- models = await availableModels();
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}` : ""})`);
107
129
  }
108
- catch (error) {
109
- if (!(error instanceof AgentProfileError) || error.code !== "catalog_unavailable")
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
+ }
157
+ throw error;
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
+ }
110
186
  throw error;
111
- const provider = options.provider ?? (await input.question("Provider: ")).trim();
112
- const model = options.model ?? (await input.question("Model: ")).trim();
113
- const variant = options.variant === undefined ? (await input.question("Variant (optional): ")).trim() : options.variant;
114
- return { name, model: validateModel(model.includes("/") ? model : `${provider}/${model}`), ...(validateVariant(variant) ? { variant: validateVariant(variant) } : {}) };
187
+ }
188
+ showTarget(selectedModel, selectedVariant);
189
+ return { name, model: selectedModel, ...(selectedVariant ? { variant: selectedVariant } : {}) };
115
190
  }
116
- const providers = [...new Set(models.map((model) => model.split("/", 1)[0]))].sort();
117
- const provider = options.provider ?? (await choose("Provider", providers, input));
118
- const selectedModel = options.model
119
- ? exactModel({ ...options, provider })
120
- : await choose("Model", models.filter((model) => model.startsWith(`${provider}/`)), input);
121
- let variants = [];
122
- try {
123
- variants = await availableModelVariants(selectedModel);
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 };
124
196
  }
125
- catch (error) {
126
- if (!(error instanceof AgentProfileError) || error.code !== "catalog_unavailable")
127
- throw error;
197
+ if (chosen === "Back") {
198
+ return interactiveSelection({ ...options, name: undefined }, action);
199
+ }
200
+ if (chosen === "Cancel") {
201
+ throw new InstallerError("cancelled", "Wizard cancelled");
128
202
  }
129
- const variant = options.variant === null
130
- ? undefined
131
- : options.variant ?? (variants.length ? await choose("Variant", ["none", ...variants.filter((value) => value !== "none")], input) : (await input.question("Variant (optional): ")).trim());
132
- return { name, model: selectedModel, ...(variant && variant !== "none" ? { variant: validateVariant(variant) } : {}) };
133
- }
134
- finally {
135
- input.close();
136
203
  }
137
204
  }
138
205
  async function runProfile(request, options) {
@@ -210,7 +277,7 @@ async function run(arguments_) {
210
277
  requireConfirmationMode(options);
211
278
  const selected = options.provider && options.model && options.name
212
279
  ? { name: validateAgentName(options.name), model: exactModel(options), ...(validateVariant(options.variant) ? { variant: validateVariant(options.variant) } : {}) }
213
- : await interactiveSelection(options);
280
+ : await interactiveSelection(options, "model-set");
214
281
  await runProfile({ action: "model-set", ...selected, variant: selected.variant ?? null }, options);
215
282
  return;
216
283
  }
@@ -237,9 +304,13 @@ async function run(arguments_) {
237
304
  : `critic-${options.name ?? ""}`;
238
305
  if (operation === "add") {
239
306
  const model = exactModel(options);
240
- if (!model)
241
- throw new InstallerError("invalid_input", "critic add requires --provider and --model, or exact --model provider/model");
242
- 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
+ }
243
314
  }
244
315
  else {
245
316
  if (options.provider || options.model || options.variant !== undefined)
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,7 +10,7 @@ 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 = {
@@ -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
  });
@@ -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.1",
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": {