@devspecai/pi-plugin 0.0.0-stage → 0.10.3

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +899 -0
  2. package/LICENSE +21 -0
  3. package/README.md +103 -2
  4. package/extensions/.gitkeep +0 -0
  5. package/extensions/core/activation-recovery.ts +135 -0
  6. package/extensions/core/active-deliveries.ts +171 -0
  7. package/extensions/core/active-session-plans.ts +374 -0
  8. package/extensions/core/adapter-overlap.ts +247 -0
  9. package/extensions/core/assistant-speech.ts +44 -0
  10. package/extensions/core/attach-context.ts +25 -0
  11. package/extensions/core/attachment-limits.ts +20 -0
  12. package/extensions/core/attachments.ts +487 -0
  13. package/extensions/core/auth-store.ts +91 -0
  14. package/extensions/core/automation-run-retry.ts +31 -0
  15. package/extensions/core/background-journal.ts +85 -0
  16. package/extensions/core/background-observer.ts +279 -0
  17. package/extensions/core/bond-recovery.ts +65 -0
  18. package/extensions/core/bond-state.ts +80 -0
  19. package/extensions/core/clear-conversation.ts +53 -0
  20. package/extensions/core/client-versions.ts +56 -0
  21. package/extensions/core/client.ts +704 -0
  22. package/extensions/core/command-offer.ts +88 -0
  23. package/extensions/core/command-runner.ts +62 -0
  24. package/extensions/core/command-turn.ts +88 -0
  25. package/extensions/core/commit-ledger.ts +195 -0
  26. package/extensions/core/commit-stamp.ts +98 -0
  27. package/extensions/core/config.ts +63 -0
  28. package/extensions/core/connection-context.ts +28 -0
  29. package/extensions/core/context-budget.ts +175 -0
  30. package/extensions/core/control-result-outbox.ts +80 -0
  31. package/extensions/core/control.ts +156 -0
  32. package/extensions/core/ctx-guard.ts +33 -0
  33. package/extensions/core/debug.ts +136 -0
  34. package/extensions/core/delivery-queue.ts +92 -0
  35. package/extensions/core/dispatch.ts +108 -0
  36. package/extensions/core/duck.ts +128 -0
  37. package/extensions/core/git-observer.ts +382 -0
  38. package/extensions/core/github-identity.ts +26 -0
  39. package/extensions/core/implementation-contract.ts +773 -0
  40. package/extensions/core/instructions.ts +40 -0
  41. package/extensions/core/interaction-events.ts +834 -0
  42. package/extensions/core/json-schema.ts +16 -0
  43. package/extensions/core/local-activity.ts +34 -0
  44. package/extensions/core/mcp-config-guard.ts +117 -0
  45. package/extensions/core/model-catalog.ts +76 -0
  46. package/extensions/core/model-surface.ts +125 -0
  47. package/extensions/core/notice-reply.ts +215 -0
  48. package/extensions/core/owner-inject.ts +148 -0
  49. package/extensions/core/post-session-receipt.ts +70 -0
  50. package/extensions/core/preempt.ts +100 -0
  51. package/extensions/core/project-choice.ts +145 -0
  52. package/extensions/core/project-scope.ts +335 -0
  53. package/extensions/core/project-selection-ui.ts +202 -0
  54. package/extensions/core/provenance-refs.ts +158 -0
  55. package/extensions/core/push-channel.ts +281 -0
  56. package/extensions/core/question-receipt-lifecycle.ts +158 -0
  57. package/extensions/core/recall.ts +121 -0
  58. package/extensions/core/remote-payload.ts +1329 -0
  59. package/extensions/core/remote-session-arg.ts +24 -0
  60. package/extensions/core/repository-context.ts +35 -0
  61. package/extensions/core/request-chain.ts +1244 -0
  62. package/extensions/core/result-projection.ts +939 -0
  63. package/extensions/core/result.ts +100 -0
  64. package/extensions/core/review-context.ts +70 -0
  65. package/extensions/core/review-watch.ts +94 -0
  66. package/extensions/core/room-access.ts +12 -0
  67. package/extensions/core/room-context.ts +178 -0
  68. package/extensions/core/room-reader.ts +308 -0
  69. package/extensions/core/room-store.ts +381 -0
  70. package/extensions/core/room-sync.ts +79 -0
  71. package/extensions/core/room-tools.ts +86 -0
  72. package/extensions/core/room-unread.ts +173 -0
  73. package/extensions/core/session-events.ts +62 -0
  74. package/extensions/core/session-polls.ts +292 -0
  75. package/extensions/core/settings.ts +39 -0
  76. package/extensions/core/status-line.ts +432 -0
  77. package/extensions/core/still-to-discuss.ts +124 -0
  78. package/extensions/core/subagent-fleet.ts +238 -0
  79. package/extensions/core/system-notice.ts +227 -0
  80. package/extensions/core/telemetry.ts +177 -0
  81. package/extensions/core/tool-selection.ts +72 -0
  82. package/extensions/core/trail-events.ts +247 -0
  83. package/extensions/core/trail-projection.ts +373 -0
  84. package/extensions/core/trail.ts +465 -0
  85. package/extensions/core/turn-lifecycle.ts +472 -0
  86. package/extensions/core/version-mismatch.ts +165 -0
  87. package/extensions/core/work-prompt.ts +44 -0
  88. package/extensions/core/work-state.ts +179 -0
  89. package/extensions/devspec-adapter-guard.ts +106 -0
  90. package/extensions/devspec-auth.ts +191 -0
  91. package/extensions/devspec-brand.ts +140 -0
  92. package/extensions/devspec-commit-ledger.ts +477 -0
  93. package/extensions/devspec-context.ts +146 -0
  94. package/extensions/devspec-memory.ts +136 -0
  95. package/extensions/devspec-provenance-assistance.ts +233 -0
  96. package/extensions/devspec-remote.ts +5152 -0
  97. package/extensions/devspec-tools.ts +552 -0
  98. package/extensions/devspec-work.ts +244 -0
  99. package/extensions/generated/README.md +18 -0
  100. package/extensions/generated/mcp-catalog.ts +15811 -0
  101. package/package.json +71 -4
  102. package/prompts/.gitkeep +0 -0
  103. package/runtime/commit-stamp.mjs +279 -0
  104. package/runtime/git-observer-shim.mjs +390 -0
  105. package/skills/.gitkeep +0 -0
  106. package/themes/.gitkeep +0 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DevSpecAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # DevSpec for Pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect the [Pi](https://pi.dev) coding agent to [DevSpec](https://devspec.ai) with a single native package. Once installed and signed in, Pi connects to your DevSpec project automatically when you start it in a repository linked to that project, appears on your project's **Agents** page, works from your tasks, decisions, and documents, and can be steered live from your phone or the web.
