@snarkipus/opencode-beads 1.0.2 → 1.1.0-beta.1
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 +41 -24
- package/assets/oc-beads/SKILL.md +149 -0
- package/assets/oc-beads/adr/0001-bd-prime-as-source-of-truth.md +40 -0
- package/assets/oc-beads/resources/AGENTS.md +43 -0
- package/assets/oc-beads/resources/ASYNC_GATES.md +63 -0
- package/assets/oc-beads/resources/BOUNDARIES.md +57 -0
- package/assets/oc-beads/resources/CHEMISTRY_PATTERNS.md +44 -0
- package/assets/oc-beads/resources/CLI_REFERENCE.md +20 -0
- package/assets/oc-beads/resources/CODE_MODE.md +78 -0
- package/assets/oc-beads/resources/DEPENDENCIES.md +82 -0
- package/assets/oc-beads/resources/INTEGRATION_PATTERNS.md +74 -0
- package/assets/oc-beads/resources/ISSUE_CREATION.md +45 -0
- package/assets/oc-beads/resources/MOLECULES.md +65 -0
- package/assets/oc-beads/resources/PATTERNS.md +43 -0
- package/assets/oc-beads/resources/RESUMABILITY.md +83 -0
- package/assets/oc-beads/resources/STATIC_DATA.md +19 -0
- package/assets/oc-beads/resources/TROUBLESHOOTING.md +49 -0
- package/assets/oc-beads/resources/WORKFLOWS.md +64 -0
- package/assets/oc-beads/resources/WORKTREES.md +19 -0
- package/package.json +5 -4
- package/src/agents.ts +2 -2
- package/src/context.ts +67 -22
- package/src/continuity.ts +380 -0
- package/src/guard.ts +44 -11
- package/src/guidance.ts +65 -15
- package/src/host.ts +323 -42
- package/src/instructions.ts +11 -8
- package/src/plugin.ts +2 -1
- package/src/prime.ts +15 -4
- package/src/workflows.ts +131 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snarkipus/opencode-beads",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.1.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A maintained OpenCode V2 integration for the Beads issue tracker: bd prime context injection with a schema-migration guard, and a bounded task agent.",
|
|
6
6
|
"author": "Matt Jackson",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"src",
|
|
46
|
+
"assets/oc-beads",
|
|
46
47
|
"README.md",
|
|
47
48
|
"LICENSE"
|
|
48
49
|
],
|
|
@@ -55,11 +56,11 @@
|
|
|
55
56
|
"typecheck": "tsc --noEmit"
|
|
56
57
|
},
|
|
57
58
|
"beads": {
|
|
58
|
-
"minimum": "1.3.
|
|
59
|
+
"minimum": "1.3.1",
|
|
59
60
|
"validated": "1.3.1"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
|
-
"@opencode/plugin": ">=2.
|
|
63
|
+
"@opencode/plugin": ">=2.1.1 <2.2"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@opencode/plugin": {
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
}
|
|
68
69
|
},
|
|
69
70
|
"devDependencies": {
|
|
70
|
-
"@opencode/plugin": "2.
|
|
71
|
+
"@opencode/plugin": "2.1.1",
|
|
71
72
|
"@types/bun": "1.3.4",
|
|
72
73
|
"@types/node": "^22.0.0",
|
|
73
74
|
"typescript": "5.9.3"
|
package/src/agents.ts
CHANGED
|
@@ -8,7 +8,7 @@ export const BEADS_TASK_AGENTS: ReadonlySet<string> = new Set([
|
|
|
8
8
|
BEADS_TASK_AGENT_LUNA,
|
|
9
9
|
]);
|
|
10
10
|
|
|
11
|
-
/** Default
|
|
11
|
+
/** Default interruption cap on worker steps; never a completion target or authority to continue. */
|
|
12
12
|
export const DEFAULT_TASK_AGENT_STEPS = 100;
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -45,7 +45,7 @@ export interface BeadsAgentDefinition {
|
|
|
45
45
|
export interface BeadsAgentOptions {
|
|
46
46
|
/** Register the Luna Max profile alongside the default task agent. */
|
|
47
47
|
luna?: boolean;
|
|
48
|
-
/**
|
|
48
|
+
/** Interruption cap on worker steps; 0 removes the cap, never the assignment's scope limits. */
|
|
49
49
|
steps?: number;
|
|
50
50
|
}
|
|
51
51
|
|
package/src/context.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { executorActor } from "./continuity";
|
|
1
2
|
import { BEADS_TASK_AGENTS } from "./agents";
|
|
2
3
|
import { guardNotice, type GuardResult } from "./guard";
|
|
3
4
|
import { PrimeTimeoutError } from "./prime";
|
|
4
|
-
import { beadsGuidance, primeFailureNotice } from "./guidance";
|
|
5
|
+
import { beadsGuidance, primeFailureNotice, type RequestCapabilities } from "./guidance";
|
|
5
6
|
|
|
6
7
|
/** Retry window for withheld or failed context, so model steps do not respawn `bd` each time. */
|
|
7
8
|
export const DEFAULT_CONTEXT_RETRY_MS = 30_000;
|
|
@@ -15,11 +16,12 @@ export const DEFAULT_IDLE_REFRESH_MS = 30 * 60_000;
|
|
|
15
16
|
|
|
16
17
|
export type AgentMode = "primary" | "subagent" | "all";
|
|
17
18
|
export type ContextAudience = "primary" | "task-agent";
|
|
18
|
-
export type ContextDiagnostic = "agent_lookup_failed" | "prime_failed" | "prime_timeout";
|
|
19
|
+
export type ContextDiagnostic = "agent_lookup_failed" | "guard_failed" | "prime_failed" | "prime_timeout";
|
|
20
|
+
export type ContextStatus = "loading" | "missing" | "withheld" | "failed" | "empty" | "ready";
|
|
19
21
|
|
|
20
22
|
export interface ContextRuntime {
|
|
21
|
-
guard(directory: string): Promise<GuardResult>;
|
|
22
|
-
prime(directory: string): Promise<string>;
|
|
23
|
+
guard(directory: string, signal?: AbortSignal): Promise<GuardResult>;
|
|
24
|
+
prime(directory: string, signal?: AbortSignal): Promise<string>;
|
|
23
25
|
/** Resolve an agent's mode; undefined when the agent is unknown. */
|
|
24
26
|
agentMode(agentID: string): Promise<AgentMode | undefined>;
|
|
25
27
|
diagnose?(code: ContextDiagnostic, sessionID: string): void;
|
|
@@ -34,26 +36,33 @@ export interface ContextControllerOptions {
|
|
|
34
36
|
|
|
35
37
|
export interface ContextController {
|
|
36
38
|
/** System text for one model request, or undefined when nothing should be added. */
|
|
37
|
-
systemFor(sessionID: string, agentID: string): Promise<string | undefined>;
|
|
39
|
+
systemFor(sessionID: string, agentID: string, capabilities?: RequestCapabilities): Promise<string | undefined>;
|
|
38
40
|
/** Refresh prime on the next request, e.g. after compaction changed the session. */
|
|
39
41
|
invalidate(sessionID: string): void;
|
|
40
42
|
/** Drop all state for a deleted session. */
|
|
41
43
|
forget(sessionID: string): void;
|
|
44
|
+
/** Context state is distinct from an absent text part (including intentional empty prime). */
|
|
45
|
+
statusFor(sessionID: string, audience: ContextAudience): ContextStatus | undefined;
|
|
46
|
+
/** A write can affect every cached session in this location. */
|
|
47
|
+
invalidateAll(): void;
|
|
48
|
+
/** Abort owned loads and prevent late context injection after unload. */
|
|
49
|
+
dispose(): void;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
type CacheEntry = { value: Promise<string | undefined>; expiresAt?: number; lastUsed: number };
|
|
52
|
+
type CacheEntry = { value: Promise<string | undefined>; expiresAt?: number; lastUsed: number; abort: AbortController; status?: ContextStatus };
|
|
45
53
|
|
|
46
54
|
/** Wrap prime output in the envelope and shared guidance for one audience. */
|
|
47
|
-
export function renderBeadsContext(prime: string, audience: ContextAudience): string {
|
|
48
|
-
|
|
55
|
+
export function renderBeadsContext(prime: string, audience: ContextAudience, request?: { sessionID: string; agent: string; capabilities?: RequestCapabilities }): string {
|
|
56
|
+
const identity = request ? `\nYour Beads executor actor: ${executorActor(request)}. Use this exact actor for every direct shell write, claim and heartbeat (command-scoped --actor or BEADS_ACTOR); children use their own actual request actor. Attribution grants no permission.\n` : "";
|
|
57
|
+
return `<beads-context audience="${audience}">\n${prime}\n</beads-context>\n${identity}\n${beadsGuidance(audience, request?.capabilities)}`;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
/**
|
|
52
61
|
* Supply Beads context through the per-request session context hook.
|
|
53
62
|
*
|
|
54
63
|
* OpenCode V2 attaches system parts to every model request without storing them, so context
|
|
55
|
-
* survives compaction on its own.
|
|
56
|
-
*
|
|
64
|
+
* survives compaction on its own. Guarded prime is cached separately from per-request actor/capability rendering;
|
|
65
|
+
* compaction invalidates it so persistent memories can refresh.
|
|
57
66
|
*/
|
|
58
67
|
export function createContextController(
|
|
59
68
|
runtime: ContextRuntime,
|
|
@@ -65,6 +74,7 @@ export function createContextController(
|
|
|
65
74
|
const now = options.now ?? Date.now;
|
|
66
75
|
const cache = new Map<string, CacheEntry>();
|
|
67
76
|
const modes = new Map<string, Promise<AgentMode | undefined>>();
|
|
77
|
+
let disposed = false;
|
|
68
78
|
|
|
69
79
|
async function audienceFor(
|
|
70
80
|
sessionID: string,
|
|
@@ -97,34 +107,53 @@ export function createContextController(
|
|
|
97
107
|
|
|
98
108
|
let guard: GuardResult;
|
|
99
109
|
try {
|
|
100
|
-
guard = await runtime.guard(directory);
|
|
110
|
+
guard = await runtime.guard(directory, entry.abort.signal);
|
|
101
111
|
} catch {
|
|
102
|
-
return
|
|
112
|
+
if (entry.abort.signal.aborted) return undefined;
|
|
113
|
+
entry.status = "failed";
|
|
114
|
+
runtime.diagnose?.("guard_failed", sessionID);
|
|
115
|
+
return retryLater("<beads-notice>Beads workflow context failed during the store safety check. Store authority is unknown; do not bypass this failure with manual prime.</beads-notice>");
|
|
116
|
+
}
|
|
117
|
+
if (entry.abort.signal.aborted) return undefined;
|
|
118
|
+
if (guard.kind !== "ready") {
|
|
119
|
+
entry.status = guard.kind === "probe-failed" ? "failed" : guard.kind === "migration-pending" || guard.kind === "unsupported" ? "withheld" : "missing";
|
|
120
|
+
return retryLater(guardNotice(guard));
|
|
103
121
|
}
|
|
104
|
-
if (guard.kind !== "ready") return retryLater(guardNotice(guard));
|
|
105
122
|
|
|
106
123
|
let output: string;
|
|
107
124
|
try {
|
|
108
|
-
output = (await runtime.prime(directory)).trim();
|
|
125
|
+
output = (await runtime.prime(directory, entry.abort.signal)).trim();
|
|
109
126
|
} catch (error) {
|
|
127
|
+
if (entry.abort.signal.aborted) return undefined;
|
|
128
|
+
entry.status = "failed";
|
|
110
129
|
const timedOut = error instanceof PrimeTimeoutError;
|
|
111
130
|
runtime.diagnose?.(timedOut ? "prime_timeout" : "prime_failed", sessionID);
|
|
112
131
|
return retryLater(primeFailureNotice(timedOut ? "timed out" : "failed"));
|
|
113
132
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
133
|
+
if (entry.abort.signal.aborted) return undefined;
|
|
134
|
+
if (!output) {
|
|
135
|
+
entry.status = "empty";
|
|
136
|
+
return retryLater(undefined);
|
|
137
|
+
}
|
|
138
|
+
entry.status = "ready";
|
|
139
|
+
return output;
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
function dropSession(sessionID: string): void {
|
|
143
|
+
modes.clear();
|
|
119
144
|
for (const key of cache.keys()) {
|
|
120
|
-
if (key.startsWith(`${sessionID}\0`))
|
|
145
|
+
if (key.startsWith(`${sessionID}\0`)) {
|
|
146
|
+
cache.get(key)?.abort.abort();
|
|
147
|
+
cache.delete(key);
|
|
148
|
+
}
|
|
121
149
|
}
|
|
122
150
|
}
|
|
123
151
|
|
|
124
152
|
return {
|
|
125
|
-
async systemFor(sessionID, agentID) {
|
|
153
|
+
async systemFor(sessionID, agentID, capabilities) {
|
|
154
|
+
if (disposed) return undefined;
|
|
126
155
|
const audience = await audienceFor(sessionID, agentID);
|
|
127
|
-
if (!audience) return undefined;
|
|
156
|
+
if (!audience || disposed) return undefined;
|
|
128
157
|
|
|
129
158
|
const key = `${sessionID}\0${audience}`;
|
|
130
159
|
const current = now();
|
|
@@ -133,16 +162,32 @@ export function createContextController(
|
|
|
133
162
|
const resumed = cached && idleRefreshMs > 0 && current - cached.lastUsed > idleRefreshMs;
|
|
134
163
|
if (cached && fresh && !resumed) {
|
|
135
164
|
cached.lastUsed = current;
|
|
136
|
-
|
|
165
|
+
const output = await cached.value;
|
|
166
|
+
if (disposed || cached.abort.signal.aborted) return undefined;
|
|
167
|
+
return cached.status === "ready" ? renderBeadsContext(output!, audience, { sessionID, agent: agentID, capabilities }) : output;
|
|
137
168
|
}
|
|
138
169
|
|
|
139
|
-
|
|
170
|
+
cached?.abort.abort();
|
|
171
|
+
const entry: CacheEntry = { value: Promise.resolve(undefined), lastUsed: current, abort: new AbortController(), status: "loading" };
|
|
140
172
|
entry.value = load(sessionID, audience, entry);
|
|
141
173
|
cache.set(key, entry);
|
|
142
|
-
|
|
174
|
+
const output = await entry.value;
|
|
175
|
+
if (disposed || entry.abort.signal.aborted) return undefined;
|
|
176
|
+
return entry.status === "ready" ? renderBeadsContext(output!, audience, { sessionID, agent: agentID, capabilities }) : output;
|
|
143
177
|
},
|
|
144
178
|
|
|
145
179
|
invalidate: dropSession,
|
|
146
180
|
forget: dropSession,
|
|
181
|
+
statusFor: (sessionID, audience) => cache.get(`${sessionID}\0${audience}`)?.status,
|
|
182
|
+
invalidateAll() {
|
|
183
|
+
for (const entry of cache.values()) entry.abort.abort();
|
|
184
|
+
cache.clear();
|
|
185
|
+
},
|
|
186
|
+
dispose() {
|
|
187
|
+
disposed = true;
|
|
188
|
+
for (const entry of cache.values()) entry.abort.abort();
|
|
189
|
+
cache.clear();
|
|
190
|
+
modes.clear();
|
|
191
|
+
},
|
|
147
192
|
};
|
|
148
193
|
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { Info, ToolContext } from "@opencode/plugin/promise/tool";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
|
+
import { isAbsolute, parse, relative, sep } from "node:path";
|
|
6
|
+
import { checkBeadsStore, createGuardRuntime, type GuardRuntime } from "./guard";
|
|
7
|
+
import { PrimeProcessError, runBd } from "./prime";
|
|
8
|
+
|
|
9
|
+
export type ContinuityHost = Pick<Plugin.Context, "location"> & {
|
|
10
|
+
permission: Pick<Plugin.Context["permission"], "assert">;
|
|
11
|
+
session: Pick<Plugin.Context["session"], "get">;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface ContinuityResult {
|
|
15
|
+
operation: Operation;
|
|
16
|
+
target: string | string[] | Record<string, unknown>;
|
|
17
|
+
actor?: string;
|
|
18
|
+
status: "acknowledged" | "not-claimed" | "landed-uncommitted" | "uncertain" | "failed" | "found" | "missing";
|
|
19
|
+
reference?: string;
|
|
20
|
+
value?: string;
|
|
21
|
+
data?: Record<string, unknown>;
|
|
22
|
+
issue?: Record<string, unknown>;
|
|
23
|
+
issues?: Record<string, Record<string, unknown>>;
|
|
24
|
+
failed?: Array<{ id: string; detail: string }>;
|
|
25
|
+
records?: Array<Record<string, unknown>>;
|
|
26
|
+
memories?: Record<string, string>;
|
|
27
|
+
view?: string;
|
|
28
|
+
direction?: "down" | "up";
|
|
29
|
+
scope?: Record<string, unknown>;
|
|
30
|
+
limit?: number;
|
|
31
|
+
complete?: boolean | null;
|
|
32
|
+
detail?: string;
|
|
33
|
+
commitIndeterminate?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ContinuityDependencies {
|
|
37
|
+
/** Override the normal bounded runner in tests. */
|
|
38
|
+
bd?: typeof runBd;
|
|
39
|
+
readText?: GuardRuntime["readText"];
|
|
40
|
+
realpath?: (path: string) => Promise<string>;
|
|
41
|
+
acknowledged?: (sessionID: string, result: ContinuityResult) => void;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Audit attribution from host invocation identity; not authentication or an assignment handoff. */
|
|
46
|
+
export function executorActor(context: { sessionID: string; agent: string }): string {
|
|
47
|
+
if ([context.sessionID, context.agent].some((value) => typeof value !== "string" || !value.trim() || value.includes("\0"))) {
|
|
48
|
+
throw new Error("Invocation executor identity unavailable; write withheld");
|
|
49
|
+
}
|
|
50
|
+
const label = context.agent.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 24) || "agent";
|
|
51
|
+
const suffix = createHash("sha256").update(context.sessionID).update("\0").update(context.agent).digest("hex").slice(0, 12);
|
|
52
|
+
return `opencode:${label}:${suffix}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Operation = "find" | "get" | "edges" | "memories" | "recall" | "claim" | "note" | "update" | "close" | "create" | "remember";
|
|
56
|
+
type Data = Record<string, unknown>;
|
|
57
|
+
type JS = import("effect").JsonSchema.JsonSchema;
|
|
58
|
+
const text: JS = { type: "string", minLength: 1, maxLength: 64_000 };
|
|
59
|
+
const token: JS = { ...text, pattern: "^[a-zA-Z0-9][a-zA-Z0-9._:-]*$" };
|
|
60
|
+
const strings: JS = { type: "array", items: token, minItems: 1, maxItems: 25, uniqueItems: true };
|
|
61
|
+
const status: JS = { enum: ["open", "in_progress", "blocked", "deferred"] };
|
|
62
|
+
const priority: JS = { type: "integer", minimum: 0, maximum: 4 };
|
|
63
|
+
const obj = (properties: Record<string, JS>, required = Object.keys(properties)): JS => ({ type: "object", properties, required, additionalProperties: false });
|
|
64
|
+
const issue: JS = { type: "object", properties: { id: token, title: text, status: { type: "string" }, priority, issue_type: text, assignee: { type: "string" }, created_by: { type: "string" }, owner: { type: "string" }, notes: { type: "string" }, acceptance_criteria: { type: "string" }, design: { type: "string" }, labels: { type: "array", items: { type: "string" } } }, required: ["id"], additionalProperties: true };
|
|
65
|
+
const summary: JS = obj({ id: token, title: text, status: text, priority, type: text, assignee: { type: ["string", "null"] } }, ["id", "title", "status", "priority", "type"]);
|
|
66
|
+
const complete: JS = { type: ["boolean", "null"] };
|
|
67
|
+
const limit: JS = { type: "integer", minimum: 1, maximum: 50, default: 20 };
|
|
68
|
+
const query = { parent: token, assignee: text, labels: strings, limit };
|
|
69
|
+
const setSchema = obj({ status, priority, addLabels: strings, removeLabels: strings }, []);
|
|
70
|
+
const createSchema = obj({ title: text, type: { enum: ["bug", "feature", "task", "epic", "chore"] }, priority, description: text, acceptance: text, design: text, parent: token, discoveredFrom: token, labels: strings }, ["title", "type", "priority"]);
|
|
71
|
+
|
|
72
|
+
function record(value: unknown): value is Data {
|
|
73
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
}
|
|
75
|
+
/** Validate the declared issue fields; preserve other CLI fields without inventing values. */
|
|
76
|
+
function isIssue(value: unknown): value is Data & { id: string } {
|
|
77
|
+
if (!record(value) || typeof value.id !== "string" || value.id.length > 64_000 || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(value.id)) return false;
|
|
78
|
+
for (const key of ["title", "status", "issue_type", "assignee", "created_by", "owner", "notes", "acceptance_criteria", "design"]) {
|
|
79
|
+
if (value[key] !== undefined && typeof value[key] !== "string") return false;
|
|
80
|
+
}
|
|
81
|
+
for (const key of ["title", "issue_type"]) {
|
|
82
|
+
if (typeof value[key] === "string" && (value[key].length === 0 || value[key].length > 64_000)) return false;
|
|
83
|
+
}
|
|
84
|
+
if (value.priority !== undefined && (!Number.isInteger(value.priority) || Number(value.priority) < 0 || Number(value.priority) > 4)) return false;
|
|
85
|
+
if (value.labels !== undefined && (!Array.isArray(value.labels) || !value.labels.every(label => typeof label === "string"))) return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
function stringField(data: Data, key: string, isToken = false): string {
|
|
89
|
+
const value = data[key];
|
|
90
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0") || value.length > 64_000 ||
|
|
91
|
+
(isToken && !/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(value))) throw new Error(`Invalid ${key}`);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
function fields(data: unknown, allowed: readonly string[]): Data {
|
|
95
|
+
if (!record(data)) throw new Error("Expected operation input");
|
|
96
|
+
for (const key of Object.keys(data)) if (!allowed.includes(key)) throw new Error(`Unsupported input: ${key}`);
|
|
97
|
+
// Optional undefined values are omitted in JSON; keep target/content JSON-consistent too.
|
|
98
|
+
return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
|
|
99
|
+
}
|
|
100
|
+
function list(data: Data, key: string): string[] {
|
|
101
|
+
const value = data[key];
|
|
102
|
+
if (!Array.isArray(value) || !value.length || value.length > 25 || new Set(value).size !== value.length) throw new Error(`Invalid ${key}`);
|
|
103
|
+
return value.map(v => stringField({ [key]: v }, key, true));
|
|
104
|
+
}
|
|
105
|
+
function validate(operation: Operation, input: unknown): Data {
|
|
106
|
+
const allowed: Record<Operation, string[]> = {
|
|
107
|
+
find: ["view", "parent", "assignee", "status", "labels", "text", "limit"], get: ["ids"], edges: ["id", "direction", "types"], memories: ["search"], recall: ["key"], claim: ["id"], note: ["id", "text"], update: ["id", "set"], close: ["id", "reason"], create: ["title", "type", "priority", "description", "acceptance", "design", "parent", "discoveredFrom", "labels"], remember: ["key", "text"],
|
|
108
|
+
};
|
|
109
|
+
const d = fields(input, allowed[operation]);
|
|
110
|
+
if (["edges", "claim", "note", "update", "close"].includes(operation)) stringField(d, "id", true);
|
|
111
|
+
if (["recall", "remember"].includes(operation)) stringField(d, "key", true);
|
|
112
|
+
if (["remember", "note"].includes(operation)) stringField(d, "text");
|
|
113
|
+
if (operation === "close") stringField(d, "reason");
|
|
114
|
+
if (operation === "get") list(d, "ids");
|
|
115
|
+
if (operation === "memories" && d.search !== undefined) stringField(d, "search");
|
|
116
|
+
if (operation === "edges") {
|
|
117
|
+
if (!["down", "up"].includes(String(d.direction))) throw new Error("Invalid direction");
|
|
118
|
+
// One qualified CLI --type per operation; arbitrary dependency types remain CLI-owned.
|
|
119
|
+
if (d.types !== undefined && (list(d, "types").length !== 1)) throw new Error("Expected one dependency type");
|
|
120
|
+
}
|
|
121
|
+
if (operation === "find") {
|
|
122
|
+
if (!["ready", "list", "search", "blocked"].includes(String(d.view))) throw new Error("Invalid view");
|
|
123
|
+
if (d.limit !== undefined && (!Number.isInteger(d.limit) || Number(d.limit) < 1 || Number(d.limit) > 50)) throw new Error("Invalid limit");
|
|
124
|
+
if (d.view === "search") stringField(d, "text");
|
|
125
|
+
else if (d.text !== undefined) throw new Error("text requires search");
|
|
126
|
+
if (d.parent !== undefined) { stringField(d, "parent", true); if (d.view === "search") throw new Error("parent unsupported for search"); }
|
|
127
|
+
if (d.assignee !== undefined) { stringField(d, "assignee"); if (d.view === "blocked") throw new Error("assignee unsupported for blocked"); }
|
|
128
|
+
if (d.status !== undefined && (!["list", "search"].includes(String(d.view)) || !["open", "in_progress", "blocked", "deferred", "closed"].includes(String(d.status)))) throw new Error("Invalid status filter");
|
|
129
|
+
if (d.labels !== undefined) list(d, "labels");
|
|
130
|
+
}
|
|
131
|
+
if (operation === "update") {
|
|
132
|
+
const set = fields(d.set, ["status", "priority", "addLabels", "removeLabels"]);
|
|
133
|
+
if (!Object.values(set).some(v => v !== undefined)) throw new Error("Empty update");
|
|
134
|
+
if (set.status !== undefined && !["open", "in_progress", "blocked", "deferred"].includes(String(set.status))) throw new Error("Invalid update status; use close separately");
|
|
135
|
+
if (set.priority !== undefined && (!Number.isInteger(set.priority) || Number(set.priority) < 0 || Number(set.priority) > 4)) throw new Error("Invalid priority");
|
|
136
|
+
for (const k of ["addLabels", "removeLabels"]) if (set[k] !== undefined) list(set, k);
|
|
137
|
+
if ((set.addLabels as string[] | undefined)?.some(v => (set.removeLabels as string[] | undefined)?.includes(v))) throw new Error("Conflicting label update");
|
|
138
|
+
}
|
|
139
|
+
if (operation === "create") {
|
|
140
|
+
stringField(d, "title");
|
|
141
|
+
if (!["bug", "feature", "task", "epic", "chore"].includes(String(d.type))) throw new Error("Invalid issue type");
|
|
142
|
+
if (!Number.isInteger(d.priority) || Number(d.priority) < 0 || Number(d.priority) > 4) throw new Error("Invalid priority");
|
|
143
|
+
for (const k of ["description", "acceptance", "design", "parent", "discoveredFrom"]) if (d[k] !== undefined) stringField(d, k, ["parent", "discoveredFrom"].includes(k));
|
|
144
|
+
if (d.labels !== undefined) list(d, "labels");
|
|
145
|
+
}
|
|
146
|
+
return d;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function argv(operation: Operation, d: Data): string[][] {
|
|
150
|
+
const flags = (value: Data, mapping: Record<string, string>): string[] => Object.entries(mapping).flatMap(([key, flag]) => value[key] === undefined ? [] : [flag, String(value[key])]);
|
|
151
|
+
switch (operation) {
|
|
152
|
+
case "get": return (d.ids as string[]).map(id => ["show", id, "--json"]);
|
|
153
|
+
case "find": return [[String(d.view), ...flags(d, { parent: "--parent", assignee: "--assignee", status: "--status" }), ...(d.labels ? ["--label", (d.labels as string[]).join(",")] : []), ...(d.view === "blocked" ? [] : ["--limit", String(Number(d.limit ?? 20) + 1)]), "--json", ...(d.view === "search" ? ["--", String(d.text)] : [])]];
|
|
154
|
+
case "edges": return [["dep", "list", String(d.id), "--direction", String(d.direction), ...(d.types ? ["--type", String((d.types as string[])[0])] : []), "--json"]];
|
|
155
|
+
case "claim": return [["update", String(d.id), "--claim", "--json"]];
|
|
156
|
+
case "note": return [["update", String(d.id), "--append-notes", String(d.text), "--json"]];
|
|
157
|
+
case "update": { const set = d.set as Data; return [["update", String(d.id), ...flags(set, { status: "--status", priority: "--priority" }), ...["addLabels", "removeLabels"].flatMap(k => (set[k] as string[] | undefined)?.flatMap(v => [k === "addLabels" ? "--add-label" : "--remove-label", v]) ?? []), "--json"]]; }
|
|
158
|
+
case "close": return [["close", String(d.id), "--reason", String(d.reason), "--json"]];
|
|
159
|
+
case "create": return [["create", "--title", String(d.title), "--type", String(d.type), "--priority", String(d.priority), ...flags(d, { description: "--description", acceptance: "--acceptance", design: "--design", parent: "--parent" }), ...(d.discoveredFrom ? ["--deps", `discovered-from:${d.discoveredFrom}`] : []), ...(d.labels ? ["--labels", (d.labels as string[]).join(",")] : []), "--json"]];
|
|
160
|
+
case "memories": return [["memories", "--json", "--", ...(d.search === undefined ? [] : [String(d.search)])]];
|
|
161
|
+
case "recall": return [["recall", String(d.key), "--json"]];
|
|
162
|
+
case "remember": return [["remember", "--key", String(d.key), "--json", "--", String(d.text)]];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function outputFor(operation: Operation, write: boolean): JS {
|
|
167
|
+
const base: Record<string, JS> = { operation: { const: operation }, target: operation === "get" ? strings : operation === "find" || operation === "create" || operation === "memories" ? { type: "object", additionalProperties: true } : token, ...(write ? { actor: text } : {}), detail: { type: "string" }, reference: token };
|
|
168
|
+
const requiredBase = ["operation", "target", ...(write ? ["actor"] : [])];
|
|
169
|
+
const branch = (value: string, extra: Record<string, JS> = {}, required: string[] = []): JS => obj({ ...base, status: { const: value }, ...extra }, [...requiredBase, "status", ...required]);
|
|
170
|
+
const readFailure = branch("failed", {}, ["detail"]);
|
|
171
|
+
const uncertainty = branch("uncertain", {}, ["detail"]);
|
|
172
|
+
switch (operation) {
|
|
173
|
+
case "find": return { anyOf: [branch("acknowledged", { view: { enum: ["ready", "list", "search", "blocked"] }, records: { type: "array", items: summary }, scope: { type: "object", additionalProperties: true }, limit, complete }, ["view", "records", "scope", "limit", "complete"]), readFailure] };
|
|
174
|
+
case "get": return { anyOf: [branch("acknowledged", { issues: { type: "object", additionalProperties: issue }, failed: { type: "array", items: obj({ id: token, detail: text }) }, complete: { type: "boolean" } }, ["issues", "failed", "complete"]), readFailure] };
|
|
175
|
+
case "edges": return { anyOf: [branch("acknowledged", { records: { type: "array", items: { ...issue, required: ["id", "dependency_type"], properties: { dependency_type: text }, additionalProperties: true } }, direction: { enum: ["down", "up"] }, scope: { type: "object", additionalProperties: true }, complete }, ["records", "direction", "scope", "complete"]), readFailure] };
|
|
176
|
+
case "memories": return { anyOf: [branch("found", { memories: { type: "object", additionalProperties: { type: "string" } } }, ["memories"]), readFailure] };
|
|
177
|
+
case "recall": return { anyOf: [branch("found", { value: { type: "string" } }, ["reference", "value"]), branch("missing", {}, ["reference"]), readFailure] };
|
|
178
|
+
case "remember": { const data = { data: { type: "object", additionalProperties: true } as JS, commitIndeterminate: { type: "boolean" } as JS }; return { anyOf: [branch("acknowledged", data, ["reference", "data", "commitIndeterminate"]), branch("landed-uncommitted", data, ["reference", "data", "commitIndeterminate"]), uncertainty] }; }
|
|
179
|
+
case "claim": return { anyOf: [branch("acknowledged", { issue }, ["reference", "issue"]), branch("not-claimed", {}, ["detail"]), uncertainty] };
|
|
180
|
+
default: return { anyOf: [branch("acknowledged", { issue }, ["reference", "issue"]), uncertainty] };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface OperationDefinition {
|
|
185
|
+
name: Operation;
|
|
186
|
+
write: boolean;
|
|
187
|
+
input: JS;
|
|
188
|
+
output: JS;
|
|
189
|
+
argv(data: Data): string[][];
|
|
190
|
+
normalize(data: Data, stdout: string, actor: string): Partial<ContinuityResult>;
|
|
191
|
+
}
|
|
192
|
+
function normalize(operation: Operation, d: Data, stdout: string, actor: string): Partial<ContinuityResult> {
|
|
193
|
+
const value: unknown = JSON.parse(stdout);
|
|
194
|
+
const fail = () => { throw new Error("Unsupported CLI acknowledgement; inspect current durable state before another write"); };
|
|
195
|
+
if (operation === "memories" && record(value)) {
|
|
196
|
+
const memories = { ...value };
|
|
197
|
+
if (Object.hasOwn(memories, "schema_version") && memories.schema_version !== 1) return fail();
|
|
198
|
+
if (memories.schema_version === 1) delete memories.schema_version;
|
|
199
|
+
if (Object.values(memories).every(v => typeof v === "string")) return { status: "found", memories: memories as Record<string, string> };
|
|
200
|
+
}
|
|
201
|
+
if (operation === "recall" && record(value) && value.key === d.key && typeof value.value === "string" && typeof value.found === "boolean" && (value.found || value.value === "")) return value.found ? { status: "found", reference: String(d.key), value: value.value } : { status: "missing", reference: String(d.key) };
|
|
202
|
+
if (operation === "remember" && record(value) && value.key === d.key && value.value === d.text && ["remembered", "updated"].includes(String(value.action))) return { status: value.committed === false || value.commit_error !== undefined || value.commit_indeterminate === true ? "landed-uncommitted" : "acknowledged", reference: String(d.key), data: value, commitIndeterminate: value.commit_indeterminate === true };
|
|
203
|
+
if (operation === "find" || operation === "edges") {
|
|
204
|
+
if (!Array.isArray(value)) return fail();
|
|
205
|
+
for (const row of value) {
|
|
206
|
+
if (!isIssue(row) || (operation === "edges" ? typeof row.dependency_type !== "string" : typeof row.title !== "string" || typeof row.status !== "string" || !Number.isInteger(row.priority) || typeof row.issue_type !== "string" || (row.assignee !== undefined && row.assignee !== null && typeof row.assignee !== "string"))) return fail();
|
|
207
|
+
}
|
|
208
|
+
const cap = operation === "edges" ? 50 : Number(d.limit ?? 20);
|
|
209
|
+
return operation === "edges" ? { status: "acknowledged", records: value.slice(0, cap), direction: d.direction as "down" | "up", scope: { id: d.id, direction: d.direction, ...(d.types ? { types: d.types } : {}) }, complete: value.length > cap ? false : null } : { status: "acknowledged", view: String(d.view), records: value.slice(0, cap).map(row => ({ id: row.id, title: row.title, status: row.status, priority: row.priority, type: row.issue_type, ...(Object.hasOwn(row, "assignee") ? { assignee: row.assignee } : {}) })), scope: { ...d, limit: cap }, limit: cap, complete: ["blocked", "search"].includes(String(d.view)) ? (value.length > cap ? false : null) : value.length <= cap };
|
|
210
|
+
}
|
|
211
|
+
const row = operation === "create" && record(value) ? value : Array.isArray(value) && value.length === 1 && record(value[0]) ? value[0] : undefined;
|
|
212
|
+
if (!isIssue(row)) return fail();
|
|
213
|
+
if (operation === "get") return row.id === d.id ? { issue: row } : fail();
|
|
214
|
+
if (operation !== "create" && row.id !== d.id) return fail();
|
|
215
|
+
let accepted = false;
|
|
216
|
+
switch (operation) {
|
|
217
|
+
case "claim": accepted = row.assignee === actor && row.status === "in_progress"; break;
|
|
218
|
+
case "note": accepted = typeof row.notes === "string" && row.notes.endsWith(String(d.text)); break;
|
|
219
|
+
case "close": accepted = row.status === "closed"; break;
|
|
220
|
+
case "create": accepted = row.title === d.title && row.issue_type === d.type && row.priority === d.priority && !row.assignee; break;
|
|
221
|
+
case "update":
|
|
222
|
+
accepted = Object.entries(d.set as Data).every(([key, value]) => {
|
|
223
|
+
if (key === "addLabels" || key === "removeLabels") {
|
|
224
|
+
if (!Array.isArray(row.labels)) return false;
|
|
225
|
+
const labels = row.labels;
|
|
226
|
+
return (value as string[]).every(label => key === "addLabels" ? labels.includes(label) : !labels.includes(label));
|
|
227
|
+
}
|
|
228
|
+
return row[key] === value;
|
|
229
|
+
});
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (!accepted) return fail();
|
|
233
|
+
return { status: "acknowledged", reference: row.id, issue: row };
|
|
234
|
+
}
|
|
235
|
+
const inputs: Record<Operation, JS> = {
|
|
236
|
+
find: { anyOf: [obj({ view: { const: "ready" }, ...query }, ["view"]), obj({ view: { const: "list" }, ...query, status: { enum: ["open", "in_progress", "blocked", "deferred", "closed"] } }, ["view"]), obj({ view: { const: "search" }, assignee: text, labels: strings, status: { enum: ["open", "in_progress", "blocked", "deferred", "closed"] }, text, limit }, ["view", "text"]), obj({ view: { const: "blocked" }, parent: token, labels: strings, limit }, ["view"])] },
|
|
237
|
+
get: obj({ ids: strings }), edges: obj({ id: token, direction: { enum: ["down", "up"] }, types: { type: "array", items: token, minItems: 1, maxItems: 1 } }, ["id", "direction"]), memories: obj({ search: text }, []), recall: obj({ key: token }), claim: obj({ id: token }), note: obj({ id: token, text }), update: obj({ id: token, set: { ...setSchema, minProperties: 1 } }), close: obj({ id: token, reason: text }), create: createSchema, remember: obj({ key: token, text }),
|
|
238
|
+
};
|
|
239
|
+
const OPERATIONS: OperationDefinition[] = (Object.keys(inputs) as Operation[]).map(name => {
|
|
240
|
+
const write = ["claim", "note", "update", "close", "create", "remember"].includes(name);
|
|
241
|
+
return { name, write, input: inputs[name], output: outputFor(name, write), argv: d => argv(name, d), normalize: (d, stdout, actor) => normalize(name, d, stdout, actor) };
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** Canonical shell resource only; execution always uses the separate argv array. */
|
|
245
|
+
function shellResource(args: readonly string[]): string {
|
|
246
|
+
return ["bd", ...args].map(arg => /^[a-zA-Z0-9_./:=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(" ");
|
|
247
|
+
}
|
|
248
|
+
function failureDetail(error: unknown): string {
|
|
249
|
+
if (error instanceof PrimeProcessError) return [error.message, error.stderr.trim(), error.stdout.trim()].filter(Boolean).join(": ");
|
|
250
|
+
return error instanceof Error ? error.message : String(error);
|
|
251
|
+
}
|
|
252
|
+
/** Only the observed single-target JSON diagnostic establishes a non-claim. */
|
|
253
|
+
function claimConflict(error: unknown, id: unknown): boolean {
|
|
254
|
+
if (!(error instanceof PrimeProcessError) || error.exitCode !== 1 || error.stdout.trim()) return false;
|
|
255
|
+
try {
|
|
256
|
+
const line = error.stderr.trim().split("\n").at(-1)!;
|
|
257
|
+
const data: unknown = JSON.parse(line);
|
|
258
|
+
return record(data) && data.schema_version === 1 && data.error === "1 of 1 issues failed to update" && Array.isArray(data.failed) && data.failed.length === 1 && record(data.failed[0]) && data.failed[0].id === id && typeof data.failed[0].error === "string" && data.failed[0].error.startsWith("updating issue: issue already claimed: already assigned to ");
|
|
259
|
+
} catch { return false; }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function contains(root: string, target: string): boolean {
|
|
263
|
+
const path = relative(root, target);
|
|
264
|
+
return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fixed N1 definitions using the same runner in production and tests. Ordinary setup must not
|
|
269
|
+
* register these before 1.1 qualification. Beads owns its normal routing and storage semantics.
|
|
270
|
+
*/
|
|
271
|
+
export function createContinuityTools(host: ContinuityHost, deps: ContinuityDependencies = {}): Info[] {
|
|
272
|
+
const bd = deps.bd ?? runBd;
|
|
273
|
+
const canonical = deps.realpath ?? realpath;
|
|
274
|
+
|
|
275
|
+
return OPERATIONS.map((definition): Info => {
|
|
276
|
+
const operation = definition.name;
|
|
277
|
+
return {
|
|
278
|
+
name: operation,
|
|
279
|
+
options: { namespace: "beads", permission: "shell", codemode: true },
|
|
280
|
+
description: `Beads ${operation}: fixed permissioned operation. No new saved grant; ask may prompt again.`,
|
|
281
|
+
input: definition.input,
|
|
282
|
+
output: definition.output,
|
|
283
|
+
async execute(input, context) {
|
|
284
|
+
const data = validate(operation, input);
|
|
285
|
+
const write = definition.write;
|
|
286
|
+
const actor = executorActor(context);
|
|
287
|
+
const commands = definition.argv(data).map(args => {
|
|
288
|
+
const terminator = args.indexOf("--");
|
|
289
|
+
args.splice(terminator < 0 ? args.length : terminator, 0, ...(write ? ["--actor", actor] : []), "--sandbox");
|
|
290
|
+
return args;
|
|
291
|
+
});
|
|
292
|
+
const target = operation === "get" ? data.ids as string[] : operation === "find" || operation === "create" || operation === "memories" ? { ...data } : String(data.id ?? data.key);
|
|
293
|
+
const base = { operation, target, ...(write ? { actor } : {}) };
|
|
294
|
+
const signal = deps.signal ? AbortSignal.any([context.signal, deps.signal]) : context.signal;
|
|
295
|
+
signal.throwIfAborted();
|
|
296
|
+
const options = { signal };
|
|
297
|
+
const runtime = createGuardRuntime((cwd, argv) => bd(cwd, argv, options));
|
|
298
|
+
if (deps.readText) runtime.readText = deps.readText;
|
|
299
|
+
const invocation = { sessionID: context.sessionID, agent: context.agent,
|
|
300
|
+
source: { type: "tool" as const, messageID: context.messageID, id: context.id }, save: [] };
|
|
301
|
+
|
|
302
|
+
async function assertPermission(action: string, resources: string[]): Promise<void> {
|
|
303
|
+
signal.throwIfAborted();
|
|
304
|
+
// The public API owns its prompt and exposes no cancellation endpoint. Stop this
|
|
305
|
+
// invocation on unload/abort; a late approval must never reach process execution.
|
|
306
|
+
await new Promise<void>((resolve, reject) => {
|
|
307
|
+
const onAbort = () => reject(signal.reason);
|
|
308
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
309
|
+
void host.permission.assert({ ...invocation, action, resources }).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
310
|
+
if (signal.aborted) onAbort();
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const directory = host.location.directory;
|
|
315
|
+
async function invocationDirectory(): Promise<string> {
|
|
316
|
+
signal.throwIfAborted();
|
|
317
|
+
const response = await host.session.get({ sessionID: context.sessionID });
|
|
318
|
+
if (response.location.directory !== directory) throw new Error("Session location changed; operation withheld");
|
|
319
|
+
return canonical(directory);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function assertGuard(): Promise<void> {
|
|
323
|
+
const guard = await checkBeadsStore(directory, runtime);
|
|
324
|
+
if (guard.kind !== "ready") throw new Error(`Beads store held: ${guard.kind}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const resolvedDirectory = await invocationDirectory();
|
|
328
|
+
const project = host.location.project.directory;
|
|
329
|
+
// Match shell access for the explicit cwd. Internal Beads redirects, backend selection
|
|
330
|
+
// and issue routing remain CLI-owned; they are not extra plugin permission targets.
|
|
331
|
+
const external = [...new Set([directory, resolvedDirectory].filter((path) =>
|
|
332
|
+
!contains(directory, path) && !(project !== parse(project).root && contains(project, path)))
|
|
333
|
+
.map((path) => `${path.replaceAll("\\", "/").replace(/\/$/, "")}/*`))];
|
|
334
|
+
if (external.length) await assertPermission("external_directory", external);
|
|
335
|
+
await assertGuard();
|
|
336
|
+
await assertPermission("shell", commands.map(shellResource));
|
|
337
|
+
const issues: Record<string, Record<string, unknown>> = {};
|
|
338
|
+
const failed: Array<{ id: string; detail: string }> = [];
|
|
339
|
+
let result: ContinuityResult = { ...base, status: "failed", detail: "No CLI result" };
|
|
340
|
+
for (const args of commands) {
|
|
341
|
+
// Approval never freezes authority. Recheck before EVERY child process, including get.
|
|
342
|
+
signal.throwIfAborted();
|
|
343
|
+
await assertGuard();
|
|
344
|
+
if (await invocationDirectory() !== resolvedDirectory) throw new Error("Invocation directory changed during approval");
|
|
345
|
+
if (executorActor(context) !== actor) throw new Error("Executor changed before spawn");
|
|
346
|
+
signal.throwIfAborted();
|
|
347
|
+
const item = operation === "get" ? { id: args[1] } : data;
|
|
348
|
+
try {
|
|
349
|
+
const normalized = definition.normalize(item, await bd(directory, args, options), actor);
|
|
350
|
+
if (operation === "get") issues[String(item.id)] = normalized.issue!;
|
|
351
|
+
else result = { ...base, ...normalized } as ContinuityResult;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
result = { ...base, status: write ? claimConflict(error, data.id) && operation === "claim" ? "not-claimed" : "uncertain" : "failed", detail: failureDetail(error) };
|
|
354
|
+
if (operation === "get") failed.push({ id: String(item.id), detail: result.detail! });
|
|
355
|
+
if (operation === "recall" && error instanceof PrimeProcessError && error.exitCode === 1) {
|
|
356
|
+
try {
|
|
357
|
+
const missing = definition.normalize(data, error.stdout, actor);
|
|
358
|
+
if (missing.status === "missing") result = { ...base, ...missing } as ContinuityResult;
|
|
359
|
+
} catch { /* Keep original diagnostic; absence is not established. */ }
|
|
360
|
+
}
|
|
361
|
+
if (signal.aborted) {
|
|
362
|
+
// A started write may have landed even when cancellation prevents a normal return.
|
|
363
|
+
if (write) notify(result);
|
|
364
|
+
signal.throwIfAborted();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (operation === "get") result = Object.keys(issues).length ? { ...base, status: "acknowledged", issues, failed, complete: failed.length === 0 } : { ...base, status: "failed", detail: failed.map(f => `${f.id}: ${f.detail}`).join("; ") };
|
|
369
|
+
function notify(value: ContinuityResult): void {
|
|
370
|
+
try { deps.acknowledged?.(context.sessionID, value); }
|
|
371
|
+
catch { value.detail = [value.detail, "Context refresh notification failed; the reported persistence outcome still applies."].filter(Boolean).join("; "); }
|
|
372
|
+
}
|
|
373
|
+
// Sole write acknowledgement; outer execute must not duplicate it.
|
|
374
|
+
notify(result);
|
|
375
|
+
signal.throwIfAborted();
|
|
376
|
+
return { output: result, content: JSON.stringify(result) };
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
}
|