pi-daddy 0.26.1 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/PRODUCT-GUIDE.md +127 -0
- package/README.md +23 -2
- package/REQUIREMENTS.md +41 -0
- package/dist/catalog.d.ts +1 -7
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +8 -40
- package/dist/catalog.js.map +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -14
- package/dist/cli.js.map +1 -1
- package/dist/daily-dashboard-host.d.ts +14 -1
- package/dist/daily-dashboard-host.d.ts.map +1 -1
- package/dist/daily-dashboard-host.js +40 -17
- package/dist/daily-dashboard-host.js.map +1 -1
- package/dist/daily-panel.d.ts +36 -0
- package/dist/daily-panel.d.ts.map +1 -0
- package/dist/daily-panel.js +84 -0
- package/dist/daily-panel.js.map +1 -0
- package/dist/daily-view-render.d.ts +2 -1
- package/dist/daily-view-render.d.ts.map +1 -1
- package/dist/daily-view-render.js +2 -1
- package/dist/daily-view-render.js.map +1 -1
- package/dist/daily-view.d.ts +3 -0
- package/dist/daily-view.d.ts.map +1 -1
- package/dist/daily-view.js +6 -0
- package/dist/daily-view.js.map +1 -1
- package/dist/dashboard-cli.d.ts +2 -0
- package/dist/dashboard-cli.d.ts.map +1 -1
- package/dist/dashboard-cli.js +37 -15
- package/dist/dashboard-cli.js.map +1 -1
- package/dist/dashboard-host-contract.d.ts +2 -0
- package/dist/dashboard-host-contract.d.ts.map +1 -1
- package/dist/dashboard-host-contract.js.map +1 -1
- package/dist/dashboard-host-transport.d.ts.map +1 -1
- package/dist/dashboard-host-transport.js +6 -3
- package/dist/dashboard-host-transport.js.map +1 -1
- package/dist/dashboard-host.d.ts +30 -2
- package/dist/dashboard-host.d.ts.map +1 -1
- package/dist/dashboard-host.js +73 -11
- package/dist/dashboard-host.js.map +1 -1
- package/dist/dashboard-menu.d.ts +16 -0
- package/dist/dashboard-menu.d.ts.map +1 -0
- package/dist/dashboard-menu.js +33 -0
- package/dist/dashboard-menu.js.map +1 -0
- package/dist/definitions.d.ts +3 -8
- package/dist/definitions.d.ts.map +1 -1
- package/dist/definitions.js +19 -43
- package/dist/definitions.js.map +1 -1
- package/dist/factory-order.d.ts +10 -10
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +7 -19
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +9 -20
- package/dist/init.js.map +1 -1
- package/dist/learning-connection.d.ts +127 -0
- package/dist/learning-connection.d.ts.map +1 -0
- package/dist/learning-connection.js +54 -0
- package/dist/learning-connection.js.map +1 -0
- package/dist/model-preflight.d.ts +5 -0
- package/dist/model-preflight.d.ts.map +1 -1
- package/dist/model-preflight.js +9 -0
- package/dist/model-preflight.js.map +1 -1
- package/dist/product-files.d.ts +6 -0
- package/dist/product-files.d.ts.map +1 -0
- package/dist/product-files.js +55 -0
- package/dist/product-files.js.map +1 -0
- package/dist/skill-packages.d.ts +6 -24
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +74 -33
- package/dist/skill-packages.js.map +1 -1
- package/dist/skill-resources.d.ts +21 -0
- package/dist/skill-resources.d.ts.map +1 -0
- package/dist/skill-resources.js +52 -0
- package/dist/skill-resources.js.map +1 -0
- package/dist/work-command.d.ts.map +1 -1
- package/dist/work-command.js +14 -6
- package/dist/work-command.js.map +1 -1
- package/dist/work-policy-registry.d.ts +133 -0
- package/dist/work-policy-registry.d.ts.map +1 -0
- package/dist/work-policy-registry.js +128 -0
- package/dist/work-policy-registry.js.map +1 -0
- package/dist/work-run.d.ts +56 -0
- package/dist/work-run.d.ts.map +1 -0
- package/dist/work-run.js +102 -0
- package/dist/work-run.js.map +1 -0
- package/dist/work-setup.d.ts +40 -0
- package/dist/work-setup.d.ts.map +1 -0
- package/dist/work-setup.js +174 -0
- package/dist/work-setup.js.map +1 -0
- package/extensions/daily-dashboard-session.ts +16 -7
- package/extensions/execute-child.ts +2 -2
- package/extensions/grants-command.ts +2 -2
- package/extensions/grants.ts +6 -2
- package/extensions/init-command.ts +3 -2
- package/extensions/learning-outcome-session.ts +38 -0
- package/extensions/learning-session.ts +59 -0
- package/extensions/ordinary-runtime.ts +2 -2
- package/extensions/run-delegation.ts +2 -2
- package/extensions/work-policy-session.ts +139 -0
- package/extensions/work-run-session.ts +75 -0
- package/extensions/work-runtime.ts +2 -2
- package/extensions/work-session.ts +132 -0
- package/package.json +9 -1
- package/src/catalog.ts +7 -37
- package/src/cli.ts +34 -14
- package/src/daily-dashboard-host.ts +32 -15
- package/src/daily-panel.ts +80 -0
- package/src/daily-view-render.ts +2 -1
- package/src/daily-view.ts +8 -0
- package/src/dashboard-cli.ts +29 -17
- package/src/dashboard-host-contract.ts +2 -0
- package/src/dashboard-host-transport.ts +7 -4
- package/src/dashboard-host.ts +48 -13
- package/src/dashboard-menu.ts +28 -0
- package/src/definitions.ts +16 -44
- package/src/index.ts +7 -1
- package/src/init.ts +10 -20
- package/src/learning-connection.ts +70 -0
- package/src/model-preflight.ts +9 -0
- package/src/product-files.ts +34 -0
- package/src/skill-packages.ts +59 -34
- package/src/skill-resources.ts +48 -0
- package/src/work-command.ts +11 -6
- package/src/work-policy-registry.ts +81 -0
- package/src/work-run.ts +79 -0
- package/src/work-setup.ts +141 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { intentKey } from "./intent-control.ts";
|
|
3
|
+
import { loadDeclaredWork, type DeclaredWorkState } from "./work-command.ts";
|
|
4
|
+
import { readProductJson, writeProductJson } from "./product-files.ts";
|
|
5
|
+
import { loadedDashboardHarnessDigest } from "./dashboard-harness.ts";
|
|
6
|
+
import type { DashboardHarness } from "./dashboard-host-contract.ts";
|
|
7
|
+
import { buildAdoptionBinding, type AdoptionAuthority, type AdoptionBinding, type AdoptionFacts, type AdoptionReceipt, type RollbackRequest, type ProductionObservation } from "./vendor/adoption.ts";
|
|
8
|
+
|
|
9
|
+
export interface LearningConfiguration { archiveRoot: string; scopeDigest: string; population: string; author: string }
|
|
10
|
+
export interface LearningUI { select(title: string, choices: string[]): Promise<string | undefined>; input(title: string, initial?: string): Promise<string | undefined>; editor(title: string, text: string): Promise<string | undefined>; confirm(title: string, detail: string): Promise<boolean>; notify(text: string, level?: "info" | "warning" | "error"): void }
|
|
11
|
+
export interface LearningComparisonContext { caseReference: unknown | null; hypothesisManifestId: string | null; adoptionBinding: AdoptionBinding | null }
|
|
12
|
+
export interface LearningWorkspace {
|
|
13
|
+
configuration(): LearningConfiguration;
|
|
14
|
+
inspect(now: number): { version: string; configuration: LearningConfiguration; cases: { name: string; title: string; state?: string; reason?: string }[]; comparisons: { name: string; title: string; state?: string; reason?: string }[]; trust: Record<string, unknown>; [key: string]: unknown };
|
|
15
|
+
dashboardBinding(): { trustDirectory: string | null; trustPolicyId: string | null };
|
|
16
|
+
bindCases(input: { name: string; title: string; version: 2 | 3; batchId: string }): unknown;
|
|
17
|
+
comparisonContext(name: string): LearningComparisonContext;
|
|
18
|
+
linkComparisonContext(name:string, context:LearningComparisonContext):unknown;
|
|
19
|
+
hypotheses(): {name:string;manifestId:string;hypothesis:{id:string}}[];
|
|
20
|
+
decisionStatus(name:string):{current:{disposition:string}|null};
|
|
21
|
+
reveal(name:string):{manifestId:string;choice:{kind:string;labels:readonly string[]};arms:readonly {label:string;configuration:{configuration:string}}[]};
|
|
22
|
+
preparedAdoption(name: string): AdoptionReceipt | null;
|
|
23
|
+
previewObservation(name: string, input: ProductionObservation): { observation: ProductionObservation; classification: unknown; digest: string };
|
|
24
|
+
observe(name: string, input: ProductionObservation, authorized: readonly string[]): { manifestId: string };
|
|
25
|
+
prepareAdoption(name: string, authority: AdoptionAuthority | null, facts: AdoptionFacts, now: number): AdoptionReceipt;
|
|
26
|
+
linkActivation(name: string, manifest: string, independentlyAuthorized: readonly string[]): unknown;
|
|
27
|
+
previewRollback(name: string, reason: "operator-request" | "confirmed-escape", evidence: string[], expiresAt?: number): RollbackRequest;
|
|
28
|
+
prepareRollback(name: string, request: RollbackRequest, authority: AdoptionAuthority | null, now: number, current: { adoptionId: string; candidateDigest: string; scopeDigest: string }): unknown;
|
|
29
|
+
linkRollback(name: string, manifest: string, independentlyAuthorized: readonly string[]): unknown;
|
|
30
|
+
}
|
|
31
|
+
export interface LearningHarness extends DashboardHarness {
|
|
32
|
+
createLearningWorkspace(directory: string, input: LearningConfiguration): LearningWorkspace;
|
|
33
|
+
openLearningWorkspace(directory: string): LearningWorkspace;
|
|
34
|
+
runLearningWizard(input: { directory: string; cwd: string; ui: LearningUI }): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
export function learningHarness(harness: DashboardHarness): LearningHarness {
|
|
37
|
+
if (!loadedDashboardHarnessDigest(harness) || ["createLearningWorkspace", "openLearningWorkspace", "runLearningWizard"].some(key => typeof (harness as unknown as Record<string, unknown>)[key] !== "function")) throw Error("Learning services are unavailable in the loaded skill-harness. Load the compatible learning-workspace-v1 release and start a fresh Pi session (the existing immutable bridge survives /reload); no private adapter is needed.");
|
|
38
|
+
return harness as LearningHarness;
|
|
39
|
+
}
|
|
40
|
+
/** Author only the missing binding from validated public readback after a durable choice. No authority is generated. */
|
|
41
|
+
export function bindLearningAdoption(workspace:LearningWorkspace,name:string,input:{candidateDigest:string;rollbackCandidateDigest:string;assessmentPolicyDigest:string;expiresAt:number}):AdoptionBinding {
|
|
42
|
+
const context=workspace.comparisonContext(name);
|
|
43
|
+
if(context.adoptionBinding)throw Error("Adoption proposal is already bound; it cannot be replaced");
|
|
44
|
+
if(!context.caseReference||!context.hypothesisManifestId)throw Error("Link the comparison's original confirmed case and hypothesis in Learning first");
|
|
45
|
+
const original=workspace.hypotheses().find(h=>h.manifestId===context.hypothesisManifestId);
|
|
46
|
+
if(!original||workspace.decisionStatus(name).current?.disposition!=="adopt")throw Error("Retained original hypothesis and explicit adopt intent required");
|
|
47
|
+
const reveal=workspace.reveal(name);
|
|
48
|
+
if(!["one","tie"].includes(reveal.choice.kind)||!reveal.arms.some(a=>reveal.choice.labels.includes(a.label)&&a.configuration.configuration===input.candidateDigest))throw Error("Candidate is not the exact configuration selected by the full-artifact quality choice");
|
|
49
|
+
const binding=buildAdoptionBinding({...input,hypothesisDigest:original.hypothesis.id,experimentDigest:reveal.manifestId,scopeDigest:workspace.configuration().scopeDigest,activationBoundary:"next-orders"});
|
|
50
|
+
workspace.linkComparisonContext(name,{...context,adoptionBinding:binding});return binding;
|
|
51
|
+
}
|
|
52
|
+
export interface LearningConnection { version: "producer-learning-connection-v1"; directory: string; configuration: LearningConfiguration; selection: DeclaredWorkState["selectedSnapshot"] }
|
|
53
|
+
export const learningScopeDigest = (state: DeclaredWorkState) => state.selectedSnapshot.snapshot.digest;
|
|
54
|
+
export async function bindLearningConnection(cwd: string, state: DeclaredWorkState, directory: string, harness: LearningHarness, author: string): Promise<LearningConnection> {
|
|
55
|
+
if (resolve(directory) !== directory) throw Error("absolute learning workspace directory required");
|
|
56
|
+
const current=await loadDeclaredWork(state.statePath);
|
|
57
|
+
if(intentKey(current?.selectedSnapshot??null)!==intentKey(state.selectedSnapshot))throw Error("selected work changed before learning connection; reopen Learning");
|
|
58
|
+
const configuration = harness.openLearningWorkspace(directory).configuration();
|
|
59
|
+
if (configuration.scopeDigest !== learningScopeDigest(state) || configuration.author !== author) throw Error("learning workspace belongs to another work scope or author; not rebound");
|
|
60
|
+
const connection: LearningConnection = { version: "producer-learning-connection-v1", directory, configuration, selection: state.selectedSnapshot };
|
|
61
|
+
await writeProductJson(join(resolve(cwd), ".pi", "learning-workspace.json"), connection, true); return connection;
|
|
62
|
+
}
|
|
63
|
+
export async function loadLearningConnection(cwd: string, state: DeclaredWorkState, harness: LearningHarness, author: string): Promise<{ connection: LearningConnection; workspace: LearningWorkspace } | null> {
|
|
64
|
+
const c = await readProductJson(join(resolve(cwd), ".pi", "learning-workspace.json")) as LearningConnection | null;
|
|
65
|
+
if (!c) return null;
|
|
66
|
+
if (Object.keys(c).sort().join() !== "configuration,directory,selection,version" || c.version !== "producer-learning-connection-v1" || typeof c.directory !== "string" || resolve(c.directory) !== c.directory || intentKey(c.selection) !== intentKey(state.selectedSnapshot)) throw Error("Learning connection is stale for selected work. Reconnect deliberately with /grants learning; no evidence was carried forward.");
|
|
67
|
+
const workspace = harness.openLearningWorkspace(c.directory), actual = workspace.configuration();
|
|
68
|
+
if (intentKey(actual) !== intentKey(c.configuration) || actual.scopeDigest !== learningScopeDigest(state) || actual.author !== author) throw Error("learning archive/population/scope/author mismatch");
|
|
69
|
+
return { connection: c, workspace };
|
|
70
|
+
}
|
package/src/model-preflight.ts
CHANGED
|
@@ -2,6 +2,15 @@ import { refusal, type StructuredRefusal } from "./refusals.ts";
|
|
|
2
2
|
|
|
3
3
|
export const ENV_ALLOW_UNRESOLVED_MODELS = "PI_GRANTS_ALLOW_UNRESOLVED_MODELS";
|
|
4
4
|
|
|
5
|
+
/** Pi's resolved catalogue carries provider support in thinkingLevelMap. Never silently clamp a user's choice. */
|
|
6
|
+
export function supportedModelEfforts(model: { reasoning: boolean; thinkingLevelMap?: Partial<Record<string, string | null>> }): ("off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max")[] {
|
|
7
|
+
if (!model.reasoning) return ["off"];
|
|
8
|
+
return (["off","minimal","low","medium","high","xhigh","max"] as const).filter(level => {
|
|
9
|
+
const mapped=model.thinkingLevelMap?.[level];
|
|
10
|
+
return mapped!==null && ((level!=="xhigh"&&level!=="max") || mapped!==undefined);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
export interface ModelCatalogue {
|
|
6
15
|
find(provider: string, modelId: string): unknown;
|
|
7
16
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readDailySnapshot } from "./daily-view-input.ts";
|
|
5
|
+
import { parseRetentionJson } from "./retention-json.ts";
|
|
6
|
+
import { runWithFinalizers } from "./finalization.ts";
|
|
7
|
+
|
|
8
|
+
export const privateDirectory = (path:string) => directory(path,true);
|
|
9
|
+
/** Pi's shared resource directory may be searchable; retained text files/subdirectories remain owner-only. */
|
|
10
|
+
export const projectProductDirectory = (path:string) => directory(path,false);
|
|
11
|
+
async function directory(path: string, ownerOnly:boolean): Promise<void> {
|
|
12
|
+
if (resolve(path) !== path) throw Error("absolute canonical product directory required");
|
|
13
|
+
try { await mkdir(path, { mode: 0o700 }); }
|
|
14
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
15
|
+
const state = await lstat(path);
|
|
16
|
+
if (!state.isDirectory() || state.isSymbolicLink() || state.mode & (ownerOnly?0o077:0o022) || state.uid !== process.getuid?.() || await realpath(path) !== path) throw Error(`owned non-writable${ownerOnly?" private":""} directory required: ${path}; existing permissions were not changed`);
|
|
17
|
+
}
|
|
18
|
+
export async function readProductJson(path: string, limit = 128 * 1024): Promise<unknown | null> {
|
|
19
|
+
try { const state = await lstat(path); if (!state.isFile() || state.isSymbolicLink() || state.mode & 0o077 || state.uid !== process.getuid?.() || state.nlink !== 1 || await realpath(path) !== path) throw Error(`private regular product file required: ${path}`); }
|
|
20
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
21
|
+
const snapshot = await readDailySnapshot(path, limit);
|
|
22
|
+
if (snapshot.status !== "read") throw Error(`product file unavailable: ${path}`);
|
|
23
|
+
const state = await lstat(path);
|
|
24
|
+
if (state.mode & 0o077 || state.uid !== process.getuid?.() || state.nlink !== 1 || await realpath(path) !== path) throw Error(`private regular product file required: ${path}`);
|
|
25
|
+
return parseRetentionJson(new TextDecoder("utf-8", { fatal: true }).decode(snapshot.bytes), limit);
|
|
26
|
+
}
|
|
27
|
+
export async function writeProductJson(path: string, value: unknown, replace = false): Promise<void> {
|
|
28
|
+
await (basename(dirname(path))===".pi"?projectProductDirectory:privateDirectory)(dirname(path));
|
|
29
|
+
const text = JSON.stringify(value, null, 2) + "\n";
|
|
30
|
+
if (Buffer.byteLength(text) > 128 * 1024) throw Error("product file bound exceeded");
|
|
31
|
+
if (!replace) { await writeFile(path, text, { mode: 0o600, flag: "wx" }); return; }
|
|
32
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
33
|
+
await runWithFinalizers(async()=>{await writeFile(temporary, text, { mode: 0o600, flag: "wx" }); await rename(temporary, path);},[{label:"product temporary cleanup",run:async()=>{await rm(temporary,{force:true});}}]);
|
|
34
|
+
}
|
package/src/skill-packages.ts
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* is pi's own convention and how pi itself loads them. Measured against `principal-pi-skills@2.3.1`:
|
|
7
|
-
*
|
|
8
|
-
* ```json
|
|
9
|
-
* "pi": { "skills": ["./decide", "./architect", "./plan", "./build", "./review", "./debug", "./git-ops"] }
|
|
10
|
-
* ```
|
|
11
|
-
*
|
|
12
|
-
* **A declaration, never a heuristic.** Walking `node_modules` looking for files called `SKILL.md` would
|
|
13
|
-
* find a package's test fixtures, its examples, and its vendored copies of someone else's skills — and
|
|
14
|
-
* would then offer to install them as spawnable sub-agents. A package that says which of its files are
|
|
15
|
-
* skills has said so on purpose, and that is the only list this reads.
|
|
16
|
-
*
|
|
17
|
-
* What it deliberately does NOT do: scan `~/.pi/agent/skills/`. Definitions already in a skill root are
|
|
18
|
-
* discovered by `loadDefinitions` and governed as they stand; copying them into a project would duplicate
|
|
19
|
-
* them under a name that shadows the original (project wins on collision), which is a change nobody asked
|
|
20
|
-
* for.
|
|
21
|
-
*
|
|
22
|
-
* **Everything read here comes from a third party**, so this module is also where the refusals live: a
|
|
23
|
-
* name, a declared capability id, or a path that cannot safely be written into a generated file is refused
|
|
24
|
-
* with a reason rather than passed on (R-77, R-78, R-80). `init` generates a shell file an operator
|
|
25
|
-
* `source`s; the only strings that may reach it are ones that survived a whitelist here.
|
|
2
|
+
* Setup discovers enabled Pi resources in place through its package resolver. Configured packages
|
|
3
|
+
* are never copied into another autoload root. For legacy npm installs unregistered with Pi only,
|
|
4
|
+
* the explicit package.json pi.skills declaration remains a scaffold source (ADR-0074).
|
|
5
|
+
* Names, ceilings and bytes are checked before they can enter the generated shell grant.
|
|
26
6
|
*/
|
|
27
7
|
|
|
28
8
|
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
29
9
|
import { homedir } from "node:os";
|
|
30
10
|
import { join, resolve, sep } from "node:path";
|
|
31
11
|
import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
|
|
12
|
+
import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
|
|
32
13
|
import { WILDCARD } from "./pi-tools.ts";
|
|
33
14
|
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
34
15
|
import { isSafeCapability } from "./capabilities.ts";
|
|
@@ -38,6 +19,8 @@ export interface DiscoveredSkill {
|
|
|
38
19
|
/** The file verbatim. `init` copies it rather than regenerating it, so nothing is lost in a round trip. */
|
|
39
20
|
text: string;
|
|
40
21
|
path: string;
|
|
22
|
+
/** Already enabled by Pi; setup must reference it rather than making a competing copy. */
|
|
23
|
+
referenced?: boolean;
|
|
41
24
|
}
|
|
42
25
|
|
|
43
26
|
/** Why a declared skill was refused before it could be planned. Each has a different fix. */
|
|
@@ -248,21 +231,63 @@ export function skillPackageRoots(cwd: string): string[] {
|
|
|
248
231
|
}
|
|
249
232
|
|
|
250
233
|
export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]> {
|
|
234
|
+
const resolved = await resolveSkillResources(cwd);
|
|
235
|
+
const packages: SkillPackage[] = [];
|
|
236
|
+
const seenSkills = new Set<string>();
|
|
237
|
+
const runtimeNames = new Set(resolved.skills.map(s => skillResourceName(s.path)));
|
|
238
|
+
const configuredRoots = new Set(resolved.configured.flatMap(p => p.installedPath ? [resolve(p.installedPath)] : []));
|
|
239
|
+
const configuredNames = new Set<string>(resolved.configured.flatMap(p => {
|
|
240
|
+
if (!p.source.startsWith("npm:")) return [];
|
|
241
|
+
const spec = p.source.slice(4);
|
|
242
|
+
const versionAt = spec.indexOf("@", 1);
|
|
243
|
+
return [versionAt < 0 ? spec : spec.slice(0, versionAt)];
|
|
244
|
+
}));
|
|
245
|
+
for (const root of configuredRoots) {
|
|
246
|
+
try { configuredNames.add(JSON.parse(await readFile(join(root, "package.json"), "utf8")).name); } catch { /* absent */ }
|
|
247
|
+
}
|
|
248
|
+
for (const resource of resolved.skills) {
|
|
249
|
+
const bytes = await readFile(resource.path).catch(() => null);
|
|
250
|
+
if (bytes === null) continue;
|
|
251
|
+
const text = bytes.toString("utf8");
|
|
252
|
+
const resourceName = skillResourceName(resource.path);
|
|
253
|
+
if (seenSkills.has(resourceName)) continue;
|
|
254
|
+
seenSkills.add(resourceName);
|
|
255
|
+
let name = resource.metadata.source;
|
|
256
|
+
let version = "local";
|
|
257
|
+
if (resource.metadata.origin === "package" && resource.metadata.baseDir) {
|
|
258
|
+
try {
|
|
259
|
+
const manifest = JSON.parse(await readFile(join(resource.metadata.baseDir, "package.json"), "utf8"));
|
|
260
|
+
name = manifest.name ?? name; version = manifest.version ?? version;
|
|
261
|
+
} catch { /* the resource can be used without optional display metadata */ }
|
|
262
|
+
} else name = `${resource.metadata.scope} skills`;
|
|
263
|
+
let pkg = packages.find(p => p.name === name && p.version === version);
|
|
264
|
+
if (!pkg) { pkg = { name, version, skills: [], refused: [], unreadable: [] }; packages.push(pkg); }
|
|
265
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) {
|
|
266
|
+
pkg.refused.push({ subject: resourceName, reason: "not-utf8", detail: [] });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const definition = parseSkillDefinition(resource.path, text);
|
|
270
|
+
if (!definition) continue;
|
|
271
|
+
const skill = { definition, text, path: resource.path, referenced: true };
|
|
272
|
+
const refusal = refusalFor(skill);
|
|
273
|
+
if (refusal) pkg.refused.push(refusal); else pkg.skills.push(skill);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Compatibility for npm installs that were never registered with Pi. Runtime discovery never scans
|
|
277
|
+
// node_modules. A configured package (including one disabled by a filter) must not re-enter here.
|
|
251
278
|
const dirs: string[] = [];
|
|
252
|
-
const seenNames = new Set<string>();
|
|
253
279
|
for (const root of skillPackageRoots(cwd)) await collectFrom(root, dirs);
|
|
254
|
-
|
|
255
|
-
const packages: SkillPackage[] = [];
|
|
280
|
+
const seenNames = new Set(packages.map(p => p.name));
|
|
256
281
|
for (const dir of dirs) {
|
|
282
|
+
if (configuredRoots.has(resolve(dir))) continue;
|
|
257
283
|
const found = await readSkillPackage(dir);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
packages.push(found);
|
|
263
|
-
}
|
|
284
|
+
if (!found || configuredNames.has(found.name) || seenNames.has(found.name)) continue;
|
|
285
|
+
seenNames.add(found.name);
|
|
286
|
+
found.skills = found.skills.filter(skill => !runtimeNames.has(skill.definition.name));
|
|
287
|
+
packages.push(found);
|
|
264
288
|
}
|
|
265
|
-
return packages
|
|
289
|
+
return packages;
|
|
290
|
+
|
|
266
291
|
}
|
|
267
292
|
|
|
268
293
|
/** Append every package directory under one `node_modules`, scoped packages included. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Read Pi's configured resource surface without locks, installs, extension execution or model calls. */
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
interface SkillResources {
|
|
7
|
+
skills: { path: string; enabled: boolean; metadata: { source: string; scope: string; origin: string; baseDir?: string } }[];
|
|
8
|
+
configured: { source: string; scope: string; installedPath?: string }[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function skillResourceName(path: string): string {
|
|
12
|
+
const parts = path.split(/[\\/]/);
|
|
13
|
+
const file = parts.at(-1) ?? "";
|
|
14
|
+
return file.toLowerCase() === "skill.md" ? parts.at(-2) ?? "" : file.replace(/\.md$/i, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function resolveSkillResources(cwd: string): Promise<SkillResources> {
|
|
18
|
+
const agentDir = getAgentDir();
|
|
19
|
+
const settingsManager = SettingsManager.fromStorage({
|
|
20
|
+
withLock(scope, read) {
|
|
21
|
+
const path = scope === "global" ? join(agentDir, "settings.json") : join(cwd, ".pi", "settings.json");
|
|
22
|
+
let text: string | undefined;
|
|
23
|
+
try { text = readFileSync(path, "utf8"); }
|
|
24
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
25
|
+
// This storage is intentionally read-only: no lock files or migrated settings are persisted.
|
|
26
|
+
read(text);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const errors = settingsManager.drainErrors();
|
|
30
|
+
if (errors.length) throw new Error(`Cannot discover skills: ${errors.map(e => `${e.scope} settings: ${e.error.message}`).join("; ")}`);
|
|
31
|
+
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
32
|
+
const resolved = await manager.resolve(async () => "skip");
|
|
33
|
+
// Pi has already applied package identity, scope, manifest and settings filters and precedence.
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
const skills = resolved.skills.filter(resource => resource.enabled).flatMap(resource => {
|
|
36
|
+
try {
|
|
37
|
+
const path = statSync(resource.path).isDirectory() ? join(resource.path, "SKILL.md") : resource.path;
|
|
38
|
+
return statSync(path).isFile() ? [{ ...resource, path }] : [];
|
|
39
|
+
} catch { return []; }
|
|
40
|
+
}).filter(resource => {
|
|
41
|
+
// Reserve identity before parsing: a malformed local override cannot reveal a wider package ceiling.
|
|
42
|
+
const name = skillResourceName(resource.path);
|
|
43
|
+
if (seen.has(name)) return false;
|
|
44
|
+
seen.add(name);
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
return { skills, configured: manager.listConfiguredPackages() };
|
|
48
|
+
}
|
package/src/work-command.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
} from "./work-ledger.ts";
|
|
18
18
|
import { withFileLock } from "./file-lock.ts";
|
|
19
19
|
import { resolveWorkSnapshotText } from "./work-ledger-snapshot.ts";
|
|
20
|
+
import { readProductJson, writeProductJson } from "./product-files.ts";
|
|
21
|
+
import { intentKey } from "./intent-control.ts";
|
|
20
22
|
|
|
21
23
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+\-]{0,127}$/;
|
|
22
24
|
const DIGEST = /^[a-f0-9]{64}$/;
|
|
@@ -88,7 +90,8 @@ export async function loadDeclaredWork(statePath: string): Promise<WorkFrozen<De
|
|
|
88
90
|
const ingestion = parseWorkLedgerText(ledger);
|
|
89
91
|
const projection = projectWorkLedger(ledger, { selectedSnapshot: value.selectedSnapshot, authority: null });
|
|
90
92
|
const obligation = projection.obligations.find(item => item.binding.obligation.digest === value.obligation.digest);
|
|
91
|
-
|
|
93
|
+
const selected = resolveWorkSnapshotText(ledger, value.selectedSnapshot);
|
|
94
|
+
if (!ingestion.complete || projection.scopeState !== "valid" || !obligation || projection.selectedSnapshot?.digest !== value.selectedSnapshot.snapshot.digest || intentKey(obligation.binding.intent) !== intentKey(value.intent) || intentKey(obligation.binding.policy) !== intentKey(value.policy) || !selected.scope || selected.scope.digest !== value.scope.digest || selected.scope.id !== value.scope.id || selected.scope.revision !== value.scope.revision) throw new Error("unbound");
|
|
92
95
|
return Object.freeze({ ...value, statePath: resolve(statePath) });
|
|
93
96
|
} catch {
|
|
94
97
|
throw new Error("invalid declared work state; no task was selected");
|
|
@@ -112,10 +115,12 @@ export async function declareWork(input: DeclareWorkInput): Promise<WorkFrozen<D
|
|
|
112
115
|
}
|
|
113
116
|
return withFileLock(resolved.statePath, "declared work", async () => {
|
|
114
117
|
const existing = await loadDeclaredWork(resolved.statePath);
|
|
115
|
-
if (existing) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
118
|
+
if (existing && (existing.id !== input.id || existing.outcomeDigest !== outcomeDigest)) throw new Error(`declared work id ${input.id} already names a different outcome`);
|
|
119
|
+
const outcomePath = join(projectPi, "work-outcomes", `${outcomeDigest}.json`);
|
|
120
|
+
const retained = await readProductJson(outcomePath);
|
|
121
|
+
if (retained && JSON.stringify(retained) !== JSON.stringify({ outcome: input.outcome.trim() })) throw Error("declared outcome identity conflict");
|
|
122
|
+
if (!retained) await writeProductJson(outcomePath, { outcome: input.outcome.trim() });
|
|
123
|
+
if (existing) return existing;
|
|
119
124
|
const pendingPath = `${resolved.statePath}.pending`;
|
|
120
125
|
let prepared: { version: "pi-daddy-work-preparation-v1"; id: string; outcomeDigest: string; createdAt: string };
|
|
121
126
|
try {
|
|
@@ -158,7 +163,7 @@ export async function declareWork(input: DeclareWorkInput): Promise<WorkFrozen<D
|
|
|
158
163
|
|
|
159
164
|
/** Persist an already-applied exact selection so the next governed dispatch cannot keep using the old cached declaration. */
|
|
160
165
|
export async function rebindDeclaredWork(state:WorkFrozen<DeclaredWorkState>,selectedSnapshot:NonNullable<WorkProjectionContext["selectedSnapshot"]>):Promise<WorkFrozen<DeclaredWorkState>>{
|
|
161
|
-
return withFileLock(state.statePath,"declared work rebind",async()=>{const current=await loadDeclaredWork(state.statePath);if(!current||
|
|
166
|
+
return withFileLock(state.statePath,"declared work rebind",async()=>{const current=await loadDeclaredWork(state.statePath);if(!current||intentKey(current.selectedSnapshot)!==intentKey(state.selectedSnapshot))throw Error("declared work rebind is stale; new dispatch remains unsupported");const text=await readFile(state.ledgerPath,"utf8"),resolved=resolveWorkSnapshotText(text,selectedSnapshot),binding=resolved.snapshot?.bindings[0];if(resolved.scopeState!=="valid"||!resolved.scope||!resolved.snapshot||!binding)throw Error("applied declared selection cannot bind future dispatch");const stored:StoredDeclaredWorkState={version:"pi-daddy-declared-work-v1",id:state.id,outcomeDigest:state.outcomeDigest,ledgerPath:state.ledgerPath,grantLedgerPath:state.grantLedgerPath,selectedSnapshot,scope:{kind:resolved.scope.kind,id:resolved.scope.id,revision:resolved.scope.revision,digest:resolved.scope.digest},intent:binding.intent,obligation:binding.obligation,policy:binding.policy};const temporary=`${state.statePath}.${process.pid}.${randomUUID()}.tmp`;try{await writeFile(temporary,`${JSON.stringify(stored,null,2)}\n`,{encoding:"utf8",mode:0o600,flag:"wx"});await rename(temporary,state.statePath);}finally{await rm(temporary,{force:true});}return Object.freeze({...stored,statePath:state.statePath});});
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
export type DeclaredOccurrenceIdentity = Pick<WorkOccurrencePayload["labels"], "toolCallId" | "taskId" | "workspaceId" | "definitionDigest" | "configurationDigest" | "modelId" | "effortId"> & {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createControlJournal, controlJournal, type ControlBinding } from "./control-journal.ts";
|
|
2
|
+
import { experimentHash, closed, cloneExperiment } from "./experiment-contract.ts";
|
|
3
|
+
import { buildAdoptionBinding, buildRollbackRequest, validateAdoptionReceipt, authorizeRollback, type AdoptionBinding, type AdoptionReceipt, type RollbackRequest } from "./vendor/adoption.ts";
|
|
4
|
+
import { factoryAuthority, requireFactoryAuthority, type FactoryAuthority } from "./factory-contract.ts";
|
|
5
|
+
import { WORK_EFFORTS, type WorkSetup } from "./work-setup.ts";
|
|
6
|
+
|
|
7
|
+
/** Additive profile: ONLY model/effort may change. Task IDs and definition names stay fixed. Normal definition-digest/grant checks still run; this is not a filesystem pin. */
|
|
8
|
+
export interface WorkPolicy { version: "ordinary-work-policy-v1"; scopeDigest: string; assessmentPolicyDigest: string; profiles: { taskId: string; agent: string | null; model: string; thinking: typeof WORK_EFFORTS[number] }[] }
|
|
9
|
+
export interface WorkPolicyInitial { version: "ordinary-work-registry-v1"; authorityId: string; scopeDigest: string; baseline: WorkPolicy }
|
|
10
|
+
export type WorkPolicyRegistry = ControlBinding<WorkPolicyInitial>;
|
|
11
|
+
export interface WorkPolicyActivation { version: "ordinary-work-activation-v1"; requestId: string; expectedRevision: number; expectedCandidateDigest: string; candidate: WorkPolicy; binding: AdoptionBinding; receipt: AdoptionReceipt }
|
|
12
|
+
const sha = (x: unknown) => typeof x === "string" && /^[a-f0-9]{64}$/.test(x);
|
|
13
|
+
export function workPolicy(input: WorkPolicy): WorkPolicy {
|
|
14
|
+
const p = cloneExperiment(input); closed(p, ["version", "scopeDigest", "assessmentPolicyDigest", "profiles"]);
|
|
15
|
+
if (p.version !== "ordinary-work-policy-v1" || !sha(p.scopeDigest) || !sha(p.assessmentPolicyDigest) || !Array.isArray(p.profiles) || !p.profiles.length || p.profiles.length > 8) throw Error("unsupported ordinary policy profile");
|
|
16
|
+
for (const t of p.profiles) { closed(t, ["taskId", "agent", "model", "thinking"]); if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/.test(t.taskId) || !(t.agent === null || typeof t.agent === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(t.agent)) || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._:/@+-]+$/.test(t.model) || t.model.length > 128 || !WORK_EFFORTS.includes(t.thinking)) throw Error("invalid model/effort profile; no fallback"); }
|
|
17
|
+
if (new Set(p.profiles.map(t => t.taskId)).size !== p.profiles.length) throw Error("duplicate profile task");
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
export const workPolicyDigest = (p: WorkPolicy) => experimentHash(workPolicy(p));
|
|
21
|
+
export const workPolicyActivationDigest = (r: WorkPolicyActivation) => experimentHash(r);
|
|
22
|
+
export function policyForSetup(setup: WorkSetup, scopeDigest: string, assessmentPolicyDigest: string): WorkPolicy { return workPolicy({ version: "ordinary-work-policy-v1", scopeDigest, assessmentPolicyDigest, profiles: setup.tasks.map(({ id: taskId, agent, model, thinking }) => ({ taskId, agent, model, thinking })) }); }
|
|
23
|
+
function compatible(baseline: WorkPolicy, candidate: WorkPolicy) {
|
|
24
|
+
const shape = (p: WorkPolicy) => p.profiles.map(t => ({ taskId: t.taskId, agent: t.agent })).sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
25
|
+
if (baseline.scopeDigest !== candidate.scopeDigest || baseline.assessmentPolicyDigest !== candidate.assessmentPolicyDigest || experimentHash(shape(baseline)) !== experimentHash(shape(candidate))) throw Error("policy changes may only select models/effort for the same scoped definitions; no instruction, capability, topology or assessment expansion");
|
|
26
|
+
}
|
|
27
|
+
function replay(binding: WorkPolicyRegistry, events: Record<string, unknown>[]) {
|
|
28
|
+
const initial = binding.initial; closed(initial, ["version", "authorityId", "scopeDigest", "baseline"]);
|
|
29
|
+
if (initial.version !== "ordinary-work-registry-v1" || !/^[a-zA-Z0-9:_-]{1,128}$/.test(initial.authorityId) || initial.scopeDigest !== workPolicy(initial.baseline).scopeDigest) throw Error("invalid policy registry binding");
|
|
30
|
+
let candidate = initial.baseline, revision = 0, active: WorkPolicyActivation | null = null;
|
|
31
|
+
let lastChange: {operation:"activate"|"rollback";requestId:string;adoptionId:string}|null=null;
|
|
32
|
+
const lineage: (WorkPolicyActivation | null)[] = [], policies = new Map([[workPolicyDigest(candidate), candidate]]), requests = new Map<string, string>(), orders = new Map<string, { candidateDigest: string; adoptionId: string | null; revision: number }>();
|
|
33
|
+
for (const e of events) {
|
|
34
|
+
if (e.type === "activate") {
|
|
35
|
+
closed(e, ["type", "request"]); const r = e.request as unknown as WorkPolicyActivation; closed(r, ["version", "requestId", "expectedRevision", "expectedCandidateDigest", "candidate", "binding", "receipt"]);
|
|
36
|
+
const { version, id, ...draft } = r.binding;
|
|
37
|
+
if (r.version !== "ordinary-work-activation-v1" || !/^[a-zA-Z0-9:_-]{1,128}$/.test(r.requestId) || requests.has(r.requestId) || r.expectedRevision !== revision || r.expectedCandidateDigest !== workPolicyDigest(candidate) || version !== "adoption-binding-v1" || buildAdoptionBinding(draft).id !== id || r.binding.scopeDigest !== initial.scopeDigest || r.binding.candidateDigest !== workPolicyDigest(r.candidate) || r.binding.rollbackCandidateDigest !== workPolicyDigest(candidate) || r.binding.assessmentPolicyDigest !== r.candidate.assessmentPolicyDigest || r.receipt.bindingId !== id || r.receipt.authorityId !== initial.authorityId) throw Error("invalid activation sequence");
|
|
38
|
+
validateAdoptionReceipt(r.receipt, r.binding, { id: r.receipt.authorityId, adoptions: [r.binding.id], rollbacks: [] }, { experimentDigest: r.binding.experimentDigest, candidateDigest: r.binding.candidateDigest, scopeDigest: r.binding.scopeDigest, assessmentPolicyDigest: r.binding.assessmentPolicyDigest, eligible: true }, 0); // Structural replay only; activate/pin independently authorize again at use.
|
|
39
|
+
compatible(initial.baseline, workPolicy(r.candidate)); requests.set(r.requestId, workPolicyActivationDigest(r)); lineage.push(active); active = r; candidate = r.candidate; policies.set(workPolicyDigest(candidate), candidate); revision++; lastChange={operation:"activate",requestId:r.requestId,adoptionId:r.receipt.id};
|
|
40
|
+
} else if (e.type === "rollback") {
|
|
41
|
+
closed(e, ["type", "request", "fromRevision"]); const r = e.request as unknown as RollbackRequest;
|
|
42
|
+
if (!active || experimentHash(buildRollbackRequest(active.receipt,r.reason,[...r.evidence],r.expiresAt)) !== experimentHash(r) || e.fromRevision !== revision || requests.has(r.id) || r.adoptionId !== active.receipt.id || r.scopeDigest !== initial.scopeDigest || r.restoreCandidateDigest !== active.binding.rollbackCandidateDigest || !policies.has(r.restoreCandidateDigest)) throw Error("invalid rollback sequence");
|
|
43
|
+
candidate = policies.get(r.restoreCandidateDigest)!; if(!lineage.length)throw Error("missing rollback lineage"); active = lineage.pop() ?? null; if((active?.binding.candidateDigest??workPolicyDigest(initial.baseline))!==workPolicyDigest(candidate))throw Error("rollback lineage mismatch"); requests.set(r.id, experimentHash(r)); revision++; lastChange={operation:"rollback",requestId:r.id,adoptionId:r.adoptionId};
|
|
44
|
+
} else if (e.type === "order-pin") {
|
|
45
|
+
closed(e, ["type", "orderId", "candidateDigest", "adoptionId", "revision"]);
|
|
46
|
+
if (typeof e.orderId !== "string" || !/^[a-zA-Z0-9:_-]{1,128}$/.test(e.orderId) || orders.has(e.orderId) || e.candidateDigest !== workPolicyDigest(candidate) || e.adoptionId !== (active?.receipt.id ?? null) || e.revision !== revision) throw Error("invalid future-order pin");
|
|
47
|
+
orders.set(e.orderId, { candidateDigest: String(e.candidateDigest), adoptionId: e.adoptionId as string | null, revision });
|
|
48
|
+
} else throw Error("unknown work policy registry event");
|
|
49
|
+
}
|
|
50
|
+
return { candidate, revision, active, requests, orders, lastChange };
|
|
51
|
+
}
|
|
52
|
+
export async function createWorkPolicyRegistry(input: { directory: string; authorityId: string; baseline: WorkPolicy }): Promise<WorkPolicyRegistry> {
|
|
53
|
+
const baseline = workPolicy(input.baseline);
|
|
54
|
+
return createControlJournal(input.directory, { version: "ordinary-work-registry-v1", authorityId: input.authorityId, scopeDigest: baseline.scopeDigest, baseline });
|
|
55
|
+
}
|
|
56
|
+
export function openWorkPolicyRegistry(binding: WorkPolicyRegistry) {
|
|
57
|
+
const store = controlJournal(binding);
|
|
58
|
+
const inspect = async () => { const s = replay(binding, (await store.read()).events); return { version: "factory-registry-view-v1" as const, profile: "ordinary-model-effort-v1", scopeDigest: binding.initial.scopeDigest, candidateDigest: workPolicyDigest(s.candidate), candidate: s.candidate, revision: s.revision, activation: s.active, lastChange:s.lastChange, application:s.lastChange?"applied":"not-performed", requestId:s.lastChange?.requestId??null, orders: [...s.orders].map(([orderId, pin]) => ({ orderId, ...pin })), acceptance: "not-assessed" }; };
|
|
59
|
+
const authority = (host: FactoryAuthority | null) => requireFactoryAuthority(factoryAuthority(host), binding.initial.authorityId);
|
|
60
|
+
const eligible = (r: WorkPolicyActivation, host: FactoryAuthority) => { const facts = host.facts.find(f => f.bindingId === r.binding.id)?.facts; if (!facts) throw Error("current independent eligibility facts required"); validateAdoptionReceipt(r.receipt, r.binding, host.adoption, facts, Date.now()); };
|
|
61
|
+
return { inspect,
|
|
62
|
+
/** Pure validated replay of the proposed rollback; resolves original retained bytes, never a mutable named file. */
|
|
63
|
+
async previewRollback(request: RollbackRequest) {
|
|
64
|
+
const r=cloneExperiment(request),events=(await store.read()).events,s=replay(binding,events);
|
|
65
|
+
const restored=replay(binding,[...events,{type:"rollback",request:r,fromRevision:s.revision}]);
|
|
66
|
+
return {requestId:r.id,adoptionId:r.adoptionId,expectedRevision:s.revision,restoreCandidateDigest:workPolicyDigest(restored.candidate),candidate:cloneExperiment(restored.candidate)};
|
|
67
|
+
},
|
|
68
|
+
async activate(request: WorkPolicyActivation, host: FactoryAuthority | null) {
|
|
69
|
+
const r = cloneExperiment(request), a = authority(host); if (!a.activationDigests.includes(workPolicyActivationDigest(r))) throw Error("exact independent activation approval required");
|
|
70
|
+
await store.transaction(async (events, append) => { eligible(r,a); const s = replay(binding, events), old = s.requests.get(r.requestId); if (old) { if (old !== workPolicyActivationDigest(r)) throw Error("immutable activation ID"); return; } const event = { type: "activate", request: r }; replay(binding, [...events, event]); await append(event); }); return inspect();
|
|
71
|
+
},
|
|
72
|
+
async rollback(request: RollbackRequest, host: FactoryAuthority | null) {
|
|
73
|
+
const r = cloneExperiment(request), a = authority(host);
|
|
74
|
+
await store.transaction(async (events, append) => { const s = replay(binding, events), old = s.requests.get(r.id); if (old) { if (old !== experimentHash(r) || !a.adoption?.rollbacks.includes(r.id)) throw Error("rollback identity/authority mismatch"); return; } if (!s.active) throw Error("no active adoption to roll back"); authorizeRollback(s.active.receipt, r, a.adoption, Date.now(), { adoptionId: s.active.receipt.id, candidateDigest: workPolicyDigest(s.candidate), scopeDigest: binding.initial.scopeDigest }); const event = { type: "rollback", request: r, fromRevision: s.revision }; replay(binding, [...events, event]); await append(event); }); return { ...await inspect(), application: "applied" as const, requestId: r.id, grantExpansion: false };
|
|
75
|
+
},
|
|
76
|
+
async pin(orderId: string, host: FactoryAuthority | null, expected: { revision: number; candidateDigest: string }) {
|
|
77
|
+
const a = authority(host);
|
|
78
|
+
return store.transaction(async (events, append) => { const s = replay(binding, events); if(s.revision!==expected.revision||workPolicyDigest(s.candidate)!==expected.candidateDigest)throw Error("registry changed before order pin"); if (s.orders.has(orderId)) throw Error("order already pinned; never relaunch on readback"); if (s.active) eligible(s.active, a); const event = { type: "order-pin", orderId, candidateDigest: workPolicyDigest(s.candidate), adoptionId: s.active?.receipt.id ?? null, revision: s.revision }; replay(binding, [...events, event]); await append(event); return { ...event, candidate: s.candidate }; });
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/work-run.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
5
|
+
import { createControlJournal, controlJournal, type ControlBinding } from "./control-journal.ts";
|
|
6
|
+
import { composeStepTask, HANDOFF_MAX_BYTES } from "./chain.ts";
|
|
7
|
+
import { splitBudget } from "./fanout.ts";
|
|
8
|
+
import { workSetup, type WorkSetup, type WorkTaskSetup } from "./work-setup.ts";
|
|
9
|
+
|
|
10
|
+
export interface WorkRunOutcome { ok: boolean; text: string; reason?: string; control?: "failed"; exitCode: number | null; truncated?: boolean; aborted?: boolean; timedOut?: boolean }
|
|
11
|
+
export interface WorkRunTask { id: string; state: "waiting" | "running" | "finished" | "failed" | "blocked"; executionId: string | null; resultPath: string | null; resultDigest: string | null; reason: string | null }
|
|
12
|
+
export interface WorkPolicyPin { orderId: string; candidateDigest: string; adoptionId: string | null; revision: number; scopeDigest: string }
|
|
13
|
+
export interface WorkRunInitial { version: "work-run-v1"; runId: string; selectionDigest: string; setup: WorkSetup; policyPin: WorkPolicyPin | null }
|
|
14
|
+
export interface WorkRunResult { binding: ControlBinding<WorkRunInitial>; tasks: WorkRunTask[]; acceptance: "not-assessed" }
|
|
15
|
+
/** Finite DAG scheduler only. Execution, grants, cancellation, deadlines and observation remain original-owner operations. */
|
|
16
|
+
export async function runWorkSetup(input: {
|
|
17
|
+
directory: string; selectionDigest: string; setup: WorkSetup; budget: number; signal?: AbortSignal; orderId?: string; policyPin?: WorkPolicyPin | null;
|
|
18
|
+
execute(task: WorkTaskSetup, prompt: string, perChildBudget: number, started: (executionId: string) => Promise<void>): Promise<WorkRunOutcome>;
|
|
19
|
+
onUpdate?: (tasks: readonly WorkRunTask[]) => void;
|
|
20
|
+
priorities?: string[];
|
|
21
|
+
/** Read the existing owner hold; waiting here never grants or performs a control action. */
|
|
22
|
+
admissionOpen?: () => boolean;
|
|
23
|
+
}): Promise<WorkRunResult> {
|
|
24
|
+
const setup = workSetup(input.setup), split = splitBudget(input.budget, setup.tasks.length);
|
|
25
|
+
if (!split.ok) throw Error(split.reason);
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(input.selectionDigest)) throw Error("exact selected work digest required");
|
|
27
|
+
const order = input.priorities ?? setup.tasks.map(t => t.id);
|
|
28
|
+
if (order.length !== setup.tasks.length || new Set(order).size !== order.length || order.some(id => !setup.tasks.some(t => t.id === id))) throw Error("priority must name each task exactly once");
|
|
29
|
+
const binding = await createControlJournal(input.directory, { version: "work-run-v1" as const, runId: input.orderId ?? randomUUID(), selectionDigest: input.selectionDigest, setup, policyPin: input.policyPin ?? null });
|
|
30
|
+
const journal = controlJournal(binding), outcomes = new Map<string, WorkRunOutcome>();
|
|
31
|
+
const tasks: WorkRunTask[] = order.map(id => ({ id, state: "waiting", executionId: null, resultPath: null, resultDigest: null, reason: null }));
|
|
32
|
+
const running = new Map<string, Promise<void>>();
|
|
33
|
+
let persistenceFailure: unknown;
|
|
34
|
+
const publish = () => { try { input.onUpdate?.(tasks.map(t => ({ ...t }))); } catch { /* display is not execution */ } };
|
|
35
|
+
const record = (task: WorkRunTask) => journal.transaction(async (_events, append) => append({ type: "task", ...task }));
|
|
36
|
+
while (tasks.some(t => t.state === "waiting") || running.size) {
|
|
37
|
+
for (const row of tasks.filter(t => t.state === "waiting")) {
|
|
38
|
+
const spec = setup.tasks.find(t => t.id === row.id)!;
|
|
39
|
+
if (input.signal?.aborted || persistenceFailure || spec.dependencies.some(d => ["failed", "blocked"].includes(tasks.find(t => t.id === d)!.state))) {
|
|
40
|
+
row.state = "blocked"; row.reason = input.signal?.aborted ? "Cancelled before admission" : persistenceFailure ? "Result/control persistence failed; no further admission" : "A dependency did not finish successfully";
|
|
41
|
+
try { await record(row); } catch (error) { persistenceFailure ??= error; }
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (input.admissionOpen?.() === false || running.size >= setup.maxParallel || !spec.dependencies.every(d => tasks.find(t => t.id === d)!.state === "finished")) continue;
|
|
45
|
+
row.state = "running";
|
|
46
|
+
const pending = (async () => {
|
|
47
|
+
try {
|
|
48
|
+
const previous = spec.dependencies.map(d => `${d}\n${outcomes.get(d)!.text}`).join("\n\n");
|
|
49
|
+
if (Buffer.byteLength(previous) > HANDOFF_MAX_BYTES) throw Error("Complete predecessor output exceeds the 32 KiB handoff bound; dependent work was not launched");
|
|
50
|
+
await record(row);
|
|
51
|
+
const outcome = await input.execute(spec, composeStepTask(spec.outcome, spec.dependencies.length ? previous : undefined), split.perChild, async executionId => { row.executionId = executionId; await record(row); publish(); });
|
|
52
|
+
outcomes.set(spec.id, outcome);
|
|
53
|
+
if (Buffer.byteLength(outcome.text) > 1024 * 1024) throw Error("original child result exceeds retention bound");
|
|
54
|
+
const bytes = Buffer.from(outcome.text), file = `${spec.id}.txt`;
|
|
55
|
+
await writeFile(join(binding.directory, file), bytes, { mode: 0o600, flag: "wx" });
|
|
56
|
+
row.resultPath = join(binding.directory, file); row.resultDigest = createHash("sha256").update(bytes).digest("hex");
|
|
57
|
+
row.state = outcome.ok && !outcome.control && !outcome.truncated ? "finished" : "failed";
|
|
58
|
+
row.reason = row.state === "finished" ? null : outcome.reason ?? (outcome.control ? "Observation/control failed" : outcome.truncated ? "Output truncated" : "Attempt failed");
|
|
59
|
+
} catch (error) { row.state = "failed"; row.reason = String(error); }
|
|
60
|
+
try { await record(row); } catch (error) { persistenceFailure ??= error; row.state = "failed"; row.reason = `Control acknowledgement unknown: ${String(error)}`; }
|
|
61
|
+
publish();
|
|
62
|
+
})().finally(() => running.delete(row.id));
|
|
63
|
+
running.set(row.id, pending); publish();
|
|
64
|
+
}
|
|
65
|
+
publish();
|
|
66
|
+
if (running.size) await Promise.race(running.values());
|
|
67
|
+
else if(tasks.some(t=>t.state==="waiting"))await wait(100,undefined,{signal:input.signal}).catch(error=>{if(!input.signal?.aborted)throw error;});
|
|
68
|
+
}
|
|
69
|
+
// No detached promises, restart relaunch, refund or retry. Original children have all settled here.
|
|
70
|
+
if (persistenceFailure) throw Error(`Work results may exist but control persistence failed: ${String(persistenceFailure)}`);
|
|
71
|
+
return { binding, tasks, acceptance: "not-assessed" };
|
|
72
|
+
}
|
|
73
|
+
export async function inspectWorkRun(binding: ControlBinding<WorkRunInitial>): Promise<WorkRunResult> {
|
|
74
|
+
workSetup(binding.initial.setup);
|
|
75
|
+
if (binding.initial.version !== "work-run-v1") throw Error("unsupported work run");
|
|
76
|
+
const rows = new Map<string, WorkRunTask>();
|
|
77
|
+
for (const event of (await controlJournal(binding).read()).events) if (event.type === "task") rows.set(String(event.id), event as unknown as WorkRunTask);
|
|
78
|
+
return { binding, tasks: binding.initial.setup.tasks.map(t => rows.get(t.id) ?? { id: t.id, state: "waiting", executionId: null, resultPath: null, resultDigest: null, reason: "No launch acknowledgement; do not infer or retry" }), acceptance: "not-assessed" };
|
|
79
|
+
}
|