@dsh-cc/subagent-resume-pins 0.5.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @dsh-cc/subagent-resume-pins
2
+
3
+ Pinned resume descriptors ("resume pins") for continuable background subagents.
4
+ Design record: `docs/plans/2026-09-04-subagent-resume-pins.md`.
5
+
6
+ A cold resume of a background child (parent session disposed, then a new Context
7
+ boots over the same persistence root and `send_message` addresses the child)
8
+ restores the harness descriptor's headers — persona, tool filter, model route —
9
+ but drops every other spawn-time `AgentOptions` field, notably the alias-stamped
10
+ `reasoningEffort` and `maxTokens`. This package closes that gap: the spawn path
11
+ pins the child's effective runtime config to disk, and the resume path re-applies
12
+ it — visibly, never silently.
13
+
14
+ ## What it mounts
15
+
16
+ One cordis plugin (`apply`) with:
17
+
18
+ - a **`PinStore`** (atomic per-child `<childId>.json` files under `pinsRoot`),
19
+ published as the `resumePinStore` service so the Task plugin's spawn capture
20
+ shares one cache with the gate and overlay;
21
+ - a **`tools/pre-execute` resume gate** on `send_message` to a pinned child with
22
+ no live Activation: session existence (`PIN_ORPHANED`), pin readability
23
+ (`PIN_UNREADABLE`), workspace existence/identity (`WORKSPACE_MISSING`,
24
+ `WORKSPACE_CHANGED`), definition re-fingerprint (`DEFINITION_CHANGED`), pinned
25
+ tool availability (`PINNED_TOOL_UNAVAILABLE`), and pinned-route availability
26
+ (`SUBAGENT_MODEL_UNAVAILABLE`) — every deny persisted into the pin
27
+ (`resume.state='blocked'`) **before** the deny returns;
28
+ - an **`agent/request` overlay** applying the pinned
29
+ `{provider, model, reasoningEffort, maxTokens}` tuple field-by-field
30
+ (absence included) to every resumed turn, whatever resumed it;
31
+ - **`tools/post-execute`** notice prefixing on `send_message` and
32
+ `resumeState`/`definitionChanged` annotation on `list_agents`;
33
+ - the **`subagents-resume` settings namespace** (kebab-case) with the policy
34
+ knobs `onUnavailableModel`, `onDefinitionChanged`, `onWorkspaceChanged`
35
+ (`resume-with-notice` defaults, `block` opt-in, `route-current` fallback for
36
+ the model route; the always-block conditions have no safe fallback).
37
+
38
+ Zero-op when unmounted: pins are simply unread and behavior is the legacy
39
+ behavior. Only pinned children are affected; a missing pin is a legacy/foreign
40
+ child (pass-through) and same-epoch followups to a live Activation are untouched.
41
+
42
+ ## Composition
43
+
44
+ The cc preset mounts the row (`cc-resume-pins`, inside the `cc-services` isolate
45
+ group) with `pinsRoot: !!js dshHomePath('sessions', 'resume-pins')` — colocated
46
+ with the harness base patch's jsonl session-persistence root — **before**
47
+ `tool-task`, whose spawn capture prefers the shared service store. Standalone
48
+ consumers may instead pass `resumePins: { pinsRoot }` to the Task plugin config.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Definition fingerprints (§4.4): a named agent definition is fingerprinted by
3
+ * hashing the canonical JSON of all recognized parsed frontmatter fields plus
4
+ * the persona body. The rule is parse-level canonicalization — comment and
5
+ * formatting-only markdown edits produce no fingerprint change, while a change
6
+ * to ANY declared field (consumed like `model`/`tools`/persona, or currently
7
+ * inert like `maxTurns`/`effort`) produces one. Discovery metadata
8
+ * (`source`, `baseDir`, `filename`) is deliberately excluded: the same file
9
+ * discovered from a different layer must keep its identity.
10
+ *
11
+ * @module @dsh-cc/subagent-resume-pins/fingerprint
12
+ */
13
+ import type { AgentDefinition } from '@dsh-cc/claude-code-agents';
14
+ /** sha256 hex of `input`, prefixed `sha256:`. */
15
+ export declare function sha256Prefixed(input: string): string;
16
+ /** Stable hash of a persona (system-prompt) string. */
17
+ export declare function personaHash(persona: string): string;
18
+ /**
19
+ * Fingerprint one parsed {@link AgentDefinition} over its content fields:
20
+ * every recognized parsed frontmatter field plus the persona body, in
21
+ * stable key order. Optional fields absent from the definition are simply
22
+ * omitted from the canonical form.
23
+ */
24
+ export declare function definitionFingerprint(def: AgentDefinition): string;
25
+ //# sourceMappingURL=fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.d.ts","sourceRoot":"","sources":["../src/fingerprint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAEjE,iDAAiD;AACjD,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,uDAAuD;AACvD,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAmBlE"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Definition fingerprints (§4.4): a named agent definition is fingerprinted by
3
+ * hashing the canonical JSON of all recognized parsed frontmatter fields plus
4
+ * the persona body. The rule is parse-level canonicalization — comment and
5
+ * formatting-only markdown edits produce no fingerprint change, while a change
6
+ * to ANY declared field (consumed like `model`/`tools`/persona, or currently
7
+ * inert like `maxTurns`/`effort`) produces one. Discovery metadata
8
+ * (`source`, `baseDir`, `filename`) is deliberately excluded: the same file
9
+ * discovered from a different layer must keep its identity.
10
+ *
11
+ * @module @dsh-cc/subagent-resume-pins/fingerprint
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { canonicalJson } from "./pin.js";
15
+ /** sha256 hex of `input`, prefixed `sha256:`. */
16
+ export function sha256Prefixed(input) {
17
+ return `sha256:${createHash('sha256').update(input, 'utf8').digest('hex')}`;
18
+ }
19
+ /** Stable hash of a persona (system-prompt) string. */
20
+ export function personaHash(persona) {
21
+ return sha256Prefixed(persona);
22
+ }
23
+ /**
24
+ * Fingerprint one parsed {@link AgentDefinition} over its content fields:
25
+ * every recognized parsed frontmatter field plus the persona body, in
26
+ * stable key order. Optional fields absent from the definition are simply
27
+ * omitted from the canonical form.
28
+ */
29
+ export function definitionFingerprint(def) {
30
+ const content = {
31
+ agentType: def.agentType,
32
+ whenToUse: def.whenToUse,
33
+ systemPrompt: def.systemPrompt,
34
+ toolRestriction: def.toolRestriction ?? null,
35
+ skills: def.skills ?? null,
36
+ mcpServers: def.mcpServers ?? null,
37
+ hooks: def.hooks ?? null,
38
+ model: def.model ?? null,
39
+ effort: def.effort ?? null,
40
+ permissionMode: def.permissionMode ?? null,
41
+ maxTurns: def.maxTurns ?? null,
42
+ initialPrompt: def.initialPrompt ?? null,
43
+ background: def.background ?? null,
44
+ memory: def.memory ?? null,
45
+ isolation: def.isolation ?? null,
46
+ };
47
+ return sha256Prefixed(canonicalJson(content));
48
+ }
49
+ //# sourceMappingURL=fingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fingerprint.js","sourceRoot":"","sources":["../src/fingerprint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAGxC,iDAAiD;AACjD,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,OAAO,UAAU,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;AAC7E,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO,cAAc,CAAC,OAAO,CAAC,CAAA;AAChC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAAoB;IACxD,MAAM,OAAO,GAAG;QACd,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,YAAY,EAAE,GAAG,CAAC,YAAY;QAC9B,eAAe,EAAE,GAAG,CAAC,eAAe,IAAI,IAAI;QAC5C,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,IAAI;QAC1B,UAAU,EAAE,GAAG,CAAC,UAAU,IAAI,IAAI;QAClC,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,IAAI;QACxB,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,IAAI;QACxB,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,IAAI;QAC1B,cAAc,EAAE,GAAG,CAAC,cAAc,IAAI,IAAI;QAC1C,QAAQ,EAAE,GAAG,CAAC,QAAQ,IAAI,IAAI;QAC9B,aAAa,EAAE,GAAG,CAAC,aAAa,IAAI,IAAI;QACxC,UAAU,EAAE,GAAG,CAAC,UAAU,IAAI,IAAI;QAClC,MAAM,EAAE,GAAG,CAAC,MAAM,IAAI,IAAI;QAC1B,SAAS,EAAE,GAAG,CAAC,SAAS,IAAI,IAAI;KACjC,CAAA;IACD,OAAO,cAAc,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAA;AAC/C,CAAC"}
package/lib/gate.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The PURE resume gate (plan §4.6): re-evaluates one pin against current
3
+ * environment facts on every cold resume. No cordis, no IO of its own — the
4
+ * environment supplies already-probed facts and throwing availability
5
+ * resolvers; the caller (the plugin) persists any deny to the pin BEFORE the
6
+ * decision is returned (durability ordering) and is also responsible for
7
+ * skipping the gate entirely when no pin exists (legacy/foreign child).
8
+ *
9
+ * A persisted `resume.state='blocked'` never short-circuits: policy flips and
10
+ * recovered conditions are authoritative, the stored state is derived.
11
+ *
12
+ * @module @dsh-cc/subagent-resume-pins/gate
13
+ */
14
+ import type { CorruptPin } from './store.ts';
15
+ import type { OverlayTuple, ResumePin } from './pin.ts';
16
+ import type { ResumePolicy } from './policy.ts';
17
+ /** Why a cold resume was denied. The first five always block (no fallback). */
18
+ export type DenyCode = 'PIN_ORPHANED' | 'PIN_UNREADABLE' | 'WORKSPACE_MISSING' | 'PINNED_TOOL_UNAVAILABLE' | 'SUBAGENT_MODEL_UNAVAILABLE' | 'WORKSPACE_CHANGED' | 'DEFINITION_CHANGED' | 'STORE_WRITE_FAILURE';
19
+ /** A resolved call config as the availability preflight returns it. */
20
+ export interface GateResolvedConfig {
21
+ readonly provider: string;
22
+ readonly model: string;
23
+ readonly reasoningEffort?: string | undefined;
24
+ readonly maxTokens?: number | undefined;
25
+ }
26
+ /** The route fields a selector resolution may contribute. */
27
+ export interface GateDetailedRoute {
28
+ readonly via: 'alias' | 'literal' | 'inherit';
29
+ readonly route: {
30
+ readonly provider?: string;
31
+ readonly model?: string;
32
+ readonly reasoningEffort?: string;
33
+ } | undefined;
34
+ }
35
+ /** The probed environment facts + availability resolvers one evaluation uses. */
36
+ export interface GateEnv {
37
+ /** The child has a persisted session (step 0: false → orphaned pin). */
38
+ readonly sessionExists: boolean;
39
+ /** The pinned workspace cwd still exists on disk. */
40
+ readonly cwdExists: boolean;
41
+ /** The CURRENT git identity of the workspace cwd (probe already run). */
42
+ readonly currentGit: {
43
+ readonly gitDir: string;
44
+ readonly gitCommonDir: string;
45
+ readonly branch: string;
46
+ };
47
+ /**
48
+ * Re-fingerprinted definition identity (step 3, named pins only): a current
49
+ * fingerprint string, `'missing'` for a gone/unreadable definition, or
50
+ * `null` when no current information exists (no current information check).
51
+ */
52
+ readonly currentDefinitionFingerprint: string | 'missing' | null;
53
+ /**
54
+ * The calling parent agent's CURRENT route ({@link AgentOptions} subset).
55
+ * The route-current fallback overlays a freshly-resolved selector onto THIS
56
+ * route — never onto the pinned tuple — so parent-route drift is honored.
57
+ */
58
+ readonly currentRoute?: {
59
+ readonly provider?: string;
60
+ readonly model?: string;
61
+ readonly maxTokens?: number;
62
+ };
63
+ /** The current restrictable tool-name universe (step 4). */
64
+ readonly restrictableNames: ReadonlySet<string>;
65
+ /**
66
+ * §4.3 preflight against the LIVE registrations; throws when the provider
67
+ * is unmounted, the route invalid, or a requested control unsupported.
68
+ */
69
+ resolveCallConfig(config: {
70
+ provider: string;
71
+ model: string;
72
+ maxTokens?: number;
73
+ reasoningEffort?: string;
74
+ }): Promise<GateResolvedConfig>;
75
+ /** Atomic provenance re-resolution of a model selector (undefined = inherit). */
76
+ resolveDetailed(selector: string | undefined): GateDetailedRoute;
77
+ }
78
+ /** One gate outcome. Denies carry a stable code and a human reason. */
79
+ export type GateDecision = {
80
+ readonly action: 'pass';
81
+ readonly notices: readonly string[];
82
+ readonly clearBlocked?: boolean;
83
+ readonly overlay?: OverlayTuple;
84
+ } | {
85
+ readonly action: 'deny';
86
+ readonly code: DenyCode;
87
+ readonly reason: string;
88
+ };
89
+ /**
90
+ * Evaluate the gate steps 0-5 for one pin (or an unreadable one — corrupt
91
+ * pins deny before any environment probe). Every deny the caller persists as
92
+ * `resume.state='blocked'`; an all-passing evaluation clears it.
93
+ * @param pin - the read pin (a {@link CorruptPin} denies `PIN_UNREADABLE`).
94
+ * @param env - probed environment facts and availability resolvers.
95
+ * @param policy - the LIVE policy read at evaluation time.
96
+ */
97
+ export declare function evaluateGate(pin: ResumePin | CorruptPin, env: GateEnv, policy: ResumePolicy): Promise<GateDecision>;
98
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"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"}
package/lib/gate.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The PURE resume gate (plan §4.6): re-evaluates one pin against current
3
+ * environment facts on every cold resume. No cordis, no IO of its own — the
4
+ * environment supplies already-probed facts and throwing availability
5
+ * resolvers; the caller (the plugin) persists any deny to the pin BEFORE the
6
+ * decision is returned (durability ordering) and is also responsible for
7
+ * skipping the gate entirely when no pin exists (legacy/foreign child).
8
+ *
9
+ * A persisted `resume.state='blocked'` never short-circuits: policy flips and
10
+ * recovered conditions are authoritative, the stored state is derived.
11
+ *
12
+ * @module @dsh-cc/subagent-resume-pins/gate
13
+ */
14
+ /** Field-by-field tuple comparison WITH absence semantics (`null` = absent). */
15
+ function tupleDrift(pinned, current) {
16
+ if (current.provider !== pinned.provider)
17
+ return `provider ${pinned.provider} -> ${current.provider}`;
18
+ if (current.model !== pinned.model)
19
+ return `model ${pinned.model} -> ${current.model}`;
20
+ if ((current.reasoningEffort ?? null) !== pinned.reasoningEffort) {
21
+ return `reasoningEffort ${JSON.stringify(pinned.reasoningEffort)} -> ${JSON.stringify(current.reasoningEffort ?? null)}`;
22
+ }
23
+ if ((current.maxTokens ?? null) !== pinned.maxTokens) {
24
+ return `maxTokens ${JSON.stringify(pinned.maxTokens)} -> ${JSON.stringify(current.maxTokens ?? null)}`;
25
+ }
26
+ return undefined;
27
+ }
28
+ /** Re-resolution of the pinned route: throws when unavailable. */
29
+ function resolvePinned(env, pin) {
30
+ return env.resolveCallConfig({
31
+ provider: pin.effective.provider,
32
+ model: pin.effective.model,
33
+ ...(pin.effective.reasoningEffort !== null ? { reasoningEffort: pin.effective.reasoningEffort } : {}),
34
+ ...(pin.effective.maxTokens !== null ? { maxTokens: pin.effective.maxTokens } : {}),
35
+ });
36
+ }
37
+ /** Alias drift: the selector no longer resolves (to the pinned model) via alias. */
38
+ function aliasDrift(env, pin) {
39
+ if (pin.modelSelector.via !== 'alias')
40
+ return undefined;
41
+ const detailed = env.resolveDetailed(pin.modelSelector.raw);
42
+ if (detailed.via !== 'alias' || detailed.route?.model === undefined) {
43
+ return `alias "${pin.modelSelector.raw}" no longer resolves`;
44
+ }
45
+ if (detailed.route.model !== pin.effective.model) {
46
+ return `alias "${pin.modelSelector.raw}" now resolves to "${detailed.route.model}"`;
47
+ }
48
+ if (detailed.route.provider !== undefined && detailed.route.provider !== pin.effective.provider) {
49
+ return `alias "${pin.modelSelector.raw}" now routes to provider "${detailed.route.provider}"`;
50
+ }
51
+ return undefined;
52
+ }
53
+ /**
54
+ * The route-current fallback (§4.6 step 5): resolve the selector fresh via
55
+ * `resolveDetailed`, overlay the resolution onto the CURRENT parent route
56
+ * (`env.currentRoute` — never the pinned tuple), and preflight the complete
57
+ * tuple atomically. Alias drift detection is unchanged; the fallback route
58
+ * itself is always the current one, so an alias that no longer resolves falls
59
+ * back to the parent's current default route rather than the pinned tuple.
60
+ * An unresolvable current route (no parent route on record, or a preflight
61
+ * failure) resolves `undefined`.
62
+ */
63
+ async function currentTuple(env, pin) {
64
+ const base = { ...env.currentRoute };
65
+ const detailed = env.resolveDetailed(pin.modelSelector.via === 'inherit' ? undefined : pin.modelSelector.raw);
66
+ const route = detailed.route;
67
+ if (route?.model !== undefined)
68
+ base.model = route.model;
69
+ if (route?.provider !== undefined)
70
+ base.provider = route.provider;
71
+ if (route?.reasoningEffort !== undefined)
72
+ base.reasoningEffort = route.reasoningEffort;
73
+ if (base.provider === undefined || base.model === undefined)
74
+ return undefined;
75
+ try {
76
+ const resolved = await env.resolveCallConfig({
77
+ provider: base.provider,
78
+ model: base.model,
79
+ ...(base.reasoningEffort !== undefined ? { reasoningEffort: base.reasoningEffort } : {}),
80
+ ...(base.maxTokens !== undefined ? { maxTokens: base.maxTokens } : {}),
81
+ });
82
+ return {
83
+ provider: resolved.provider,
84
+ model: resolved.model,
85
+ reasoningEffort: resolved.reasoningEffort ?? null,
86
+ maxTokens: resolved.maxTokens ?? null,
87
+ };
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /**
94
+ * Evaluate the gate steps 0-5 for one pin (or an unreadable one — corrupt
95
+ * pins deny before any environment probe). Every deny the caller persists as
96
+ * `resume.state='blocked'`; an all-passing evaluation clears it.
97
+ * @param pin - the read pin (a {@link CorruptPin} denies `PIN_UNREADABLE`).
98
+ * @param env - probed environment facts and availability resolvers.
99
+ * @param policy - the LIVE policy read at evaluation time.
100
+ */
101
+ export async function evaluateGate(pin, env, policy) {
102
+ // Step 1 (readability — probed before anything else: a corrupt pin has no
103
+ // fields to evaluate against, and fail-closed beats every notice path).
104
+ if ('kind' in pin) {
105
+ return { action: 'deny', code: 'PIN_UNREADABLE', reason: `[PIN_UNREADABLE] resume pin is unreadable (${pin.reason}); refusing to resume` };
106
+ }
107
+ const resumedPin = pin;
108
+ const notices = [];
109
+ // Step 0: the persisted session must exist — otherwise the pin is an
110
+ // orphan of an aborted spawn and the id is unaddressable.
111
+ if (!env.sessionExists) {
112
+ return { action: 'deny', code: 'PIN_ORPHANED', reason: '[PIN_ORPHANED] no persisted session exists for this pinned child; the spawn must have been aborted' };
113
+ }
114
+ // Step 2: workspace. A missing cwd has no fallback; a changed canonical
115
+ // repo identity is a policy; branch-only drift is a notice either way.
116
+ if (!env.cwdExists) {
117
+ return { action: 'deny', code: 'WORKSPACE_MISSING', reason: `[WORKSPACE_MISSING] pinned workspace ${resumedPin.workspace.cwd} no longer exists; there is no relocatable fallback` };
118
+ }
119
+ const repoIdentityChanged = env.currentGit.gitDir !== resumedPin.workspace.gitDir
120
+ || env.currentGit.gitCommonDir !== resumedPin.workspace.gitCommonDir;
121
+ if (repoIdentityChanged) {
122
+ if (policy.onWorkspaceChanged === 'block') {
123
+ return { action: 'deny', code: 'WORKSPACE_CHANGED', reason: `[WORKSPACE_CHANGED] workspace repository identity changed since spawn (pinned ${resumedPin.workspace.gitDir}/${resumedPin.workspace.gitCommonDir}, current ${env.currentGit.gitDir}/${env.currentGit.gitCommonDir}); policy onWorkspaceChanged=block` };
124
+ }
125
+ notices.push('resumed after the workspace repository identity changed (branch/worktree re-provisioned); continuing in the current workspace');
126
+ }
127
+ if (env.currentGit.branch !== resumedPin.workspace.branch) {
128
+ notices.push(`workspace branch changed since spawn (pinned ${resumedPin.workspace.branch}, current ${env.currentGit.branch}); continuing on the current branch`);
129
+ }
130
+ // Step 3: definition identity (named pins only). The child keeps its
131
+ // PINNED persona/tool filter — the harness descriptor restores those, so
132
+ // nothing is re-guessed even when the file changed.
133
+ if (resumedPin.definition.kind === 'named' && env.currentDefinitionFingerprint !== null) {
134
+ if (env.currentDefinitionFingerprint !== resumedPin.definition.fingerprint) {
135
+ if (policy.onDefinitionChanged === 'block') {
136
+ return { action: 'deny', code: 'DEFINITION_CHANGED', reason: `[DEFINITION_CHANGED] definition "${resumedPin.definition.agentType}" changed since spawn and policy onDefinitionChanged=block` };
137
+ }
138
+ notices.push('resumed with changed definition (pinned persona retained)');
139
+ }
140
+ }
141
+ // Step 4: every pinned filter name must still be restrictable — pruning an
142
+ // allow entry shrinks capability, pruning a deny entry widens permissions.
143
+ const missing = [...resumedPin.toolFilter.allow, ...resumedPin.toolFilter.deny]
144
+ .filter(name => !env.restrictableNames.has(name));
145
+ if (missing.length > 0) {
146
+ return { action: 'deny', code: 'PINNED_TOOL_UNAVAILABLE', reason: `[PINNED_TOOL_UNAVAILABLE] pinned tool filter names tools this composition no longer knows: ${missing.join(', ')}` };
147
+ }
148
+ // Step 5: model/route availability & drift.
149
+ const unavailable = (detail) => `[SUBAGENT_MODEL_UNAVAILABLE] pinned route ${resumedPin.effective.provider}/${resumedPin.effective.model} is no longer available (${detail}); unblock with subagents-resume.onUnavailableModel: 'route-current'`;
150
+ if (resumedPin.effective.complete) {
151
+ let resolved;
152
+ try {
153
+ resolved = await resolvePinned(env, pin);
154
+ }
155
+ catch (error) {
156
+ const detail = error.message;
157
+ if (policy.onUnavailableModel === 'block') {
158
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(detail) };
159
+ }
160
+ const overlay = await currentTuple(env, pin);
161
+ if (overlay === undefined) {
162
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(`${detail}; the route-current fallback is unavailable too`) };
163
+ }
164
+ notices.push(`original model ${resumedPin.effective.provider}/${resumedPin.effective.model} unavailable; resumed with current default route ${overlay.provider}/${overlay.model} per policy`);
165
+ return finish({ overlay });
166
+ }
167
+ const drift = tupleDrift(resumedPin.effective, resolved) ?? aliasDrift(env, pin);
168
+ if (drift === undefined)
169
+ return finish();
170
+ if (policy.onUnavailableModel === 'block') {
171
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(drift) };
172
+ }
173
+ const overlay = await currentTuple(env, pin);
174
+ if (overlay === undefined) {
175
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(`${drift}; the route-current fallback is unavailable too`) };
176
+ }
177
+ notices.push(`original model ${resumedPin.effective.provider}/${resumedPin.effective.model} unavailable; resumed with current default route ${overlay.provider}/${overlay.model} per policy`);
178
+ return finish({ overlay });
179
+ }
180
+ // Degraded pin: only provider-mounted and alias-drift are checkable.
181
+ try {
182
+ await resolvePinned(env, pin);
183
+ }
184
+ catch (error) {
185
+ const detail = error.message;
186
+ if (policy.onUnavailableModel === 'block') {
187
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(detail) };
188
+ }
189
+ const overlay = await currentTuple(env, pin);
190
+ if (overlay === undefined) {
191
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(`${detail}; the route-current fallback is unavailable too`) };
192
+ }
193
+ notices.push(`original model ${resumedPin.effective.provider}/${resumedPin.effective.model} unavailable; resumed with current default route ${overlay.provider}/${overlay.model} per policy`);
194
+ return finish({ overlay });
195
+ }
196
+ const drift = aliasDrift(env, pin);
197
+ if (drift !== undefined) {
198
+ if (policy.onUnavailableModel === 'block') {
199
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(drift) };
200
+ }
201
+ const overlay = await currentTuple(env, pin);
202
+ if (overlay === undefined) {
203
+ return { action: 'deny', code: 'SUBAGENT_MODEL_UNAVAILABLE', reason: unavailable(`${drift}; the route-current fallback is unavailable too`) };
204
+ }
205
+ notices.push(`original model ${resumedPin.effective.provider}/${resumedPin.effective.model} unavailable; resumed with current default route ${overlay.provider}/${overlay.model} per policy`);
206
+ return finish({ overlay });
207
+ }
208
+ return finish();
209
+ function finish(extra = {}) {
210
+ return {
211
+ action: 'pass',
212
+ notices,
213
+ ...resumedPin.resume.state === 'blocked' ? { clearBlocked: true } : {},
214
+ ...extra.overlay !== undefined ? { overlay: extra.overlay } : {},
215
+ };
216
+ }
217
+ }
218
+ //# sourceMappingURL=gate.js.map