@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.
- package/CHANGELOG.md +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolveEndpoint } from "./auth-store.ts";
|
|
3
|
+
import { callDevspec, MODEL_TIMEOUT_MS, readDevspecResource } from "./client.ts";
|
|
4
|
+
import { unwrapMcpResult } from "./result.ts";
|
|
5
|
+
import { UPDATE_EXTENSION_INSTRUCTION } from "./version-mismatch.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The product implementation contract, as Pi loads and verifies it.
|
|
9
|
+
*
|
|
10
|
+
* THERE IS NO EXECUTION MODE (DevSpec item 6d085535). DevSpec used to serve two
|
|
11
|
+
* resources — `…/attended` and `…/unattended` — each a projection of one core
|
|
12
|
+
* contract plus a mode overlay, and a delivery named which projection applied.
|
|
13
|
+
* Both are gone: the overlays collapsed into core, the projection went with them,
|
|
14
|
+
* and there is one document at one URI.
|
|
15
|
+
*
|
|
16
|
+
* That removes a whole verification dance as well as a concept. A projection could
|
|
17
|
+
* not authenticate its own parent (`effective_hash` deliberately excluded
|
|
18
|
+
* `contract_hash`), so Pi had to read the compatibility bundle once per
|
|
19
|
+
* authority/version just to confirm the parent hash it had been handed. The served
|
|
20
|
+
* document now hashes its own entire content, so one read verifies itself and the
|
|
21
|
+
* compatibility bundle is only a fallback for a host that cannot read resources.
|
|
22
|
+
*
|
|
23
|
+
* Nothing replaced the mode — no timeout, no patience window, no ask-policy. The
|
|
24
|
+
* rules that used to live in the unattended overlay ("do not assume a human is
|
|
25
|
+
* present", "hand work back truthfully") were never about who was watching, so they
|
|
26
|
+
* are simply core rules now, and the local safety fallback below carries them
|
|
27
|
+
* unconditionally.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const IMPLEMENTATION_CONTRACT_RESOURCE_URI = "devspec://product/implementation-contract";
|
|
31
|
+
|
|
32
|
+
export interface ImplementationContractReference {
|
|
33
|
+
contract_version?: string;
|
|
34
|
+
resource_uri: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ImplementationKickoff {
|
|
38
|
+
kind: "devspec.implementation_kickoff";
|
|
39
|
+
schema_version: 1;
|
|
40
|
+
work: {
|
|
41
|
+
task_ids: string[];
|
|
42
|
+
};
|
|
43
|
+
implementation_contract: {
|
|
44
|
+
contract_version: string;
|
|
45
|
+
resource_uri: string;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ImplementationRule {
|
|
50
|
+
id: string;
|
|
51
|
+
phase?: string;
|
|
52
|
+
requirement: string;
|
|
53
|
+
enforcement?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A served section, kept exactly as DevSpec sent it. */
|
|
57
|
+
export type ServedSection = Readonly<Record<string, unknown>>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Who may change the project's record (DevSpec item ff803a1a). Served beside the
|
|
61
|
+
* work-entry and commit-provenance sections since contract 4.4.0. Pi shows it to the
|
|
62
|
+
* model and relays its proposal outcome, and never restates it in its own prose.
|
|
63
|
+
*
|
|
64
|
+
* Read tolerantly (item 39c05c1f): a role, a class or a rule DevSpec adds reaches the
|
|
65
|
+
* model without a Pi release, and a field Pi does not show is ignored rather than
|
|
66
|
+
* refused.
|
|
67
|
+
*/
|
|
68
|
+
export interface AuthorityContract {
|
|
69
|
+
schema_version: 1;
|
|
70
|
+
classes: Array<{
|
|
71
|
+
class: string;
|
|
72
|
+
meaning: string;
|
|
73
|
+
/** role (or `unattended`) → outcome, in the order DevSpec served them. */
|
|
74
|
+
outcomes: Array<[string, string]>;
|
|
75
|
+
}>;
|
|
76
|
+
proposal_outcome?: { token: string; requirement: string };
|
|
77
|
+
/** The rule texts the model reads, quoted verbatim, in the order shown. */
|
|
78
|
+
rules: Array<{ key: string; text: string }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A section Pi could not read in full — at a schema_version it does not know, or in
|
|
83
|
+
* a shape it does not recognise. It is shown to the model exactly as served, so the
|
|
84
|
+
* model still has DevSpec's words, and the notice says to update the extension.
|
|
85
|
+
*/
|
|
86
|
+
export interface UnreadSection {
|
|
87
|
+
section: string;
|
|
88
|
+
value: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ImplementationContractContext {
|
|
92
|
+
contractVersion: string;
|
|
93
|
+
contractHash?: string;
|
|
94
|
+
resourceUri: string;
|
|
95
|
+
source: "resource" | "compatibility" | "cache" | "local_safety_fallback";
|
|
96
|
+
rules: ImplementationRule[];
|
|
97
|
+
/** Shown to the model exactly as served: Pi only carries it. */
|
|
98
|
+
workEntryContract?: ServedSection;
|
|
99
|
+
/** Carried and shown as served; Pi does not interpret it. */
|
|
100
|
+
commitProvenanceContract?: ServedSection;
|
|
101
|
+
/** Which instruction tier wins, shown as served; Pi does not interpret it. */
|
|
102
|
+
instructionBoundary?: ServedSection;
|
|
103
|
+
authorityContract?: AuthorityContract;
|
|
104
|
+
/** Known sections Pi could not read in full; shown as served. */
|
|
105
|
+
unreadSections?: UnreadSection[];
|
|
106
|
+
/** Rules in the core section Pi could not read (no id or requirement). */
|
|
107
|
+
skippedRules?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Top-level sections this Pi does not implement, present in the document it
|
|
110
|
+
* verified. Not an error — see `parseContractBundle`.
|
|
111
|
+
*/
|
|
112
|
+
unknownSections?: string[];
|
|
113
|
+
warning?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ImplementationContractLoader {
|
|
117
|
+
/** Stable, non-secret authority identity (endpoint + token fingerprint). */
|
|
118
|
+
cacheScope(): Promise<string>;
|
|
119
|
+
readResource(uri: string): Promise<unknown>;
|
|
120
|
+
readCompatibilityBundle(): Promise<unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface CachedContract extends Omit<ImplementationContractContext, "source" | "warning"> {}
|
|
124
|
+
|
|
125
|
+
interface ContractCache {
|
|
126
|
+
byIdentity: Map<string, CachedContract>;
|
|
127
|
+
latestByResource: Map<string, CachedContract>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// v7 added the canonical work-entry v2 tracking/plan boundary; v8 added the
|
|
131
|
+
// authority_contract section (contract 4.4.0); v9 records unknown top-level
|
|
132
|
+
// sections instead of rejecting the document for carrying one; v11 read the
|
|
133
|
+
// one-or-more commit-provenance section (item 96f3dfb7); v12 reads every section
|
|
134
|
+
// on its own and tolerantly, so a section Pi cannot read is carried as served
|
|
135
|
+
// instead of taking the others down (item 39c05c1f). Older parser caches cannot
|
|
136
|
+
// authorize: they hold shapes this generation no longer produces.
|
|
137
|
+
const CACHE_KEY = Symbol.for("devspec.pi.implementation-contract.v12");
|
|
138
|
+
|
|
139
|
+
function cache(): ContractCache {
|
|
140
|
+
const root = globalThis as typeof globalThis & { [CACHE_KEY]?: ContractCache };
|
|
141
|
+
const current = root[CACHE_KEY];
|
|
142
|
+
if (!current || !(current.byIdentity instanceof Map) || !(current.latestByResource instanceof Map)) {
|
|
143
|
+
root[CACHE_KEY] = {
|
|
144
|
+
byIdentity: new Map(),
|
|
145
|
+
latestByResource: new Map(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return root[CACHE_KEY]!;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function cacheIdentity(scope: string, resourceUri: string, contractVersion: string): string {
|
|
152
|
+
return `${scope}\0${resourceUri}\0${contractVersion}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function latestIdentity(scope: string, resourceUri: string): string {
|
|
156
|
+
return `${scope}\0${resourceUri}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
160
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
161
|
+
? value as Record<string, unknown>
|
|
162
|
+
: null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
166
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Contract fields are canonical data: padding is invalid, not normalized. */
|
|
170
|
+
function exactContractString(value: unknown): string | undefined {
|
|
171
|
+
return typeof value === "string" && value.length > 0 && value === value.trim() ? value : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Text Pi shows the model: taken exactly as served, never trimmed or rewritten. */
|
|
175
|
+
function servedText(value: unknown): string | undefined {
|
|
176
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const CONTRACT_HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
180
|
+
const CONTRACT_VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
181
|
+
/**
|
|
182
|
+
* The document's own format. This — not the product's contract_version — is what
|
|
183
|
+
* says whether Pi can read the document at all. DevSpec raises it only when the
|
|
184
|
+
* whole document changes shape; anything less is a change inside a section, which
|
|
185
|
+
* that section's own schema_version describes.
|
|
186
|
+
*/
|
|
187
|
+
const READABLE_ROOT_SCHEMA_VERSIONS = new Set([1]);
|
|
188
|
+
const PRODUCT_SOURCE = {
|
|
189
|
+
authority: "devspec_product",
|
|
190
|
+
tier: "product_implementation_contract",
|
|
191
|
+
} as const;
|
|
192
|
+
|
|
193
|
+
/** The sections Pi knows by name; anything else is an unknown section. */
|
|
194
|
+
const KNOWN_ROOT_KEYS = [
|
|
195
|
+
"kind", "schema_version", "contract_version", "contract_hash", "source", "instruction_boundary",
|
|
196
|
+
"core_implementation_contract", "work_entry_contract", "commit_provenance_contract", "authority_contract",
|
|
197
|
+
] as const;
|
|
198
|
+
|
|
199
|
+
function normalizeForCanonicalJson(value: unknown): unknown {
|
|
200
|
+
if (Array.isArray(value)) return value.map(normalizeForCanonicalJson);
|
|
201
|
+
if (value !== null && typeof value === "object") {
|
|
202
|
+
return Object.fromEntries(
|
|
203
|
+
Object.entries(value as Record<string, unknown>)
|
|
204
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
205
|
+
.map(([key, nested]) => [key, normalizeForCanonicalJson(nested)]),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function canonicalJson(value: unknown): string {
|
|
212
|
+
return JSON.stringify(normalizeForCanonicalJson(value));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function hashContractValue(value: unknown): string {
|
|
216
|
+
return `sha256:${createHash("sha256").update(canonicalJson(value), "utf8").digest("hex")}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Keys the document carries that this parser knows nothing about. */
|
|
220
|
+
function extraKeys(value: Record<string, unknown>, expected: readonly string[]): string[] {
|
|
221
|
+
return Object.keys(value).filter((key) => !expected.includes(key)).sort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A section at a schema_version this Pi has a reader for. A section without one is
|
|
226
|
+
* read as version 1, which is how every section that has never changed is served.
|
|
227
|
+
*/
|
|
228
|
+
function sectionVersionIsReadable(section: Record<string, unknown>, readable: readonly number[]): boolean {
|
|
229
|
+
const version = section.schema_version ?? 1;
|
|
230
|
+
return typeof version === "number" && readable.includes(version);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The core rules. Pi needs an id and the requirement text; phase and enforcement
|
|
235
|
+
* are carried when present, whatever their value, because Pi only shows them. A
|
|
236
|
+
* rule without an id or a requirement is skipped and counted, never the reason
|
|
237
|
+
* every other rule is thrown away.
|
|
238
|
+
*/
|
|
239
|
+
function readRules(value: unknown): { rules: ImplementationRule[]; skipped: number } | null {
|
|
240
|
+
const section = asRecord(value);
|
|
241
|
+
if (!section || !sectionVersionIsReadable(section, [1]) || !Array.isArray(section.rules)) return null;
|
|
242
|
+
const rules: ImplementationRule[] = [];
|
|
243
|
+
let skipped = 0;
|
|
244
|
+
for (const candidate of section.rules) {
|
|
245
|
+
const row = asRecord(candidate);
|
|
246
|
+
const id = row ? servedText(row.id) : undefined;
|
|
247
|
+
const requirement = row ? servedText(row.requirement) : undefined;
|
|
248
|
+
if (!row || !id || !requirement) {
|
|
249
|
+
skipped++;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const phase = servedText(row.phase);
|
|
253
|
+
const enforcement = servedText(row.enforcement);
|
|
254
|
+
rules.push({ id, ...(phase ? { phase } : {}), requirement, ...(enforcement ? { enforcement } : {}) });
|
|
255
|
+
}
|
|
256
|
+
return rules.length > 0 ? { rules, skipped } : null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Authority rule texts Pi quotes, with the label each is shown under, in order.
|
|
261
|
+
* Any other `*_rule` text DevSpec serves is quoted after these under a label made
|
|
262
|
+
* from its name, so a rule added on the server reaches the model before Pi knows
|
|
263
|
+
* its name. The two left out here are deliberately not shown: role_source_rule
|
|
264
|
+
* matters to people reading the record, not to an agent's next write, and
|
|
265
|
+
* preview_rule describes the web review screen.
|
|
266
|
+
*/
|
|
267
|
+
const AUTHORITY_RULE_LABELS: ReadonlyArray<readonly [string, string]> = [
|
|
268
|
+
["ownership_rule", "Ownership rule"],
|
|
269
|
+
["unattended_rule", "Unattended rule"],
|
|
270
|
+
["requester_rule", "Requester rule"],
|
|
271
|
+
["flag_ruling_rule", "Queued review-flag rulings"],
|
|
272
|
+
];
|
|
273
|
+
const AUTHORITY_RULES_NOT_SHOWN = new Set(["role_source_rule", "preview_rule"]);
|
|
274
|
+
|
|
275
|
+
function readAuthorityContract(value: unknown): AuthorityContract | null {
|
|
276
|
+
const section = asRecord(value);
|
|
277
|
+
if (!section || !sectionVersionIsReadable(section, [1]) || !Array.isArray(section.classes)) return null;
|
|
278
|
+
const classes: AuthorityContract["classes"] = [];
|
|
279
|
+
for (const candidate of section.classes) {
|
|
280
|
+
const row = asRecord(candidate);
|
|
281
|
+
const name = row ? servedText(row.class) : undefined;
|
|
282
|
+
const meaning = row ? servedText(row.meaning) : undefined;
|
|
283
|
+
const outcomes = row ? asRecord(row.outcomes) : null;
|
|
284
|
+
// A class Pi cannot read would be shown with a hole in it; the section is
|
|
285
|
+
// carried as served instead, which still gives the model DevSpec's words.
|
|
286
|
+
if (!name || !meaning || !outcomes) return null;
|
|
287
|
+
const pairs = Object.entries(outcomes).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim() !== "");
|
|
288
|
+
if (pairs.length === 0) return null;
|
|
289
|
+
classes.push({ class: name, meaning, outcomes: pairs });
|
|
290
|
+
}
|
|
291
|
+
if (classes.length === 0) return null;
|
|
292
|
+
const proposal = asRecord(section.proposal_outcome);
|
|
293
|
+
const token = proposal ? servedText(proposal.token) : undefined;
|
|
294
|
+
const requirement = proposal ? servedText(proposal.requirement) : undefined;
|
|
295
|
+
const known = new Set(AUTHORITY_RULE_LABELS.map(([key]) => key));
|
|
296
|
+
const rules: AuthorityContract["rules"] = [];
|
|
297
|
+
for (const [key] of AUTHORITY_RULE_LABELS) {
|
|
298
|
+
const text = servedText(section[key]);
|
|
299
|
+
if (text) rules.push({ key, text });
|
|
300
|
+
}
|
|
301
|
+
for (const key of Object.keys(section)) {
|
|
302
|
+
if (known.has(key) || AUTHORITY_RULES_NOT_SHOWN.has(key) || !key.endsWith("_rule")) continue;
|
|
303
|
+
const text = servedText(section[key]);
|
|
304
|
+
if (text) rules.push({ key, text });
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
schema_version: 1,
|
|
308
|
+
classes,
|
|
309
|
+
...(token && requirement ? { proposal_outcome: { token, requirement } } : {}),
|
|
310
|
+
rules,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseJsonText(value: unknown, expectedResourceUri: string): unknown {
|
|
315
|
+
const root = asRecord(value);
|
|
316
|
+
if (!root || !Array.isArray(root.contents)) return value;
|
|
317
|
+
const matching = root.contents
|
|
318
|
+
.map(asRecord)
|
|
319
|
+
.find((entry) => entry?.uri === expectedResourceUri && nonEmptyString(entry.text));
|
|
320
|
+
const text = nonEmptyString(matching?.text);
|
|
321
|
+
if (!text) throw new Error(`implementation contract resource did not return ${expectedResourceUri}`);
|
|
322
|
+
return JSON.parse(text);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Why a served document could not be read at all. Only these take Pi to its
|
|
327
|
+
* built-in rules; everything inside a section degrades that section alone.
|
|
328
|
+
*/
|
|
329
|
+
class UnreadableContract extends Error {}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* One parser for both delivery paths, because both carry the same document.
|
|
333
|
+
* `contract_hash` covers every other served field, so a bundle authenticates itself.
|
|
334
|
+
*
|
|
335
|
+
* Each section is read on its own (item 39c05c1f). Pi takes what it shows the model
|
|
336
|
+
* and ignores the rest, so DevSpec can add a field, a role, a rule or a whole section
|
|
337
|
+
* without a Pi release. A section Pi cannot read — a schema_version it has no reader
|
|
338
|
+
* for, or a shape it does not recognise — is carried exactly as served, with a notice
|
|
339
|
+
* saying to update the extension; the other sections stay in force. Before this, one
|
|
340
|
+
* changed field in any section Pi verified sent /devspec.work back to five generic
|
|
341
|
+
* local rules: from 2026-09-14 (flag_ruling_rule, 3dff471b) for three weeks, and again
|
|
342
|
+
* when the server and Pi disagreed about commit references (96f3dfb7).
|
|
343
|
+
*
|
|
344
|
+
* What still refuses the whole document: it is not DevSpec's product contract, its
|
|
345
|
+
* own format (root schema_version) is one Pi cannot read, or the hash does not cover
|
|
346
|
+
* exactly what was served — including any part Pi cannot read, so nothing is
|
|
347
|
+
* accepted unverified.
|
|
348
|
+
*/
|
|
349
|
+
function parseContractBundle(root: Record<string, unknown>, resourceUri: string): CachedContract {
|
|
350
|
+
if (root.kind !== "devspec.product.implementation_contract") {
|
|
351
|
+
throw new UnreadableContract("the served document is not DevSpec's implementation contract");
|
|
352
|
+
}
|
|
353
|
+
if (typeof root.schema_version !== "number" || !READABLE_ROOT_SCHEMA_VERSIONS.has(root.schema_version)) {
|
|
354
|
+
throw new UnreadableContract(`the contract is in a format (${String(root.schema_version)}) newer than this extension reads`);
|
|
355
|
+
}
|
|
356
|
+
const source = asRecord(root.source);
|
|
357
|
+
if (!source || source.authority !== PRODUCT_SOURCE.authority || source.tier !== PRODUCT_SOURCE.tier) {
|
|
358
|
+
throw new UnreadableContract("the served document does not name DevSpec's product contract as its source");
|
|
359
|
+
}
|
|
360
|
+
const contractVersion = exactContractString(root.contract_version);
|
|
361
|
+
if (!contractVersion || !CONTRACT_VERSION_PATTERN.test(contractVersion)) {
|
|
362
|
+
throw new UnreadableContract("the served document carries no contract version");
|
|
363
|
+
}
|
|
364
|
+
// Hash exactly what was served; no trimming or reconstructed value may
|
|
365
|
+
// authenticate a differently encoded document.
|
|
366
|
+
const contractHash = exactContractString(root.contract_hash);
|
|
367
|
+
const { contract_hash: _contractHash, ...contractHashInput } = root;
|
|
368
|
+
if (!contractHash || !CONTRACT_HASH_PATTERN.test(contractHash) || hashContractValue(contractHashInput) !== contractHash) {
|
|
369
|
+
throw new UnreadableContract("the served document does not match its own hash");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const unreadSections: UnreadSection[] = [];
|
|
373
|
+
const unread = (section: string) => {
|
|
374
|
+
if (Object.hasOwn(root, section)) unreadSections.push({ section, value: root[section] });
|
|
375
|
+
};
|
|
376
|
+
const core = readRules(root.core_implementation_contract);
|
|
377
|
+
if (!core) unread("core_implementation_contract");
|
|
378
|
+
// Pi shows these two exactly as served and interprets neither, so any object
|
|
379
|
+
// DevSpec sends is one Pi can carry, at any schema_version.
|
|
380
|
+
const workEntryContract = asRecord(root.work_entry_contract) ?? undefined;
|
|
381
|
+
if (!workEntryContract) unread("work_entry_contract");
|
|
382
|
+
const commitProvenanceContract = asRecord(root.commit_provenance_contract) ?? undefined;
|
|
383
|
+
const instructionBoundary = asRecord(root.instruction_boundary) ?? undefined;
|
|
384
|
+
const authorityContract = readAuthorityContract(root.authority_contract) ?? undefined;
|
|
385
|
+
if (!authorityContract) unread("authority_contract");
|
|
386
|
+
const unknownSections = extraKeys(root, KNOWN_ROOT_KEYS);
|
|
387
|
+
return {
|
|
388
|
+
contractVersion,
|
|
389
|
+
contractHash,
|
|
390
|
+
resourceUri,
|
|
391
|
+
// Without readable core rules the model still gets Pi's built-in ones, with
|
|
392
|
+
// the served section beside them and a notice saying which is which.
|
|
393
|
+
rules: core?.rules ?? [...LOCAL_SAFETY_RULES],
|
|
394
|
+
...(workEntryContract ? { workEntryContract } : {}),
|
|
395
|
+
...(commitProvenanceContract ? { commitProvenanceContract } : {}),
|
|
396
|
+
...(instructionBoundary ? { instructionBoundary } : {}),
|
|
397
|
+
...(authorityContract ? { authorityContract } : {}),
|
|
398
|
+
...(unreadSections.length > 0 ? { unreadSections } : {}),
|
|
399
|
+
...(core && core.skipped > 0 ? { skippedRules: core.skipped } : {}),
|
|
400
|
+
...(unknownSections.length > 0 ? { unknownSections } : {}),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function contractFromResourcePayload(payload: unknown, resourceUri: string): CachedContract {
|
|
405
|
+
const root = asRecord(parseJsonText(payload, resourceUri));
|
|
406
|
+
if (!root) throw new UnreadableContract("the served document is not a JSON object");
|
|
407
|
+
return parseContractBundle(root, resourceUri);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function contractFromCompatibilityPayload(payload: unknown, resourceUri: string): CachedContract {
|
|
411
|
+
const root = asRecord(payload);
|
|
412
|
+
if (!root) throw new UnreadableContract("the compatibility payload is not a JSON object");
|
|
413
|
+
const bundle = asRecord(root.implementation_contract) ?? root;
|
|
414
|
+
return parseContractBundle(bundle, resourceUri);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* What Pi teaches when it cannot read the canonical contract at all, and beside a
|
|
419
|
+
* served core section it could not read. These are deliberately the same in every
|
|
420
|
+
* situation: the two rules that used to be the unattended overlay are here
|
|
421
|
+
* unconditionally, because "do not invent authority" was never contingent on
|
|
422
|
+
* nobody watching.
|
|
423
|
+
*/
|
|
424
|
+
const LOCAL_SAFETY_RULES: readonly ImplementationRule[] = [
|
|
425
|
+
{
|
|
426
|
+
id: "pi-fallback.claim-early",
|
|
427
|
+
phase: "claim",
|
|
428
|
+
requirement: "Search, reuse or create the smallest covering item and claim it early when DevSpec is available; an absent claim never blocks local repository work.",
|
|
429
|
+
enforcement: "agent",
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: "pi-fallback.commit-link",
|
|
433
|
+
phase: "delivery",
|
|
434
|
+
requirement: "Prefer one full [devspec:<task-id>] commit reference and use record_implementation for authoritative post-commit linkage; unsupported Git surfaces rely on server reconciliation.",
|
|
435
|
+
enforcement: "agent",
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: "pi-fallback.no-human-assumption",
|
|
439
|
+
phase: "implementation",
|
|
440
|
+
requirement: "Do not assume a human is present and do not invent authority or intent.",
|
|
441
|
+
enforcement: "agent",
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
id: "pi-fallback.waiting-work-stays-on-its-branch",
|
|
445
|
+
phase: "delivery",
|
|
446
|
+
requirement: "When the claim or the item carries waiting_for_ok (\"Experiment\", or \"Waiting for <name>'s OK\"), build and push the work on its own branch and never merge it into the tracking branch until it has the OK; tell the person plainly.",
|
|
447
|
+
enforcement: "agent",
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
id: "pi-fallback.stop-at-implemented",
|
|
451
|
+
phase: "completion",
|
|
452
|
+
requirement: "Record implementation after pushing and stop at implemented; never mark the item done.",
|
|
453
|
+
enforcement: "server",
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "pi-fallback.truthful-handoff",
|
|
457
|
+
phase: "failure",
|
|
458
|
+
requirement: "On failure, use fail_work_item or release_work_item truthfully; never leave failed work implementing.",
|
|
459
|
+
enforcement: "agent",
|
|
460
|
+
},
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
function localSafetyFallback(resourceUri: string, warning: string): ImplementationContractContext {
|
|
464
|
+
return {
|
|
465
|
+
contractVersion: "pi-local-fail-open-v1",
|
|
466
|
+
resourceUri,
|
|
467
|
+
source: "local_safety_fallback",
|
|
468
|
+
warning,
|
|
469
|
+
rules: [...LOCAL_SAFETY_RULES],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function validateResourceUri(uri: string): boolean {
|
|
474
|
+
return uri === IMPLEMENTATION_CONTRACT_RESOURCE_URI;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function storeContract(scope: string, value: CachedContract): void {
|
|
478
|
+
const current = cache();
|
|
479
|
+
current.byIdentity.set(cacheIdentity(scope, value.resourceUri, value.contractVersion), value);
|
|
480
|
+
current.latestByResource.set(latestIdentity(scope, value.resourceUri), value);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** How a section is named to a person, who has never seen the document's keys. */
|
|
484
|
+
const SECTION_LABELS: Readonly<Record<string, string>> = {
|
|
485
|
+
core_implementation_contract: "the core rules",
|
|
486
|
+
work_entry_contract: "the rules for starting work",
|
|
487
|
+
commit_provenance_contract: "the rules for linking commits",
|
|
488
|
+
authority_contract: "the rules for who may change the project's record",
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
function sectionLabel(section: string): string {
|
|
492
|
+
return SECTION_LABELS[section] ?? section;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function listOf(items: readonly string[]): string {
|
|
496
|
+
return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The notice an accepted contract carries when part of it is something this Pi
|
|
501
|
+
* cannot read. It never downgrades what Pi did read; it says what was not, that
|
|
502
|
+
* the rest is in force, and how to fix it.
|
|
503
|
+
*/
|
|
504
|
+
function partialReadNotice(contract: CachedContract): string | undefined {
|
|
505
|
+
const parts: string[] = [];
|
|
506
|
+
const unread = contract.unreadSections ?? [];
|
|
507
|
+
if (unread.length > 0) {
|
|
508
|
+
const coreUnread = unread.some((entry) => entry.section === "core_implementation_contract");
|
|
509
|
+
parts.push(`This version of the DevSpec Pi extension could not fully read ${listOf(unread.map((entry) => sectionLabel(entry.section)))} in DevSpec's rules for agents, so ${unread.length === 1 ? "that part is" : "those parts are"} shown exactly as DevSpec sent ${unread.length === 1 ? "it" : "them"}${coreUnread ? ", next to Pi's built-in rules" : ""}.`);
|
|
510
|
+
}
|
|
511
|
+
if (contract.skippedRules) {
|
|
512
|
+
parts.push(`${contract.skippedRules} of DevSpec's core rules could not be read by this version of the DevSpec Pi extension.`);
|
|
513
|
+
}
|
|
514
|
+
const unknown = contract.unknownSections ?? [];
|
|
515
|
+
if (unknown.length > 0) {
|
|
516
|
+
parts.push(`DevSpec's rules for agents include ${unknown.length === 1 ? "a part" : "parts"} this version of the DevSpec Pi extension does not know (${unknown.join(", ")}); get_workflow_rules with include_implementation_contract returns the whole contract.`);
|
|
517
|
+
}
|
|
518
|
+
if (parts.length === 0) return undefined;
|
|
519
|
+
return [...parts, "Everything else is loaded and in force.", UPDATE_EXTENSION_INSTRUCTION].join(" ");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function acceptedContract(
|
|
523
|
+
contract: CachedContract,
|
|
524
|
+
source: "resource" | "compatibility" | "cache",
|
|
525
|
+
warning?: string,
|
|
526
|
+
): ImplementationContractContext {
|
|
527
|
+
const notice = partialReadNotice(contract);
|
|
528
|
+
const combined = [warning, notice].filter(Boolean).join(" ");
|
|
529
|
+
return { ...contract, source, ...(combined ? { warning: combined } : {}) };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function cachedContract(
|
|
533
|
+
scope: string,
|
|
534
|
+
resourceUri: string,
|
|
535
|
+
expectedVersion?: string,
|
|
536
|
+
): ImplementationContractContext | null {
|
|
537
|
+
const current = cache();
|
|
538
|
+
const value = expectedVersion
|
|
539
|
+
? current.byIdentity.get(cacheIdentity(scope, resourceUri, expectedVersion))
|
|
540
|
+
: current.latestByResource.get(latestIdentity(scope, resourceUri));
|
|
541
|
+
return value ? acceptedContract(value, "cache") : null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Load the product contract at work entry. A delivery that names a version can use
|
|
546
|
+
* that exact immutable cache entry; an unversioned one refreshes and may fall back
|
|
547
|
+
* to the latest cached contract, because there is no version it was promised.
|
|
548
|
+
*/
|
|
549
|
+
export async function loadImplementationContext(input: {
|
|
550
|
+
reference?: ImplementationContractReference;
|
|
551
|
+
loader: ImplementationContractLoader;
|
|
552
|
+
/** Pump/bond authority checkpoint for each asynchronous preparation stage. */
|
|
553
|
+
assertCurrent?: () => void;
|
|
554
|
+
}): Promise<ImplementationContractContext> {
|
|
555
|
+
const resourceUri = input.reference?.resource_uri ?? IMPLEMENTATION_CONTRACT_RESOURCE_URI;
|
|
556
|
+
const expectedVersion = nonEmptyString(input.reference?.contract_version);
|
|
557
|
+
const scope = await input.loader.cacheScope();
|
|
558
|
+
input.assertCurrent?.();
|
|
559
|
+
if (!validateResourceUri(resourceUri)) {
|
|
560
|
+
return localSafetyFallback(
|
|
561
|
+
IMPLEMENTATION_CONTRACT_RESOURCE_URI,
|
|
562
|
+
`The delivered implementation-contract pointer was not ${IMPLEMENTATION_CONTRACT_RESOURCE_URI}; the Pi safety fallback is active.`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (expectedVersion) {
|
|
567
|
+
const exact = cachedContract(scope, resourceUri, expectedVersion);
|
|
568
|
+
if (exact) return exact;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const errors: string[] = [];
|
|
572
|
+
// A document that arrived and could not be read is a different situation from
|
|
573
|
+
// one that never arrived: the first needs an extension update, the second needs
|
|
574
|
+
// the service back. Saying "unavailable" for both sends whoever reads it looking in
|
|
575
|
+
// the wrong place.
|
|
576
|
+
let served = false;
|
|
577
|
+
try {
|
|
578
|
+
const payload = await input.loader.readResource(resourceUri);
|
|
579
|
+
input.assertCurrent?.();
|
|
580
|
+
served = true;
|
|
581
|
+
const contract = contractFromResourcePayload(payload, resourceUri);
|
|
582
|
+
if (expectedVersion && contract.contractVersion !== expectedVersion) {
|
|
583
|
+
throw new Error(`resource version ${contract.contractVersion} did not match kickoff version ${expectedVersion}`);
|
|
584
|
+
}
|
|
585
|
+
storeContract(scope, contract);
|
|
586
|
+
return acceptedContract(contract, "resource");
|
|
587
|
+
} catch (error) {
|
|
588
|
+
input.assertCurrent?.();
|
|
589
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const payload = await input.loader.readCompatibilityBundle();
|
|
594
|
+
input.assertCurrent?.();
|
|
595
|
+
served = true;
|
|
596
|
+
const contract = contractFromCompatibilityPayload(payload, resourceUri);
|
|
597
|
+
if (expectedVersion && contract.contractVersion !== expectedVersion) {
|
|
598
|
+
throw new Error(`compatibility version ${contract.contractVersion} did not match kickoff version ${expectedVersion}`);
|
|
599
|
+
}
|
|
600
|
+
storeContract(scope, contract);
|
|
601
|
+
return acceptedContract(contract, "compatibility");
|
|
602
|
+
} catch (error) {
|
|
603
|
+
input.assertCurrent?.();
|
|
604
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
input.assertCurrent?.();
|
|
608
|
+
const retained = expectedVersion
|
|
609
|
+
? cachedContract(scope, resourceUri, expectedVersion)
|
|
610
|
+
: cachedContract(scope, resourceUri);
|
|
611
|
+
if (retained) {
|
|
612
|
+
return {
|
|
613
|
+
...retained,
|
|
614
|
+
warning: [
|
|
615
|
+
`DevSpec contract refresh failed; using the retained ${retained.contractVersion} contract.`,
|
|
616
|
+
retained.warning,
|
|
617
|
+
].filter(Boolean).join(" "),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return localSafetyFallback(
|
|
621
|
+
resourceUri,
|
|
622
|
+
served
|
|
623
|
+
? `DevSpec sent its rules for agents in a form this version of the DevSpec Pi extension cannot read (${errors.join("; ") || "unknown error"}), so Pi is using its short built-in rules instead. ${UPDATE_EXTENSION_INSTRUCTION}`
|
|
624
|
+
: `DevSpec's rules for agents could not be fetched (${errors.join("; ") || "unknown error"}), so Pi is using its short built-in rules instead.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function createImplementationContractLoader(token: string): ImplementationContractLoader {
|
|
629
|
+
const tokenFingerprint = createHash("sha256").update(token, "utf8").digest("hex").slice(0, 24);
|
|
630
|
+
return {
|
|
631
|
+
cacheScope: async () => `${await resolveEndpoint()}#${tokenFingerprint}`,
|
|
632
|
+
readResource: (uri) => readDevspecResource(token, uri, {
|
|
633
|
+
lane: "work",
|
|
634
|
+
timeoutMs: MODEL_TIMEOUT_MS,
|
|
635
|
+
}),
|
|
636
|
+
readCompatibilityBundle: async () => {
|
|
637
|
+
const raw = await callDevspec(
|
|
638
|
+
token,
|
|
639
|
+
"get_workflow_rules",
|
|
640
|
+
{ include_implementation_contract: true },
|
|
641
|
+
{ lane: "work", timeoutMs: MODEL_TIMEOUT_MS },
|
|
642
|
+
);
|
|
643
|
+
const unwrapped = unwrapMcpResult(raw);
|
|
644
|
+
if (unwrapped.isError) throw new Error(unwrapped.text || "get_workflow_rules failed");
|
|
645
|
+
return unwrapped.details ?? unwrapped.text;
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function buildImplementationKickoff(input: {
|
|
651
|
+
taskIds: readonly string[];
|
|
652
|
+
context: ImplementationContractContext;
|
|
653
|
+
contractReference?: ImplementationContractReference;
|
|
654
|
+
}): ImplementationKickoff {
|
|
655
|
+
const taskIds = [...new Set(input.taskIds.map((id) => id.trim()).filter(Boolean))];
|
|
656
|
+
if (taskIds.length === 0) throw new Error("implementation kickoff requires at least one task id");
|
|
657
|
+
const suppliedVersion = nonEmptyString(input.contractReference?.contract_version);
|
|
658
|
+
if (
|
|
659
|
+
input.context.source !== "local_safety_fallback" && suppliedVersion &&
|
|
660
|
+
suppliedVersion !== input.context.contractVersion
|
|
661
|
+
) {
|
|
662
|
+
throw new Error("implementation kickoff contract version did not match the loaded canonical contract");
|
|
663
|
+
}
|
|
664
|
+
if (input.contractReference?.resource_uri && input.contractReference.resource_uri !== input.context.resourceUri) {
|
|
665
|
+
throw new Error("implementation kickoff contract resource did not match the loaded canonical contract");
|
|
666
|
+
}
|
|
667
|
+
const reference = {
|
|
668
|
+
// During an outage preserve the immutable server pointer the delivery
|
|
669
|
+
// carried. The local fallback affects guidance, not work authorization.
|
|
670
|
+
contract_version: suppliedVersion ?? input.context.contractVersion,
|
|
671
|
+
resource_uri: input.contractReference?.resource_uri ?? input.context.resourceUri,
|
|
672
|
+
};
|
|
673
|
+
const contractVersion = nonEmptyString(reference.contract_version);
|
|
674
|
+
if (!contractVersion || !validateResourceUri(reference.resource_uri)) {
|
|
675
|
+
throw new Error("implementation kickoff requires a versioned product-contract reference");
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
kind: "devspec.implementation_kickoff",
|
|
679
|
+
schema_version: 1,
|
|
680
|
+
work: {
|
|
681
|
+
task_ids: taskIds,
|
|
682
|
+
},
|
|
683
|
+
implementation_contract: {
|
|
684
|
+
contract_version: contractVersion,
|
|
685
|
+
resource_uri: reference.resource_uri,
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function formatImplementationKickoffHeader(kickoff: ImplementationKickoff): string {
|
|
691
|
+
return `DevSpec work kickoff: ${JSON.stringify(kickoff)}`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function formatImplementationContext(context: ImplementationContractContext): string {
|
|
695
|
+
const identity = [
|
|
696
|
+
`version=${context.contractVersion}`,
|
|
697
|
+
context.contractHash ? `hash=${context.contractHash}` : null,
|
|
698
|
+
`source=${context.source}`,
|
|
699
|
+
].filter(Boolean).join(" · ");
|
|
700
|
+
return [
|
|
701
|
+
`${context.source === "local_safety_fallback" ? "Local fail-open fallback (the canonical product contract was not loaded; the notice below says why)" : "Product implementation contract"} (${identity})`,
|
|
702
|
+
...(context.warning ? [`Availability notice (tell the person you are working with): ${context.warning}`] : []),
|
|
703
|
+
...(context.workEntryContract ? [
|
|
704
|
+
"Canonical work entry contract (the only task/session-plan boundary):",
|
|
705
|
+
JSON.stringify(context.workEntryContract),
|
|
706
|
+
] : []),
|
|
707
|
+
// The rules point at both of these by name, so the model reads them as served (5a9eaa57).
|
|
708
|
+
...(context.commitProvenanceContract ? [
|
|
709
|
+
"Commit provenance contract (commit_provenance_contract — how implementing commits are linked):",
|
|
710
|
+
JSON.stringify(context.commitProvenanceContract),
|
|
711
|
+
] : []),
|
|
712
|
+
...(context.instructionBoundary ? [
|
|
713
|
+
"Instruction boundary (instruction_boundary — which instruction tier wins, and what no tier may override):",
|
|
714
|
+
JSON.stringify(context.instructionBoundary),
|
|
715
|
+
] : []),
|
|
716
|
+
...(context.authorityContract ? formatAuthorityContract(context.authorityContract) : []),
|
|
717
|
+
// A section this Pi could not read in full still reaches the model in
|
|
718
|
+
// DevSpec's own words; only Pi's summary of it is missing.
|
|
719
|
+
...(context.unreadSections ?? []).flatMap((entry) => [
|
|
720
|
+
`${entry.section}, exactly as DevSpec sent it (this version of the extension could not read it in full):`,
|
|
721
|
+
JSON.stringify(entry.value),
|
|
722
|
+
]),
|
|
723
|
+
"Rules:",
|
|
724
|
+
...context.rules.map((rule) => `- [${rule.id}] ${rule.requirement}`),
|
|
725
|
+
].join("\n");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Shown before the proposal outcome; every other rule text follows it. */
|
|
729
|
+
const AUTHORITY_RULES_BEFORE_PROPOSAL = new Set(["ownership_rule", "unattended_rule", "requester_rule"]);
|
|
730
|
+
|
|
731
|
+
function authorityRuleLabel(key: string): string {
|
|
732
|
+
const known = AUTHORITY_RULE_LABELS.find(([name]) => name === key)?.[1];
|
|
733
|
+
if (known) return known;
|
|
734
|
+
const words = key.replace(/_/g, " ").trim();
|
|
735
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The authority section as the model reads it: the classes with the outcomes the
|
|
740
|
+
* server actually returns, the rules that qualify those outcomes, and what a queued
|
|
741
|
+
* proposal means. Verbs and role meanings stay in the served document — the model
|
|
742
|
+
* reads the outcome of each call; it never has to predict one.
|
|
743
|
+
*
|
|
744
|
+
* The class table is printed with `ownership_rule` and `unattended_rule` beside it
|
|
745
|
+
* because on its own it is wrong about the two writes an agent makes most (DevSpec
|
|
746
|
+
* item 689d44cb): the table says an `add` commits unattended, while the served
|
|
747
|
+
* unattended rule records `memory.record` and `artifact.create` as proposals and
|
|
748
|
+
* refuses personal-scope writes; and the ownership rule is what makes closing your
|
|
749
|
+
* own item an add and closing a colleague's an overrule. Every rule is quoted from
|
|
750
|
+
* the served document, never paraphrased — this plugin does not own these sentences.
|
|
751
|
+
* Outcomes are printed for whichever roles DevSpec serves, in its order, so a role
|
|
752
|
+
* added on the server is shown without a Pi release.
|
|
753
|
+
*/
|
|
754
|
+
export function formatAuthorityContract(authority: AuthorityContract): string[] {
|
|
755
|
+
const rule = (entry: { key: string; text: string }) => `- ${authorityRuleLabel(entry.key)}: ${entry.text}`;
|
|
756
|
+
return [
|
|
757
|
+
"Write authority (authority_contract — who may change the project's record is decided per call by the server from the verb, the person's project role and whether the run is unattended; read every write's outcome, never predict it):",
|
|
758
|
+
...authority.classes.map((cls) =>
|
|
759
|
+
`- ${cls.class}: ${cls.meaning} Outcomes — ${cls.outcomes.map(([role, outcome]) => `${role.replace(/_/g, " ")} ${outcome}`).join(", ")}.`,
|
|
760
|
+
),
|
|
761
|
+
...authority.rules.filter((entry) => AUTHORITY_RULES_BEFORE_PROPOSAL.has(entry.key)).map(rule),
|
|
762
|
+
...(authority.proposal_outcome ? [`- ${authority.proposal_outcome.token}: ${authority.proposal_outcome.requirement}`] : []),
|
|
763
|
+
...authority.rules.filter((entry) => !AUTHORITY_RULES_BEFORE_PROPOSAL.has(entry.key)).map(rule),
|
|
764
|
+
];
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function resetImplementationContractCache(): void {
|
|
768
|
+
const root = globalThis as typeof globalThis & { [CACHE_KEY]?: ContractCache };
|
|
769
|
+
root[CACHE_KEY] = {
|
|
770
|
+
byIdentity: new Map(),
|
|
771
|
+
latestByResource: new Map(),
|
|
772
|
+
};
|
|
773
|
+
}
|