4
+
5
+ ## Requirements
6
+
7
+ - [Pi](https://pi.dev) 1.0 or later
8
+ - Node.js 22.19 or later
9
+
10
+ Full commit tracking — every commit reported to DevSpec and tagged with its task automatically — works on macOS, Linux, and Windows, both natively and through WSL. On native Windows, Pi runs its commands in Git Bash, which comes with [Git for Windows](https://git-scm.com/download/win) and is what Pi uses there by default. If Pi's shell is the older WSL `bash.exe` instead, Pi does not see the agent's commits, so the agent adds the task tag itself and DevSpec links the commits to their tasks later.
11
+
12
+ ## Setup
13
+
14
+ ### 1. Install Pi
15
+
16
+ On macOS and Linux:
17
+
18
+ ```bash
19
+ curl -fsSL https://pi.dev/install.sh | sh
20
+ ```
21
+
22
+ On Windows PowerShell:
23
+
24
+ ```powershell
25
+ powershell -c "irm https://pi.dev/install.ps1 | iex"
26
+ ```
27
+
28
+ ### 2. Install the DevSpec package
29
+
30
+ Install the DevSpec package at user level (without `-l`) so every folder has DevSpec:
31
+
32
+ ```bash
33
+ pi install npm:@devspecai/pi-plugin
34
+ ```
35
+
36
+ (If npm is unreachable in your environment, you can also install directly from GitHub with `pi install git:github.com/DevSpecAI/DevSpec-Pi`.)
37
+
38
+ This package provides native DevSpec tools, authentication, project selection, and remote control. No separate DevSpec MCP server entry is needed. (If you previously added DevSpec to Pi's `/mcp` settings, the package disables that duplicate entry so its tools aren't loaded twice.)
39
+
40
+ **Optional recommended extensions** (delegated subagents and web search):
41
+
42
+ ```bash
43
+ pi install npm:pi-subagents
44
+ pi install npm:pi-exa
45
+ ```
46
+
47
+ ### 3. Sign in with your DevSpec token
48
+
49
+ Create an access token with **Read & write** access on your [Agent setup](https://app.devspec.ai/settings/agents) page, then start `pi` and run:
50
+
51
+ ```text
52
+ /devspec.login dvs_your_token_here
53
+ ```
54
+
55
+ Your token is saved locally (`~/.pi/agent/devspec/auth.json`) with owner-only permissions and works across all of your DevSpec projects.
56
+
57
+ ### 4. Start Pi and connect to your project
58
+
59
+ - **Inside a linked repository:** Start `pi` in the folder where you cloned the Git repository linked to your DevSpec project. Pi matches the repository to your project automatically and appears on the project's **Agents** page.
60
+ - **In any other folder, or when switching projects:** Start `pi` and run `/devspec.project` to choose which project this conversation belongs to (or launch with `pi --devspec-project "Client Website"`).
61
+
62
+ ## Commands
63
+
64
+ Everything in DevSpec can be asked in plain words ("Work on DevSpec task 4f2a", "What did we decide about caching?", "Create a task for the login redirect bug"). The package also adds these slash commands in Pi:
65
+
66
+ | Command | What it does |
67
+ |---|---|
68
+ | `/devspec.login <token>` | Signs Pi in to DevSpec with your `dvs_…` access token. |
69
+ | `/devspec.logout` | Removes the stored token from this computer. |
70
+ | `/devspec.status` | Shows whether you're connected, as whom, and to which project and session. |
71
+ | `/devspec.project` | Opens the project menu to inspect or change the project for this conversation or folder. |
72
+ | `/devspec.remote` | Connects this conversation to DevSpec (or pass `--project`, `--session <id>`, `--new`, `--new --private`, or `--name "…"`). |
73
+ | `/devspec.remote-stop` | Disconnects this Pi conversation from DevSpec while leaving other terminals running. |
74
+ | `/devspec.work` | Lists open tasks or claims a specific task by ID or title to start working on it. |
75
+ | `/devspec.pin <project-uuid>` | Writes `.devspec/project.json` in the current folder to pin it to that project. |
76
+
77
+ ## Choosing and remembering a project
78
+
79
+ When a repository belongs to more than one DevSpec project, or you start Pi in a folder without a Git remote, Pi prompts you to pick from your accessible projects.
80
+
81
+ You can also target a project directly:
82
+
83
+ ```text
84
+ pi --devspec-project "Client Website"
85
+ /devspec.remote --project "Client Website"
86
+ /devspec.remote --project <project-id> --session <session-id>
87
+ ```
88
+
89
+ Use `/devspec.project` to manage the current conversation's project and folder default:
90
+
91
+ - `/devspec.project status` shows the selected project, organization, full project ID, how it was selected, and any folder default.
92
+ - `/devspec.project choose` selects a project for this conversation. Switching away from a project you have already been working in starts a fresh local conversation so context from one project never leaks into another.
93
+ - `/devspec.project remember` saves the current project to `.devspec/project.json` in the folder after showing the file path and asking for confirmation.
94
+ - `/devspec.project forget` removes the `.devspec/project.json` folder default after confirmation without disconnecting active conversations.
95
+
96
+ ## Updating
97
+
98
+ To update the package to the latest version, run:
99
+
100
+ ```bash
101
+ pi update
102
+ ```
103
+
104
+ Then restart any open `pi` sessions so the updated package is loaded.
File without changes
@@ -0,0 +1,135 @@
1
+ /**
2
+ * DevSpec Pi — resume/duplicate recovery for deferred tool activation
3
+ * (DevSpec item dc8f52d4).
4
+ *
5
+ * Per-process activation state and the transcript's permanent addedToolNames
6
+ * announcements diverge across a resume: session_start rebuilds the hot set,
7
+ * but every announcement stays in history. Pi's Kimi deferred-tools serializer
8
+ * (openai-completions, deferredToolsMode "kimi") re-injects a full tool
9
+ * definition after EVERY toolResult group in history that announced tools, so
10
+ * a tool announced twice in one transcript reaches the provider wire twice —
11
+ * the request hard-fails ("duplicate tool names are not allowed") and every
12
+ * later turn re-renders the same history, bricking the session. Two defences:
13
+ *
14
+ * 1. scanTranscriptToolState — on session_start, restore exactly the rare
15
+ * tools the current branch already announced (minus recorded recoveries),
16
+ * so the loader reports them "already active" instead of re-announcing.
17
+ * Fresh sessions have announced nothing, so the happy path is untouched.
18
+ * Restoring also keeps Kimi-mode mid-history injections byte-identical
19
+ * across a resume instead of silently re-rendering them.
20
+ * 2. planDuplicateRecovery — if a duplicate-tools provider error still occurs
21
+ * (e.g. a transcript poisoned before this fix), the named tools are
22
+ * deactivated for the session. Kimi-mode injections resolve against the
23
+ * CURRENT tool set, so deactivation clears every copy on the next turn.
24
+ *
25
+ * The serializer-side fix (dedupe injected names per request, subtract called
26
+ * tools) belongs upstream in Pi — see the follow-up item linked from
27
+ * dc8f52d4. This module is the extension-side compensation.
28
+ */
29
+
30
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
31
+
32
+ /** Session-persisted custom entry type recording recovery denials. */
33
+ export const RECOVERY_ENTRY_TYPE = "devspec-tool-recovery";
34
+
35
+ /** Fireworks/OpenAI-compatible rejection observed in the wild (item dc8f52d4). */
36
+ export const DUPLICATE_TOOL_NAMES_PATTERN =
37
+ /duplicate tool names are not allowed:\s*\[([^\]]*)\]/i;
38
+
39
+ /** Parse `['a', 'b']` / `["a"]` name lists out of a provider error message. */
40
+ export function parseDuplicateToolNames(message: string): string[] {
41
+ const match = DUPLICATE_TOOL_NAMES_PATTERN.exec(message);
42
+ if (!match) return [];
43
+ const names: string[] = [];
44
+ for (const part of match[1].matchAll(/'([^']+)'|"([^"]+)"/g)) {
45
+ names.push(part[1] ?? part[2]);
46
+ }
47
+ return names;
48
+ }
49
+
50
+ export interface TranscriptToolState {
51
+ /** Rare tools the branch has announced and not been denied — re-activate these. */
52
+ restore: string[];
53
+ /** Tools a previous recovery deactivated in this session. */
54
+ denied: string[];
55
+ }
56
+
57
+ /**
58
+ * Walk one branch of the transcript. Announcements come from toolResult
59
+ * messages' addedToolNames (stamped by Pi core's tool wrapper); denials come
60
+ * from custom entries written by the recovery path. Both are filtered through
61
+ * `isRare` so only loader-managed verbs are ever restored.
62
+ */
63
+ export function scanTranscriptToolState(
64
+ entries: readonly SessionEntry[],
65
+ isRare: (name: string) => boolean,
66
+ ): TranscriptToolState {
67
+ const announced: string[] = [];
68
+ const announcedSeen = new Set<string>();
69
+ const denied: string[] = [];
70
+ const deniedSeen = new Set<string>();
71
+ for (const entry of entries) {
72
+ if (entry.type === "message") {
73
+ const message = entry.message;
74
+ if (message.role !== "toolResult") continue;
75
+ // Pre-1.0 transcripts may retain this historical field. Pi 1.x owns
76
+ // current tool-state messages; do not assume every tool result has it.
77
+ const legacyNames = (message as typeof message & { addedToolNames?: unknown }).addedToolNames;
78
+ if (!Array.isArray(legacyNames)) continue;
79
+ for (const name of legacyNames) {
80
+ if (typeof name !== "string" || !isRare(name) || announcedSeen.has(name)) continue;
81
+ announcedSeen.add(name);
82
+ announced.push(name);
83
+ }
84
+ } else if (entry.type === "custom" && entry.customType === RECOVERY_ENTRY_TYPE) {
85
+ const list = (entry.data as { denied?: unknown } | undefined)?.denied;
86
+ if (!Array.isArray(list)) continue;
87
+ for (const name of list) {
88
+ if (typeof name !== "string" || deniedSeen.has(name)) continue;
89
+ deniedSeen.add(name);
90
+ denied.push(name);
91
+ }
92
+ }
93
+ }
94
+ return { restore: announced.filter((name) => !deniedSeen.has(name)), denied };
95
+ }
96
+
97
+ /** Append previously announced tools missing from the active set; order-stable. */
98
+ export function withRestoredTools(active: readonly string[], restore: readonly string[]): string[] {
99
+ if (restore.length === 0) return [...active];
100
+ const seen = new Set(active);
101
+ return [...active, ...restore.filter((name) => !seen.has(name))];
102
+ }
103
+
104
+ export interface DuplicateRecoveryPlan {
105
+ names: string[];
106
+ errorMessage: string;
107
+ }
108
+
109
+ /**
110
+ * Decide which tools to deactivate after a duplicate-tools provider error.
111
+ * Only catalog tools that are currently active and not already denied are
112
+ * actionable; a re-fired error whose names are all handled returns null so
113
+ * the recovery path can never loop.
114
+ */
115
+ export function planDuplicateRecovery(
116
+ messages: readonly unknown[],
117
+ opts: {
118
+ isCatalogTool: (name: string) => boolean;
119
+ isActive: (name: string) => boolean;
120
+ isDenied: (name: string) => boolean;
121
+ },
122
+ ): DuplicateRecoveryPlan | null {
123
+ for (let index = messages.length - 1; index >= 0; index--) {
124
+ const errorMessage = (messages[index] as { errorMessage?: unknown } | null | undefined)
125
+ ?.errorMessage;
126
+ if (typeof errorMessage !== "string" || !DUPLICATE_TOOL_NAMES_PATTERN.test(errorMessage)) {
127
+ continue;
128
+ }
129
+ const names = parseDuplicateToolNames(errorMessage).filter(
130
+ (name) => opts.isCatalogTool(name) && opts.isActive(name) && !opts.isDenied(name),
131
+ );
132
+ return names.length > 0 ? { names, errorMessage } : null;
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,171 @@
1
+ import type { ImplementationContractContext, ImplementationKickoff } from "./implementation-contract.ts";
2
+
3
+ /**
4
+ * Which deliveries this Pi process has handed to a turn and not yet seen finish.
5
+ *
6
+ * This was `implementation-mode.ts`, and it installed an execution mode for the
7
+ * duration of a delivery. There is no execution mode (DevSpec item 6d085535):
8
+ * nothing is installed for the duration of a batch and nothing needs clearing when
9
+ * one resolves. What is genuinely worth tracking is narrower and survives the
10
+ * rename — WHICH locally selected items went out with WHICH contract, so the footer
11
+ * can say so and a failed direct send can be rolled back.
12
+ *
13
+ * Plural direct deliveries coexist on purpose, so a lifecycle result only ever
14
+ * touches the delivery holding that item.
15
+ */
16
+
17
+ export interface ActiveDelivery {
18
+ deliveryKey: string;
19
+ taskIds: string[];
20
+ terminalTaskIds: string[];
21
+ contractVersion: string;
22
+ contractHash?: string;
23
+ recordedAt: number;
24
+ generation: number;
25
+ }
26
+
27
+ export interface DeliveryRecord {
28
+ deliveryKey: string;
29
+ generation: number;
30
+ previous?: ActiveDelivery;
31
+ previousIndex: number;
32
+ }
33
+
34
+ interface MutableDeliveryState {
35
+ deliveries: ActiveDelivery[];
36
+ nextGeneration: number;
37
+ }
38
+
39
+ const DELIVERIES_KEY = Symbol.for("devspec.pi.active-deliveries.v2");
40
+
41
+ function state(): MutableDeliveryState {
42
+ const root = globalThis as typeof globalThis & { [DELIVERIES_KEY]?: MutableDeliveryState };
43
+ if (!root[DELIVERIES_KEY]) root[DELIVERIES_KEY] = { deliveries: [], nextGeneration: 1 };
44
+ if (!Number.isFinite(root[DELIVERIES_KEY].nextGeneration)) root[DELIVERIES_KEY].nextGeneration = 1;
45
+ return root[DELIVERIES_KEY];
46
+ }
47
+
48
+ function unique(values: readonly string[]): string[] {
49
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
50
+ }
51
+
52
+ function kickoffKey(kickoff: ImplementationKickoff): string {
53
+ return `items:${[...kickoff.work.task_ids].sort().join(",")}`;
54
+ }
55
+
56
+ function copy(active: ActiveDelivery): ActiveDelivery {
57
+ return {
58
+ ...active,
59
+ taskIds: [...active.taskIds],
60
+ terminalTaskIds: [...active.terminalTaskIds],
61
+ };
62
+ }
63
+
64
+ /** Record exactly one locally selected delivery; a delivery is not connection identity. */
65
+ export function recordActiveDelivery(
66
+ kickoff: ImplementationKickoff,
67
+ context: ImplementationContractContext,
68
+ ): DeliveryRecord {
69
+ const current = state();
70
+ const deliveryKey = kickoffKey(kickoff);
71
+ const previousIndex = current.deliveries.findIndex((entry) => entry.deliveryKey === deliveryKey);
72
+ const previous = previousIndex >= 0 ? copy(current.deliveries[previousIndex]!) : undefined;
73
+ const generation = current.nextGeneration++;
74
+ const taskIds = unique(kickoff.work.task_ids);
75
+ const next: ActiveDelivery = {
76
+ deliveryKey,
77
+ taskIds,
78
+ terminalTaskIds: previous?.terminalTaskIds.filter((id) => taskIds.includes(id)) ?? [],
79
+ contractVersion: context.contractVersion,
80
+ ...(context.contractHash ? { contractHash: context.contractHash } : {}),
81
+ recordedAt: Date.now(),
82
+ generation,
83
+ };
84
+ current.deliveries = [
85
+ ...current.deliveries.filter((entry) => entry.deliveryKey !== deliveryKey),
86
+ next,
87
+ ];
88
+ return {
89
+ deliveryKey,
90
+ generation,
91
+ ...(previous ? { previous } : {}),
92
+ previousIndex,
93
+ };
94
+ }
95
+
96
+ /** Most recently recorded delivery, retained for footer/debug compatibility. */
97
+ export function getLatestActiveDelivery(): ActiveDelivery | null {
98
+ const deliveries = state().deliveries;
99
+ return deliveries.length > 0 ? copy(deliveries[deliveries.length - 1]!) : null;
100
+ }
101
+
102
+ export function listActiveDeliveries(): ActiveDelivery[] {
103
+ return state().deliveries.map(copy);
104
+ }
105
+
106
+ export function clearActiveDelivery(input: { deliveryKey: string }): boolean {
107
+ const current = state();
108
+ if (!input.deliveryKey) return false;
109
+ const before = current.deliveries.length;
110
+ current.deliveries = current.deliveries.filter((entry) => entry.deliveryKey !== input.deliveryKey);
111
+ return current.deliveries.length !== before;
112
+ }
113
+
114
+ export function rollbackActiveDelivery(record: DeliveryRecord): boolean {
115
+ const current = state();
116
+ const activeIndex = current.deliveries.findIndex((entry) =>
117
+ entry.deliveryKey === record.deliveryKey && entry.generation === record.generation
118
+ );
119
+ if (activeIndex < 0) return false;
120
+ current.deliveries.splice(activeIndex, 1);
121
+ if (record.previous) {
122
+ const index = Math.max(0, Math.min(record.previousIndex, current.deliveries.length));
123
+ current.deliveries.splice(index, 0, copy(record.previous));
124
+ }
125
+ return true;
126
+ }
127
+
128
+ function itemIdFrom(value: unknown): string | undefined {
129
+ if (!value || typeof value !== "object") return undefined;
130
+ const root = value as Record<string, unknown>;
131
+ for (const key of ["task_id", "id"]) {
132
+ if (typeof root[key] === "string" && root[key]) return root[key] as string;
133
+ }
134
+ for (const key of ["item", "task", "details"]) {
135
+ const nested = itemIdFrom(root[key]);
136
+ if (nested) return nested;
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * Successful native work-tool results drive the matching delivery's cleanup.
143
+ * Plural local selections coexist; no unrelated delivery can be cleared.
144
+ */
145
+ export function consumeDeliveryLifecycle(
146
+ name: string,
147
+ args: Record<string, unknown> = {},
148
+ result?: unknown,
149
+ ): boolean {
150
+ const current = state();
151
+ if (current.deliveries.length === 0) return false;
152
+
153
+ if (!["record_implementation", "release_work_item", "fail_work_item"].includes(name)) return false;
154
+ const taskId =
155
+ (typeof args.task_id === "string" && args.task_id ? args.task_id : undefined) ??
156
+ itemIdFrom(result);
157
+ if (!taskId) return false;
158
+ const delivery = current.deliveries.find((entry) => entry.taskIds.includes(taskId));
159
+ if (!delivery) return false;
160
+ delivery.terminalTaskIds = unique([...delivery.terminalTaskIds, taskId]);
161
+ if (delivery.taskIds.every((id) => delivery.terminalTaskIds.includes(id))) {
162
+ return clearActiveDelivery({ deliveryKey: delivery.deliveryKey });
163
+ }
164
+ return false;
165
+ }
166
+
167
+ export function resetActiveDeliveries(): void {
168
+ const current = state();
169
+ current.deliveries = [];
170
+ current.nextGeneration = 1;
171
+ }