pi-daddy 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/PRODUCT-GUIDE.md +125 -0
  3. package/README.md +16 -1
  4. package/REQUIREMENTS.md +41 -0
  5. package/dist/cli.d.ts +2 -2
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +35 -7
  8. package/dist/cli.js.map +1 -1
  9. package/dist/daily-dashboard-host.d.ts +14 -1
  10. package/dist/daily-dashboard-host.d.ts.map +1 -1
  11. package/dist/daily-dashboard-host.js +40 -17
  12. package/dist/daily-dashboard-host.js.map +1 -1
  13. package/dist/daily-panel.d.ts +36 -0
  14. package/dist/daily-panel.d.ts.map +1 -0
  15. package/dist/daily-panel.js +84 -0
  16. package/dist/daily-panel.js.map +1 -0
  17. package/dist/daily-view-render.d.ts +2 -1
  18. package/dist/daily-view-render.d.ts.map +1 -1
  19. package/dist/daily-view-render.js +2 -1
  20. package/dist/daily-view-render.js.map +1 -1
  21. package/dist/daily-view.d.ts +3 -0
  22. package/dist/daily-view.d.ts.map +1 -1
  23. package/dist/daily-view.js +6 -0
  24. package/dist/daily-view.js.map +1 -1
  25. package/dist/dashboard-cli.d.ts +2 -0
  26. package/dist/dashboard-cli.d.ts.map +1 -1
  27. package/dist/dashboard-cli.js +37 -15
  28. package/dist/dashboard-cli.js.map +1 -1
  29. package/dist/dashboard-host-contract.d.ts +2 -0
  30. package/dist/dashboard-host-contract.d.ts.map +1 -1
  31. package/dist/dashboard-host-contract.js.map +1 -1
  32. package/dist/dashboard-host-transport.d.ts.map +1 -1
  33. package/dist/dashboard-host-transport.js +6 -3
  34. package/dist/dashboard-host-transport.js.map +1 -1
  35. package/dist/dashboard-host.d.ts +30 -2
  36. package/dist/dashboard-host.d.ts.map +1 -1
  37. package/dist/dashboard-host.js +73 -11
  38. package/dist/dashboard-host.js.map +1 -1
  39. package/dist/dashboard-menu.d.ts +16 -0
  40. package/dist/dashboard-menu.d.ts.map +1 -0
  41. package/dist/dashboard-menu.js +33 -0
  42. package/dist/dashboard-menu.js.map +1 -0
  43. package/dist/index.d.ts +7 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +7 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/learning-connection.d.ts +127 -0
  48. package/dist/learning-connection.d.ts.map +1 -0
  49. package/dist/learning-connection.js +54 -0
  50. package/dist/learning-connection.js.map +1 -0
  51. package/dist/measured-order.d.ts.map +1 -1
  52. package/dist/measured-order.js +63 -5
  53. package/dist/measured-order.js.map +1 -1
  54. package/dist/model-preflight.d.ts +5 -0
  55. package/dist/model-preflight.d.ts.map +1 -1
  56. package/dist/model-preflight.js +9 -0
  57. package/dist/model-preflight.js.map +1 -1
  58. package/dist/product-files.d.ts +6 -0
  59. package/dist/product-files.d.ts.map +1 -0
  60. package/dist/product-files.js +55 -0
  61. package/dist/product-files.js.map +1 -0
  62. package/dist/work-command.d.ts.map +1 -1
  63. package/dist/work-command.js +14 -6
  64. package/dist/work-command.js.map +1 -1
  65. package/dist/work-policy-registry.d.ts +133 -0
  66. package/dist/work-policy-registry.d.ts.map +1 -0
  67. package/dist/work-policy-registry.js +128 -0
  68. package/dist/work-policy-registry.js.map +1 -0
  69. package/dist/work-run.d.ts +56 -0
  70. package/dist/work-run.d.ts.map +1 -0
  71. package/dist/work-run.js +102 -0
  72. package/dist/work-run.js.map +1 -0
  73. package/dist/work-setup.d.ts +40 -0
  74. package/dist/work-setup.d.ts.map +1 -0
  75. package/dist/work-setup.js +174 -0
  76. package/dist/work-setup.js.map +1 -0
  77. package/extensions/daily-dashboard-session.ts +16 -7
  78. package/extensions/execute-child.ts +2 -2
  79. package/extensions/grants-command.ts +2 -2
  80. package/extensions/grants.ts +6 -2
  81. package/extensions/learning-outcome-session.ts +38 -0
  82. package/extensions/learning-session.ts +59 -0
  83. package/extensions/ordinary-runtime.ts +2 -2
  84. package/extensions/run-delegation.ts +2 -2
  85. package/extensions/work-policy-session.ts +139 -0
  86. package/extensions/work-run-session.ts +75 -0
  87. package/extensions/work-runtime.ts +2 -2
  88. package/extensions/work-session.ts +132 -0
  89. package/package.json +9 -1
  90. package/src/cli.ts +26 -7
  91. package/src/daily-dashboard-host.ts +32 -15
  92. package/src/daily-panel.ts +80 -0
  93. package/src/daily-view-render.ts +2 -1
  94. package/src/daily-view.ts +8 -0
  95. package/src/dashboard-cli.ts +29 -17
  96. package/src/dashboard-host-contract.ts +2 -0
  97. package/src/dashboard-host-transport.ts +7 -4
  98. package/src/dashboard-host.ts +48 -13
  99. package/src/dashboard-menu.ts +28 -0
  100. package/src/index.ts +7 -1
  101. package/src/learning-connection.ts +70 -0
  102. package/src/measured-order.ts +8 -3
  103. package/src/model-preflight.ts +9 -0
  104. package/src/product-files.ts +34 -0
  105. package/src/work-command.ts +11 -6
  106. package/src/work-policy-registry.ts +81 -0
  107. package/src/work-run.ts +79 -0
  108. package/src/work-setup.ts +141 -0
@@ -10,6 +10,8 @@ import { parseDashboardLedger } from "./dashboard-projection.ts";
10
10
  import { renderDashboard } from "./dashboard-render.ts";
11
11
  import { createDailyViewReader, isDailyViewReader, readDailyView, type DailyViewOptions } from "./daily-view.ts";
12
12
  import { renderDailyView } from "./daily-view-render.ts";
13
+ import { renderDailyPanel, panelLines, learningSummary, type PanelAction, type WorkPresentation } from "./daily-panel.ts";
14
+ import { createDashboardMenu } from "./dashboard-menu.ts";
13
15
  import { projectWorkLedger, type WorkProjectionContext } from "./work-ledger.ts";
14
16
  import { parseRetentionJson } from "./retention-json.ts";
15
17
  import { createInterface } from "node:readline";
@@ -49,6 +51,7 @@ export interface DashboardFrameOptions {
49
51
  debrief?: DebriefPresenter;
50
52
  debriefJson?: boolean;
51
53
  connected?: DashboardConnection;
54
+ actionMenu?: ReturnType<typeof createDashboardMenu>;
52
55
  }
53
56
  export async function dashboardHostAction(host:DashboardConnection,line:string){
54
57
  if(!isDashboardConnection(host)||Buffer.byteLength(line)>60000)throw Error("genuine bounded dashboard connection required");
@@ -60,10 +63,10 @@ export async function dashboardHostAction(host:DashboardConnection,line:string){
60
63
 
61
64
  /** A fulfilled host call is not necessarily an applied native effect; keep those facts distinct in the UI. */
62
65
  export function dashboardActionFeedback(command:string,outcome:unknown):string{
63
- const value=outcome&&typeof outcome==="object"?outcome as {state?:unknown;result?:unknown}:{};
66
+ const value=outcome&&typeof outcome==="object"?outcome as {state?:unknown;result?:unknown;error?:unknown;reason?:unknown}:{};
64
67
  const state=typeof value.state==="string"?value.state:"unrecognised host response";
65
68
  if(state==="failed-or-unknown"||state==="readback-only")return `NO ACTION CLAIM: ${command} returned ${state}; inspect/reconcile explicitly.`;
66
- if(state!=="acknowledged")return `NOT APPLIED: ${command} returned ${state}.`;
69
+ if(state!=="acknowledged")return `NOT APPLIED: ${command} returned ${state}.${typeof (value.error??value.reason)==="string"?` ${String(value.error??value.reason).slice(0,1024)}`:""}`;
67
70
  const result=value.result&&typeof value.result==="object"?value.result as {application?:unknown}:null;
68
71
  // A retained records[] snapshot has no outer dashboard request correlation. Never select an old receipt.
69
72
  const nativeApplication=typeof result?.application==="string"?result.application:undefined;
@@ -110,15 +113,14 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
110
113
  if(options.connected){
111
114
  if(!isDashboardConnection(options.connected))throw Error("original dashboard connection required");
112
115
  let view:Awaited<ReturnType<DashboardConnection["frame"]>>;
113
- try{view=await options.connected.frame();}catch(error){return `PI-DADDY — CONNECTED HOST ERROR\n${error instanceof Error ? error.message : String(error)}\nNo prior acceptance, presentation or continuity substituted. No action retried.`;}
116
+ try{view=await options.connected.frame();}catch(error){options.actionMenu?.clear();return panelLines(["PI-DADDY — Disconnected", "The original host is unavailable. No action was retried.", "Return to Pi: /grants host, then /grants dashboard.", ...(options.details?[String(error)]:[])],options.width);}
114
117
  if(options.dailyJson||options.debriefJson)return JSON.stringify(view);
115
118
  const source=view.source as {daily?:Parameters<typeof renderDailyView>[0]}|null;
116
- const actions=(view.actions as {key:string;label:string}[]|undefined)??[];
117
- const hostError=typeof view.error==="string"&&view.error?`HOST ERROR — ${view.error}`:null;
118
- return ["PI-DADDY — CONNECTED HOST (snapshot; identity/acceptance not authenticated)",hostError,source?.daily?renderDailyView(source.daily,options.width):"Source unavailable; no previous acceptance substituted.",
119
- view.debrief?renderDebrief(view.debrief as Parameters<typeof renderDebrief>[0],options.width):"Debrief deferred/closed. No implicit presentation or steering.",
120
- `Host control ${view.control}; acknowledgement ${view.acknowledgement}. Attention reserved ${view.attention.attentionUsed}/5 (not proof of delivery). Tip ${view.tip}.`,
121
- actions.length?`ACTIONS — type one command and Enter\n${actions.map(action=>` ${action.key} — ${action.label}`).join("\n")}`:"ACTIONS — none currently authorized; refresh never acts."].filter((line):line is string=>Boolean(line)).join("\n\n");
119
+ const actions=(view.actions as PanelAction[]|undefined)??[];
120
+ const menu=(options.actionMenu??createDashboardMenu()).show(view.tip,actions);
121
+ const learning=view.debrief?["Retained review open below"]:learningSummary(view.learning);
122
+ const summary=source?.daily?renderDailyPanel(source.daily,options.width,{details:options.details,connected:true,presentation:view.presentation as WorkPresentation|null,error:view.error,controls:view.controls,control:view.control,actions:menu,learning}):panelLines(["PI-DADDY — Needs you","Work observation unavailable. Capture current work or open Details.",...menu.map(a=>`${a.choice} ${a.label}`)],options.width);
123
+ return [summary,view.debrief?renderDebrief(view.debrief as Parameters<typeof renderDebrief>[0],options.width):"",options.details?panelLines([`Host control ${view.control}; acknowledgement ${view.acknowledgement}. Attention reserved ${view.attention.attentionUsed}/5. Tip ${view.tip}.`,JSON.stringify(view.controls),JSON.stringify(view.learning),...actions.map(a=>`${a.key} — ${a.label}`)],options.width):""].filter(Boolean).join("\n\n");
122
124
  }
123
125
  if (options.debrief || options.debriefJson) {
124
126
  if (!isDebriefPresenter(options.debrief)) return "PI-DADDY — DEBRIEF UNAVAILABLE: genuine presenter/host missing";
@@ -128,7 +130,7 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
128
130
  try {
129
131
  if (options.dailyReader !== undefined && !isDailyViewReader(options.dailyReader)) throw new TypeError("untrusted daily reader callback");
130
132
  const view = await (options.dailyReader ?? readDailyView)(options.dailyView);
131
- return options.dailyJson ? JSON.stringify(view) : renderDailyView(view, options.width);
133
+ return options.dailyJson ? JSON.stringify(view) : renderDailyPanel(view, options.width, { details: options.details });
132
134
  } catch {
133
135
  return "PI-DADDY — DAILY VIEW UNAVAILABLE\nInvalid or unavailable read-only input. No previous acceptance or continuity was substituted.";
134
136
  }
@@ -227,6 +229,8 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
227
229
  if (!host.debrief) await debrief.open({ mode: "manual", userPresent: true }); // Explicit CLI fixture only; never override a host deferral.
228
230
  }
229
231
  const dailyReader = createDailyViewReader();
232
+ const actionMenu=createDashboardMenu();
233
+ let details=cli.details;
230
234
  let previous = "";
231
235
  let feedback = "";
232
236
  let input: ReturnType<typeof createInterface> | null = null;
@@ -237,13 +241,13 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
237
241
  protocol,
238
242
  color: cli.color,
239
243
  width: process.stdout.columns || 80,
240
- details: cli.details,
244
+ details, actionMenu,
241
245
  dailyView, dailyReader, dailyJson: cli.dailyJson, debrief, debriefJson: cli.debriefJson, connected,
242
246
  });
243
- const rendered = feedback ? `${frame}\n\n${feedback}` : frame;
247
+ const rendered = feedback ? `${panelLines([feedback], process.stdout.columns || 80)}\n\n${frame}` : frame;
244
248
  if (rendered === previous && !clear) return;
245
249
  previous = rendered;
246
- const prompt = "COMMAND — type an exact listed key, then Enter (refresh never acts): ";
250
+ const prompt = "Action number / d Details, then Enter: ";
247
251
  const draft = input?.line ?? "";
248
252
  process.stdout.write(clear ? `\u001b]0;PI-DADDY\u0007\u001b[2J\u001b[H${rendered}\n\n${input ? prompt + draft : ""}` : `${rendered}\n`);
249
253
  if (input) input.setPrompt(prompt);
@@ -253,7 +257,7 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
253
257
  await draw(false);
254
258
  return;
255
259
  }
256
- input = debrief || connected ? createInterface({ input: process.stdin, output: process.stdout, terminal: true }) : null;
260
+ input = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) });
257
261
  await draw(true);
258
262
  let drawing = false;
259
263
  const redraw = (): void => {
@@ -264,10 +268,18 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
264
268
  let acting = false;
265
269
  input?.on("line", line => {
266
270
  if (acting) return; // never replay a line typed while the prior exact command is still settling.
271
+ if(line.trim().toLowerCase()==="d"){details=!details;redraw();return;}
267
272
  acting = true;
268
- feedback = `COMMAND ${line.trim() || "(empty)"} — applying the displayed exact request…`;
269
- void (connected?dashboardHostAction(connected,line):debriefAction(debrief!, line)).then(outcome => {
270
- feedback = connected ? dashboardActionFeedback(line.trim() || "command", outcome) : `ACKNOWLEDGED: ${line.trim() || "command"}. Refreshed state is shown below.`;
273
+ let label=line.trim()||"(empty)";
274
+ const act=async()=>{
275
+ if(connected&&/^\d+$/.test(line.trim())){const selected=actionMenu.select(line.trim());label=selected.label;return connected.humanAction(selected.key,{tip:selected.tip,requestDigest:selected.requestDigest});}
276
+ if(connected)return dashboardHostAction(connected,line);
277
+ if(debrief)return debriefAction(debrief,line);
278
+ throw Error("Read-only snapshot; no controls connected");
279
+ };
280
+ feedback = `Action ${label} — awaiting acknowledgement…`;
281
+ void act().then(outcome => {
282
+ feedback = connected ? dashboardActionFeedback(label, outcome) : `ACKNOWLEDGED: ${label}. Refreshed state is shown below.`;
271
283
  }).catch(error => {
272
284
  feedback = `REJECTED: ${error instanceof Error ? error.message : String(error)}. No command was retried; inspect the refreshed offered keys.`;
273
285
  }).finally(() => { acting = false; redraw(); });
@@ -32,6 +32,8 @@ export interface DashboardHostConfig {
32
32
  budgetDigest: string; experimentDigest: string | null; harnessArtifactDigest: string;
33
33
  ordinaryDigest?: string;
34
34
  learningLifecycleId?: string;
35
+ /** Explicit host journal, while reusing the original trust store/attention across host lifetimes. */
36
+ journalDirectory?: string;
35
37
  }
36
38
  export interface DashboardHostRequest { version: "1.0"; requestId: string; hostDigest: string; expectedTip: string; selectionDigest: string; operation: "observe" | "present" | "presented" | "defer" | "debrief" | "dispatch" | "intent" | "cancel" | "dispatch-reconcile" | "intent-reconcile" | "ordinary-cancel"; payload: unknown }
37
39
  export interface DashboardHostAuthority {
@@ -13,10 +13,13 @@ export async function serveDashboardHost(path:string,host:DashboardHost){
13
13
  if(!isDashboardHost(host))throw Error("original dashboard host required");await parent(path);const clients=new Set<Socket>(),tasks=new Set<Promise<void>>();
14
14
  const server=createServer(socket=>{if(clients.size>=4){socket.destroy();return;}clients.add(socket);socket.on("close",()=>clients.delete(socket));socket.on("error",()=>{});socket.setTimeout(5000,()=>socket.destroy());let size=0,used=false;const chunks:Buffer[]=[];
15
15
  socket.on("data",bytes=>{if(used)return;size+=bytes.length;if(size>65536){socket.destroy();return;}chunks.push(bytes);if(bytes.at(-1)!==10)return;used=true;
16
- const task=(async()=>{try{const text=new TextDecoder("utf-8",{fatal:true}).decode(Buffer.concat(chunks));const r=parseRetentionJson(text,65536) as {operation:string;request?:DashboardHostRequest;key?:string};let result:unknown;
16
+ const task=(async()=>{try{const text=new TextDecoder("utf-8",{fatal:true}).decode(Buffer.concat(chunks));const r=parseRetentionJson(text,65536) as {operation:string;request?:DashboardHostRequest;key?:string;expected?:{tip:string;requestDigest:string}};let result:unknown;
17
17
  if(Object.keys(r).join()==="operation"&&r.operation==="frame")result=await host.frame();
18
18
  else if(Object.keys(r).sort().join()==="operation,request"&&r.operation==="action")result=await host.action(dashboardHostRequest(r.request!));
19
- else if(Object.keys(r).sort().join()==="key,operation"&&r.operation==="human-action"&&typeof r.key==="string")result=await host.humanAction(r.key);
19
+ else if(["key,operation","expected,key,operation"].includes(Object.keys(r).sort().join())&&r.operation==="human-action"&&typeof r.key==="string"){
20
+ if(r.expected&&(!/^[a-f0-9]{64}$/.test(r.expected.tip)||!/^[a-f0-9]{64}$/.test(r.expected.requestDigest)||Object.keys(r.expected).sort().join()!=="requestDigest,tip"))throw Error("exact displayed action required");
21
+ result=await host.humanAction(r.key,r.expected);
22
+ }
20
23
  else throw Error("closed dashboard protocol required");
21
24
  const output=JSON.stringify({ok:true,result})+"\n";if(Buffer.byteLength(output)>512*1024)throw Error("dashboard frame bound exceeded");socket.end(output);
22
25
  }catch{socket.end(JSON.stringify({ok:false,error:"refused or acknowledgement unknown; explicit readback only"})+"\n");}})();tasks.add(task);void task.finally(()=>tasks.delete(task));
@@ -28,7 +31,7 @@ export async function serveDashboardHost(path:string,host:DashboardHost){
28
31
  }
29
32
  /** Explicit operator-selected endpoint. Socket ownership is access policy, not module/human authentication. */
30
33
  export function connectDashboardHost(path:string):DashboardConnection{
31
- const call=async(operation:string,payload:{request?:DashboardHostRequest;key?:string}={})=>{await parent(path);const s=await lstat(path);if(!s.isSocket()||s.mode&0o077||s.uid!==process.getuid?.())throw Error("private original dashboard endpoint unavailable");
34
+ const call=async(operation:string,payload:{request?:DashboardHostRequest;key?:string;expected?:{tip:string;requestDigest:string}}={})=>{await parent(path);const s=await lstat(path);if(!s.isSocket()||s.mode&0o077||s.uid!==process.getuid?.())throw Error("private original dashboard endpoint unavailable");
32
35
  return new Promise<any>((resolve,reject)=>{const socket=createConnection(path);let size=0,done=false;const chunks:Buffer[]=[];const fail=(e:unknown)=>{if(!done){done=true;socket.destroy();reject(e);}};
33
36
  socket.setTimeout(5000,()=>fail(Error("dashboard acknowledgement timeout; do not repeat action")));socket.on("error",fail);socket.on("connect",()=>socket.write(JSON.stringify({operation,...payload})+"\n"));
34
37
  socket.on("data",b=>{size+=b.length;if(size>512*1024){fail(Error("dashboard frame limit"));return;}chunks.push(b);});
@@ -36,5 +39,5 @@ export function connectDashboardHost(path:string):DashboardConnection{
36
39
  socket.on("close",()=>{if(!done)fail(Error("dashboard disconnected; acknowledgement unknown"));});
37
40
  });
38
41
  };
39
- const api={frame:()=>call("frame"),action:(r:DashboardHostRequest)=>call("action",{request:dashboardHostRequest(r)}),humanAction:(key:string)=>call("human-action",{key})};connections.add(api);return Object.freeze(api);
42
+ const api={frame:()=>call("frame"),action:(r:DashboardHostRequest)=>call("action",{request:dashboardHostRequest(r)}),humanAction:(key:string,expected?:{tip:string;requestDigest:string})=>call("human-action",{key,...(expected?{expected}:{})})};connections.add(api);return Object.freeze(api);
40
43
  }
@@ -1,3 +1,4 @@
1
+ import type { WorkPresentation } from "./daily-panel.ts";
1
2
  import { isOrdinaryChildren, holdOrdinaryDispatch, type OrdinaryChildren, type OrdinaryCancellation } from "./ordinary-children.ts";
2
3
  import { loadedDashboardHarnessDigest } from "./dashboard-harness.ts";
3
4
  import { join, isAbsolute } from "node:path";
@@ -22,6 +23,11 @@ export type DashboardHost=ReturnType<typeof openDashboardHost>;
22
23
  export interface DashboardHostOptions { ordinary?:OrdinaryChildren; harness:DashboardHarness; config:DashboardHostConfig; budget:GovernedBudgetBinding; experiment?:ReturnType<typeof openExperiment>; authority:()=>DashboardHostAuthority|null;
23
24
  /** Exact actions constructed and authorized by the owner; the dashboard exposes only human labels/keys. */
24
25
  humanActions?:(context:{hostDigest:string;selectionDigest:string;tip:string;observations:readonly {sourceId:string;checkpointId:string}[];preparedPresentationDigest:string|null})=>readonly {key:string;label:string;request:DashboardHostRequest}[]|Promise<readonly {key:string;label:string;request:DashboardHostRequest}[]>;
26
+ /** Read-only, digest-bound local outcome labels. Never grants acceptance or control. */
27
+ presentation?: () => WorkPresentation | null | Promise<WorkPresentation | null>;
28
+ currentWork?: () => Promise<import("./daily-view.ts").DailyView>;
29
+ learningView?: () => unknown;
30
+ afterObservation?: (observation: import("./dashboard-observation.ts").DashboardObservation) => void;
25
31
  /** Explicit approved observation effect only; refresh/frame never invokes it. */
26
32
  beforeObservation?:(sourceId:string)=>Promise<void>;
27
33
  /** Owner callback after the exact intent journal reports applied; failure keeps host acknowledgement unknown. */
@@ -30,7 +36,8 @@ export interface DashboardHostOptions { ordinary?:OrdinaryChildren; harness:Dash
30
36
  presence?:()=>{present:boolean;closing:boolean;evidenceDigest:string;expiresAt:number}|null;
31
37
  }
32
38
  function configuration(input:DashboardHostConfig){
33
- const c=detached(input);controlShape(c,["version","trustDirectory","trustPolicyId","archiveRoot","scope","author","policyPath","policySha256","sources","selection","cases","blind","budgetDigest","experimentDigest","harnessArtifactDigest",...(Object.hasOwn(c,"ordinaryDigest")?["ordinaryDigest"]:[]),...(Object.hasOwn(c,"learningLifecycleId")?["learningLifecycleId"]:[])]);
39
+ const c=detached(input);controlShape(c,["version","trustDirectory","trustPolicyId","archiveRoot","scope","author","policyPath","policySha256","sources","selection","cases","blind","budgetDigest","experimentDigest","harnessArtifactDigest",...(Object.hasOwn(c,"ordinaryDigest")?["ordinaryDigest"]:[]),...(Object.hasOwn(c,"learningLifecycleId")?["learningLifecycleId"]:[]),...(Object.hasOwn(c,"journalDirectory")?["journalDirectory"]:[])]);
40
+ if(c.journalDirectory!==undefined&&(typeof c.journalDirectory!=="string"||!isAbsolute(c.journalDirectory)))throw Error("explicit host journal directory required");
34
41
  if(Object.hasOwn(c,"ordinaryDigest")&&!sha(c.ordinaryDigest))throw Error("invalid ordinary binding digest");
35
42
  if(Object.hasOwn(c,"learningLifecycleId")&&!sha(c.learningLifecycleId))throw Error("invalid learning lifecycle manifest");
36
43
  if(c.version!=="producer-dashboard-host-v1"||![c.trustDirectory,c.archiveRoot,c.policyPath].every(p=>typeof p==="string"&&isAbsolute(p))||![c.trustPolicyId,c.policySha256,c.budgetDigest,c.harnessArtifactDigest].every(sha)||!(c.experimentDigest===null||sha(c.experimentDigest))||typeof c.scope!=="string"||!c.scope||c.scope.length>128||typeof c.author!=="string"||!c.author||c.author.length>256||!Array.isArray(c.sources)||c.sources.length<1||c.sources.length>32||new Set(c.sources.map(s=>s.id)).size!==c.sources.length||c.sources.filter(s=>s.kind==="work").length>1||c.sources.some(s=>!/^[a-zA-Z0-9:_-]{1,128}$/.test(s.id)||!["work","retention","facts"].includes(s.kind)))throw Error("invalid explicit dashboard host binding");
@@ -43,7 +50,7 @@ export function createDashboardHost(options:DashboardHostOptions){
43
50
  if(!a?.hostDigests.includes(dashboardHostDigest(c)))throw Error("independent exact host authority required");
44
51
  const trust=h.openTrustLifecycle(c.trustDirectory),view=trust.inspect(Date.now());
45
52
  if(view.policyId!==c.trustPolicyId)throw Error("trust policy changed");
46
- h.learningJournal(join(c.trustDirectory,"producer-host"),{type:"producer-host-initial",config:c});
53
+ h.learningJournal(c.journalDirectory??join(c.trustDirectory,"producer-host"),{type:"producer-host-initial",config:c});
47
54
  return openDashboardHost(options);
48
55
  }
49
56
  /** Existing native writers + original controllers; no model, launch, authority-file loader or PID recovery. */
@@ -53,7 +60,7 @@ export function openDashboardHost(options:DashboardHostOptions){
53
60
  const budget=openResourceBudget(options.budget),experiment=options.experiment,ordinary=options.ordinary;
54
61
  if(ordinary&&(!isOrdinaryChildren(ordinary)||ordinary.bindingDigest!==c.ordinaryDigest))throw Error("original ordinary controller binding required");
55
62
  if(resourceBindingDigest(budget.binding)!==c.budgetDigest||experiment&&(!isExperimentController(experiment)||experimentBindingDigest(experiment.binding)!==c.experimentDigest))throw Error("original controller binding required");
56
- const trust=h.openTrustLifecycle(c.trustDirectory),journal=h.learningJournal(join(c.trustDirectory,"producer-host"));
63
+ const trust=h.openTrustLifecycle(c.trustDirectory),journal=h.learningJournal(c.journalDirectory??join(c.trustDirectory,"producer-host"));
57
64
  const initial=journal.read()[0].value;
58
65
  if(initial.type!=="producer-host-initial"||dataDigest(initial.config)!==hostDigest)throw Error("host store binding changed; no new budget");
59
66
  const trustInput=trust.history()[0].value.input as {archiveRoot:string;component:unknown;exposure:{attentionRemaining:number}|null};
@@ -62,7 +69,7 @@ export function openDashboardHost(options:DashboardHostOptions){
62
69
  const observation=dashboardObservations(h,c,journal.read),history=()=>journal.read();
63
70
  const append=(value:Record<string,unknown>)=>journal.append(history().at(-1)!.id,value);
64
71
  const saved=():DebriefCheckpoint|null=>{const rows=history().filter(e=>e.value.type==="checkpoint");return rows.length?detached(rows.at(-1)!.value.checkpoint) as DebriefCheckpoint:null;};
65
- let presenter:DebriefPresenter|undefined,busy=false,poisoned=false,presentationRevision:number|null=null,presenceDigest:string|null=null,attentionDeferred:string|null=null,presentationAcknowledged=false;
72
+ let presenter:DebriefPresenter|undefined,busy=false,poisoned=false,stopped=false,presentationRevision:number|null=null,presenceDigest:string|null=null,attentionDeferred:string|null=null,presentationAcknowledged=false;
66
73
  const ordinaryDispatchHoldKey=dataDigest({hostDigest,control:"pause-new-ordinary-dispatch"});
67
74
  // A host frame is a read-only snapshot: ordinary children may attach or settle without advancing this journal.
68
75
  // Within one tip, a key keeps its first exact request; humanAction revalidates it against current native authority.
@@ -88,39 +95,60 @@ export function openDashboardHost(options:DashboardHostOptions){
88
95
  return {key:x.key,label:x.label,operation:request.operation,request};});
89
96
  };
90
97
  const api={hostDigest,selectionDigest,
98
+ /** Same owner gate as action: checking stop cannot race a newly admitted effect. Never releases a hold. */
99
+ async stop(){
100
+ if(stopped)return;if(busy)throw Error("Cannot stop: dashboard operation busy; wait for its acknowledgement first");busy=true;
101
+ try{
102
+ const rows=history();
103
+ if(poisoned||rows.some(e=>e.value.type==="host-failure")||rows.some(e=>e.value.type==="claim"&&!rows.some(r=>r.value.type==="result"&&r.value.requestId===e.value.requestId)))throw Error("Cannot stop: acknowledgement unknown; retain this original host for readback. Do not repeat effects. Ending the whole Pi session does not reconcile or release this hold.");
104
+ if(c.ordinaryDigest&&!ordinary)throw Error("Cannot stop: original ordinary boundary unavailable; no recovery");
105
+ const dispatch=await budget.controls(null).inspect();
106
+ if(dispatch.admission==="blocked-pending")throw Error("Cannot stop: pending original control requires explicit reconciliation, then resume before stopping");
107
+ if(dispatch.paused||ordinary&&(ordinary.inspect() as {admission:string}).admission!=="open")throw Error("Cannot stop: resume new work in the original dashboard first. Any pending intent also needs its original reconciliation; no hold was released.");
108
+ stopped=true;presenter?.close();presentationRevision=null;
109
+ }finally{busy=false;}
110
+ },
111
+ /** Final owner-session disposal only, not /grants host stop. Disconnects without resolving/releasing any control. */
112
+ endSession(){stopped=true;presenter?.close();presentationRevision=null;},
91
113
  async frame(){
92
114
  const rows=history(),tip=rows.at(-1)!.id,a=authority();let source:unknown=null,error:string|null=null;
93
115
  let selection=c.selection,selectionState="current-control-snapshot";
94
116
  try{selection=await currentSelection();}catch(e){error=String(e);selectionState="unavailable-host-declaration-only";}
95
- try{source=await observation.view(selectionState==="current-control-snapshot"?a:null,selection);}catch(e){error=String(e);}
117
+ try{source=await observation.view(selectionState==="current-control-snapshot"?a:null,selection);
118
+ if(options.currentWork)source={...(source as object),daily:await options.currentWork()};
119
+ }catch(e){error=String(e);}
96
120
  let debrief:unknown=null;
97
121
  if(a&&presenter&&presentationRevision!==null&&presence()?.evidenceDigest===presenceDigest){try{await paused(presentationRevision);debrief=presenter.view();}catch{debrief=null;}}
98
122
  let controls:unknown=null;try{controls=await nativeRead();}catch(e){error=String(e);}
99
123
  let learning:unknown=null;if(c.learningLifecycleId)try{learning=h.readLearningLifecycle(c.archiveRoot,c.learningLifecycleId);}catch(e){error=String(e);}
100
- const attention=trust.inspect(Date.now());let actions:{key:string;label:string;operation:string}[]=[];
124
+ let presentation:WorkPresentation|null=null;try{presentation=await options.presentation?.()??null;}catch(e){error=String(e);}
125
+ if(options.learningView)try{learning=options.learningView();}catch(e){learning={state:"deferred",reason:String(e)};}
126
+ const attention=trust.inspect(Date.now());let actions:{key:string;label:string;operation:string;requestDigest:string}[]=[];
101
127
  try{const available=await availableHumanActions();
102
128
  // A frame that completed after its captured tip is stale and cannot replace a newer display generation.
103
- if(history().at(-1)!.id===tip){
129
+ if(!stopped&&history().at(-1)!.id===tip){
104
130
  if(!displayedActions||displayedActions.tip!==tip)displayedActions={tip,actions:new Map()};
105
131
  const additions=available.filter(action=>!displayedActions!.actions.has(action.key));
106
132
  if(displayedActions.actions.size+additions.length>MAX_DISPLAYED_ACTIONS_PER_TIP)throw Error("displayed dashboard action capacity exhausted");
107
133
  const shown=available.filter(action=>{const prior=displayedActions!.actions.get(action.key);
108
134
  if(prior&&dashboardHostRequestDigest(prior.request)!==dashboardHostRequestDigest(action.request))return false;
109
135
  if(!prior)displayedActions!.actions.set(action.key,action);return true;});
110
- actions=shown.map(({key,label,operation})=>({key,label,operation}));
136
+ actions=shown.map(({key,label,operation,request})=>({key,label,operation,requestDigest:dashboardHostRequestDigest(request)}));
111
137
  }}catch(e){error=String(e);}
112
- return freeze(detached({version:"producer-dashboard-frame-v1",hostDigest,selectionDigest:dashboardSelectionDigest(selection),selectionState,tip,source,controls,learning,debrief,attention,error,actions,
138
+ return freeze(detached({version:"producer-dashboard-frame-v1",hostDigest,selectionDigest:dashboardSelectionDigest(selection),selectionState,tip,source,controls,learning,debrief,attention,error,actions,presentation,
113
139
  requests:rows.filter(e=>["claim","result","presentation","defer","ordinary-intent-pending"].includes(String(e.value.type))).map(e=>e.value),
114
140
  control:rows.some(e=>e.value.type==="host-failure")?"failed":rows.some(e=>e.value.type==="claim"&&!rows.some(r=>r.value.type==="result"&&r.value.requestId===e.value.requestId))?"unknown":"not-assessed",
115
141
  acknowledgement:poisoned?"unknown":"readback-only",identity:"independently-declared-host; not human/module authentication",activeBranch:null,acceptance:"not-assessed",freshness:"snapshot-unknown",workerInteractions:0}));
116
142
  },
117
143
  /** Explicit reconciliation is read-only. A retained claim is never replayed as an effect. */
118
144
  reconcile:()=>api.frame(),
119
- async humanAction(key:string){const tip=history().at(-1)!.id;if(!displayedActions||displayedActions.tip!==tip)throw Error("dashboard actions must be displayed at the current tip");
145
+ async humanAction(key:string,expected?:{tip:string;requestDigest:string}){const tip=history().at(-1)!.id;if(expected&&expected.tip!==tip)throw Error("displayed action is stale; select again");if(!displayedActions||displayedActions.tip!==tip)throw Error("dashboard actions must be displayed at the current tip");
120
146
  const found=displayedActions.actions.get(key);if(!found)throw Error(`unknown dashboard action ${JSON.stringify(key)}`);
147
+ if(expected&&dashboardHostRequestDigest(found.request)!==expected.requestDigest)throw Error("displayed action identity changed; no effect attempted");
121
148
  const current=(await availableHumanActions()).find(action=>action.key===key);if(!current||current.label!==found.label||dashboardHostRequestDigest(current.request)!==dashboardHostRequestDigest(found.request))throw Error("displayed dashboard action changed; no effect attempted");return api.action(found.request);},
122
149
  async action(input:DashboardHostRequest){
123
150
  const request=dashboardHostRequest(input),digest=dashboardHostRequestDigest(request);
151
+ if(stopped)throw Error("dashboard host stopped; no effect attempted");
124
152
  if(busy||poisoned)throw Error("dashboard operation busy or acknowledgement unknown");busy=true;let attempted=false;
125
153
  try{
126
154
  const rows=history(),old=rows.find(e=>e.value.type==="claim"&&e.value.requestId===request.requestId);
@@ -135,7 +163,7 @@ export function openDashboardHost(options:DashboardHostOptions){
135
163
  try{
136
164
  if(request.operation==="observe"){
137
165
  const sourceId=(request.payload as {sourceId?:unknown})?.sourceId;if(typeof sourceId!=="string")throw Error("exact observation source required");await options.beforeObservation?.(sourceId);
138
- const captured=observation.observe(request.payload,a!,selection);append({type:"observation",observation:captured});result=captured;
166
+ const captured=observation.observe(request.payload,a!,selection);append({type:"observation",observation:captured});options.afterObservation?.(captured);result=captured;
139
167
  }else if(request.operation==="present"){
140
168
  const p=detached(request.payload) as {userPresent:boolean;closing:boolean;evidenceDigest:string;dispatchRevision:number};
141
169
  if(Object.keys(p).sort().join()!=="closing,dispatchRevision,evidenceDigest,userPresent"||!sha(p.evidenceDigest)||typeof p.userPresent!=="boolean"||typeof p.closing!=="boolean"||!Number.isSafeInteger(p.dispatchRevision))throw Error("explicit presence/boundary evidence required");
@@ -165,8 +193,10 @@ export function openDashboardHost(options:DashboardHostOptions){
165
193
  const port=budget.controls(a!.dispatch),ordinaryHold=ordinary&&["pause-dispatch","resume-dispatch"].includes(native.action)?holdOrdinaryDispatch(ordinary,ordinaryDispatchHoldKey):undefined;
166
194
  if(request.operation==="dispatch-reconcile"&&(await port.inspect()).records.find(r=>r.request.requestId===native.requestId)?.digest!==dispatchRequestDigest(native))throw Error("exact original dispatch reconciliation required");
167
195
  result=request.operation==="dispatch"?await port.request(native):await port.reconcile(native.requestId);
168
- const record=(result as {records:{request:{requestId:string};decision:string;application:string}[]}).records.find(r=>r.request.requestId===native.requestId);
169
- if(ordinaryHold&&(record?.decision!=="approved"||native.action==="resume-dispatch"&&record.application==="applied"))ordinaryHold.release();
196
+ const snapshot=result as import("./dispatch-control.ts").DispatchSnapshot,record=snapshot.records.find(r=>r.request.requestId===native.requestId);
197
+ if(!record)throw Error("native dispatch application acknowledgement unavailable");
198
+ // A denied resume must not undo an earlier pause/pending control. Release only after final host sync.
199
+ if(ordinaryHold&&(native.action==="resume-dispatch"&&record.decision==="approved"&&record.application==="applied"||record.application==="not-applied"&&snapshot.admission==="enabled"))releaseOrdinary=ordinaryHold.release;
170
200
  }else if(request.operation==="intent"||request.operation==="intent-reconcile"){
171
201
  const native=detached(intentRequest(request.payload as IntentRequest)) as IntentRequest,port=budget.intentControls(a!.dispatch),digest=intentRequestDigest(native);
172
202
  if(c.ordinaryDigest&&!ordinary)throw Error("original ordinary boundary unavailable; no recovery");
@@ -202,6 +232,11 @@ export function openDashboardHost(options:DashboardHostOptions){
202
232
  }else throw Error("unsupported dashboard operation");
203
233
  }
204
234
  }catch(error){presenter?.close();presentationRevision=null;append({type:"result",requestId:request.requestId,state:"failed-or-unknown",reason:String(error)});throw error;}
235
+ if(["dispatch","dispatch-reconcile","intent","intent-reconcile"].includes(request.operation)&&result&&typeof result==="object"){
236
+ const snapshot=result as {records?:{requestId?:string;request?:{requestId:string};application?:string}[]},nativeId=(request.payload as {requestId:string}).requestId;
237
+ const applied=snapshot.records?.find(r=>(r.requestId??r.request?.requestId)===nativeId);
238
+ if(applied?.application)result={...result,application:applied.application,nativeRequestId:nativeId};
239
+ }
205
240
  append({type:"result",requestId:request.requestId,state:"acknowledged",resultDigest:dataDigest(result??null)});releaseOrdinary?.();return {state:"acknowledged",result};
206
241
  }catch(error){if(attempted){poisoned=true;try{if(history().some(e=>e.value.type==="claim"&&e.value.requestId===request.requestId))append({type:"host-failure",requestId:request.requestId,reason:String(error)});}catch{/* Failure recording also unacknowledged; no claim of no effects. */}}throw error;}finally{busy=false;}
207
242
  }
@@ -0,0 +1,28 @@
1
+ import type { PanelAction } from "./daily-panel.ts";
2
+ /** Numbers never acquire another meaning during repaint, including after a new journal tip. */
3
+ export function createDashboardMenu() {
4
+ const ids = new Map<string, string>();
5
+ const choices = new Map<string, { key: string; tip: string; requestDigest: string; label: string }>();
6
+ let current = new Set<string>();
7
+ return {
8
+ show(tip: string, actions: readonly PanelAction[]) {
9
+ current = new Set();
10
+ return actions.map(action => {
11
+ if (!action.requestDigest) return { choice: action.key, label: action.label };
12
+ const identity = JSON.stringify([tip, action.key, action.requestDigest, action.label]);
13
+ let choice = ids.get(identity);
14
+ if (!choice) {
15
+ if (ids.size >= 32768) throw Error("Action menu capacity reached; reopen the panel. No choice was remapped.");
16
+ choice = String(ids.size + 1); ids.set(identity, choice);
17
+ choices.set(choice, { key: action.key, tip, requestDigest: action.requestDigest, label: action.label });
18
+ }
19
+ current.add(choice); return { choice, label: action.label };
20
+ });
21
+ },
22
+ select(choice: string) {
23
+ if (!current.has(choice)) throw Error("That action is no longer displayed; select a current number.");
24
+ return choices.get(choice)!;
25
+ },
26
+ clear() { current.clear(); },
27
+ };
28
+ }
package/src/index.ts CHANGED
@@ -45,7 +45,8 @@ export { renderDebrief, debriefAction as dashboardDebriefAction } from "./debrie
45
45
  export { type ReviewPort, type ReviewRequest, type BlindPort, type BlindChoice } from "./debrief-contract.ts";
46
46
  export { readDailyView, createDailyViewReader, DAILY_VIEW_VERSION, type DailyView, type DailyViewOptions,
47
47
  type DailyAttempt, type DailyObligation } from "./daily-view.ts";
48
- export { renderDailyView } from "./daily-view-render.ts";
48
+ export { renderDailyView, renderDailyDetails } from "./daily-view-render.ts";
49
+ export { renderDailyPanel, type PanelOptions, type WorkPresentation } from "./daily-panel.ts";
49
50
  export { parseArchiveProjection, ARCHIVE_PROJECTION_VERSION } from "./daily-view-input.ts";
50
51
  export { bindWorkIntent } from "./intent-application.ts";
51
52
  export { intentRequestDigest, parseIntentRequest, type IntentRequest, type IntentSelection, type IntentPriority,
@@ -154,6 +155,11 @@ export {
154
155
  } from "./dashboard-projection.ts";
155
156
 
156
157
  export { renderDashboard, type DashboardRenderOptions } from "./dashboard-render.ts";
158
+ export { declareWork, loadDeclaredWork, type DeclaredWorkState } from "./work-command.ts";
159
+ export { workSetup, recordWorkSetup, selectRecordedWork, loadWorkSetup, listWorkSetups, workPresentation, type WorkSetup, type WorkTaskSetup, type RecordedWorkSetup } from "./work-setup.ts";
160
+ export { runWorkSetup, inspectWorkRun, type WorkRunResult, type WorkRunInitial, type WorkPolicyPin } from "./work-run.ts";
161
+ export { workPolicy, workPolicyDigest, policyForSetup, createWorkPolicyRegistry, openWorkPolicyRegistry, workPolicyActivationDigest, type WorkPolicy, type WorkPolicyRegistry, type WorkPolicyActivation } from "./work-policy-registry.ts";
162
+ export { learningHarness, bindLearningConnection, loadLearningConnection, bindLearningAdoption, learningScopeDigest, type LearningConnection, type LearningWorkspace } from "./learning-connection.ts";
157
163
 
158
164
  export {
159
165
  runMeasuredAgentSession,
@@ -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
+ }
@@ -11,6 +11,11 @@ import { freezeWork } from "./work-ledger-json.ts";
11
11
  const ID=/^[a-zA-Z0-9:_-]{1,128}$/,HEX=/^[a-f0-9]{64}$/;
12
12
  const hash=(value:unknown)=>createHash("sha256").update(typeof value==="string"?value:intentKey(value)).digest("hex");
13
13
  const exact=(value:object,keys:string[])=>Object.keys(value).sort().join()===keys.sort().join();
14
+ type JournalJson=null|boolean|number|string|JournalJson[]|{[key:string]:JournalJson};
15
+ class JournalSerializationError extends Error {constructor(){super("invalid measured order journal value");this.name="JournalSerializationError";}}
16
+ /** Result evidence is not work/intent input: provider money is finite decimal Number data, not an integer schema field. */
17
+ function journalKey(value:unknown):string{try{const ancestors=new Set<object>();const copy=(v:unknown,depth:number):JournalJson=>{if(v===null||typeof v==="boolean"||typeof v==="string")return v;if(typeof v==="number"){if(Number.isFinite(v))return v;throw new JournalSerializationError();}if(!v||typeof v!=="object"||depth>16||ancestors.has(v))throw new JournalSerializationError();ancestors.add(v);let result:JournalJson;if(Array.isArray(v)){if(Object.getPrototypeOf(v)!==Array.prototype||v.length>256)throw new JournalSerializationError();const fields=Object.getOwnPropertyDescriptors(v);if(Reflect.ownKeys(fields).length!==v.length+1)throw new JournalSerializationError();result=[];for(let i=0;i<v.length;i++){const field=fields[String(i)];if(!field||!Object.hasOwn(field,"value")||!field.enumerable)throw new JournalSerializationError();result.push(copy(field.value,depth+1));}}else{if(Object.getPrototypeOf(v)!==Object.prototype&&Object.getPrototypeOf(v)!==null)throw new JournalSerializationError();const fields=Object.getOwnPropertyDescriptors(v),object:Record<string,JournalJson>=Object.create(null);for(const key of Reflect.ownKeys(fields)){if(typeof key!=="string")throw new JournalSerializationError();const field=fields[key];if(!Object.hasOwn(field,"value")||!field.enumerable)throw new JournalSerializationError();Object.defineProperty(object,key,{value:copy(field.value,depth+1),enumerable:true});}result=object;}ancestors.delete(v);return result;},emit=(v:JournalJson):string=>v===null||typeof v!=="object"?JSON.stringify(v):Array.isArray(v)?`[${v.map(emit).join(",")}]`:`{${Object.keys(v).sort().map(key=>`${JSON.stringify(key)}:${emit(v[key])}`).join(",")}}`;return emit(copy(value,1));}catch(error){if(error instanceof JournalSerializationError)throw error;throw new JournalSerializationError();}}
18
+ const journalHash=(value:unknown)=>createHash("sha256").update(journalKey(value)).digest("hex");
14
19
  export type MeasuredOutputContract={version:"measured-output-exact-v1";expectedSha256:string}|{version:"measured-output-json-projection-v1";requiredKeys:string[];textFields:string[];arrayField:string;minItems:number;maxItems:number;itemRequiredFields:string[];projectionFields:string[];expectedProjectionSha256:string};
15
20
  export interface MeasuredOrderAttempt extends Omit<MeasuredSessionInput,"budget"|"attempt"|"signal">{attemptId:string;includeDependencies:boolean;qualification:MeasuredOutputContract}
16
21
  export interface MeasuredOrderNode {nodeId:string;dependencies:string[];attempts:MeasuredOrderAttempt[]}
@@ -49,8 +54,8 @@ export async function createMeasuredOrder(input:{directory:string;budget:Governe
49
54
  function checkedBinding(input:MeasuredOrderBinding):MeasuredOrderBinding{const b=JSON.parse(intentKey(input)) as MeasuredOrderBinding,identity={directory:b.directory,directoryDevice:b.directoryDevice,directoryInode:b.directoryInode,journalDevice:b.journalDevice,journalInode:b.journalInode};if(!exact(b,["version","directory","directoryDevice","directoryInode","journalDevice","journalInode","budget","order","digest"])||b.version!=="measured-order-binding-v1"||!isAbsolute(b.directory)||resolve(b.directory)!==b.directory||![b.directoryDevice,b.directoryInode,b.journalDevice,b.journalInode].every(x=>/^\d+$/.test(x))||b.digest!==hash({...identity,budgetDigest:resourceBindingDigest(b.budget),orderDigest:measuredOrderDigest(checkedOrder(b.order,b.budget))}))throw Error("invalid measured order binding");return b;}
50
55
  async function pinnedJournal(binding:MeasuredOrderBinding,writable=false){const path=paths(binding.directory).journal,handle=await open(path,(writable?constants.O_RDWR|constants.O_APPEND:constants.O_RDONLY)|constants.O_NOFOLLOW);try{const st=await handle.stat({bigint:true});if(!st.isFile()||st.nlink!==1n||(st.mode&0o077n)||String(st.dev)!==binding.journalDevice||String(st.ino)!==binding.journalInode)throw Error("measured order binding changed");return handle;}catch(error){await handle.close();throw error;}}
51
56
  async function linked(binding:MeasuredOrderBinding){const st=await lstat(paths(binding.directory).journal,{bigint:true});if(!st.isFile()||st.nlink!==1n||String(st.dev)!==binding.journalDevice||String(st.ino)!==binding.journalInode||(await realpath(paths(binding.directory).journal))!==paths(binding.directory).journal)throw Error("measured order binding changed");}
52
- function parseJournal(text:string):JournalEvent[]{if(text&&!text.endsWith("\n"))throw Error("incomplete measured order journal");const rows=text?text.trimEnd().split("\n").map(line=>{const row=JSON.parse(line) as JournalEvent;if(intentKey(row)!==line)throw Error("noncanonical measured order journal");return row;}):[];let prior:string|null=null;for(let i=0;i<rows.length;i++){const row=rows[i],copy={...row} as any,claimed=copy.digest;delete copy.digest;if(row.sequence!==i||row.previousDigest!==prior||claimed!==hash(copy))throw Error("invalid measured order journal");prior=claimed;}return rows;}
53
- async function appendJournal(binding:MeasuredOrderBinding,event:Omit<JournalEvent,"sequence"|"previousDigest"|"digest">){const path=paths(binding.directory).journal;return withFileLock(path,"measured order",async()=>{const handle=await pinnedJournal(binding,true);try{const rows=parseJournal(await handle.readFile("utf8")),base={...event,sequence:rows.length,previousDigest:rows.at(-1)?.digest??null} as any,record={...base,digest:hash(base)} as JournalEvent;await linked(binding);await handle.writeFile(intentKey(record)+"\n");await handle.sync();return record;}finally{await handle.close();}});}
57
+ function parseJournal(text:string):JournalEvent[]{if(text&&!text.endsWith("\n"))throw Error("incomplete measured order journal");const rows=text?text.trimEnd().split("\n").map(line=>{const row=JSON.parse(line) as JournalEvent;if(journalKey(row)!==line)throw Error("noncanonical measured order journal");return row;}):[];let prior:string|null=null;for(let i=0;i<rows.length;i++){const row=rows[i],copy={...row} as any,claimed=copy.digest;delete copy.digest;if(row.sequence!==i||row.previousDigest!==prior||claimed!==journalHash(copy))throw Error("invalid measured order journal");prior=claimed;}return rows;}
58
+ async function appendJournal(binding:MeasuredOrderBinding,event:Omit<JournalEvent,"sequence"|"previousDigest"|"digest">){const path=paths(binding.directory).journal;return withFileLock(path,"measured order",async()=>{const handle=await pinnedJournal(binding,true);try{const rows=parseJournal(await handle.readFile("utf8")),base={...event,sequence:rows.length,previousDigest:rows.at(-1)?.digest??null} as any,record={...base,digest:journalHash(base)} as JournalEvent;await linked(binding);await handle.writeFile(journalKey(record)+"\n");await handle.sync();return record;}finally{await handle.close();}});}
54
59
  function project(order:MeasuredOrder,events:JournalEvent[],currentOwner:string):MeasuredOrderNodeView[]{const rows=new Map<string,MeasuredOrderNodeView>();const evaluate=(node:MeasuredOrderNode):MeasuredOrderNodeView=>{const prior=rows.get(node.nodeId);if(prior)return prior;const row:MeasuredOrderNodeView={nodeId:node.nodeId,state:"exhausted",action:"none",attemptId:null,outputSha256:null,unknownClaim:null};if(node.dependencies.some(id=>evaluate(order.nodes.find(n=>n.nodeId===id)!).state!=="satisfied")){row.state="dependency-blocked";row.action="wait";rows.set(node.nodeId,row);return row;}
55
60
  for(const attempt of node.attempts){const claim=events.find(e=>e.type==="claim"&&e.attemptId===attempt.attemptId) as Extract<JournalEvent,{type:"claim"}>|undefined;if(!claim){row.state="eligible";row.action="dispatch";row.attemptId=attempt.attemptId;break;}const result=events.find(e=>e.type==="result"&&e.attemptId===attempt.attemptId) as Extract<JournalEvent,{type:"result"}>|undefined;const acknowledged=events.some(e=>e.type==="acknowledge-unknown"&&e.request.attemptId===attempt.attemptId&&e.request.claimDigest===claim.digest&&e.request.claimOwner===claim.owner);if(!result){if(claim.owner!==currentOwner){if(acknowledged)continue;row.state="unknown";row.action="acknowledge";row.unknownClaim={owner:claim.owner,digest:claim.digest};}else{row.state="running";row.action="wait";}row.attemptId=attempt.attemptId;break;}if(result.state==="unknown"){if(!acknowledged){row.state="unknown";row.action="acknowledge";row.attemptId=attempt.attemptId;row.unknownClaim={owner:claim.owner,digest:claim.digest};break;}continue;}if(result.state==="completed"){row.state="satisfied";row.outputSha256=result.outputSha256;row.attemptId=attempt.attemptId;break;}}
56
61
  rows.set(node.nodeId,row);return row;};return order.nodes.map(evaluate);}
@@ -62,6 +67,6 @@ export function openMeasuredOrder(input:MeasuredOrderBinding,hostAuthority:Measu
62
67
  if(!selected||!claim)return inspect();const node=binding.order.nodes.find(n=>n.attempts.some(a=>a.attemptId===selected!.attemptId))!,index=node.attempts.findIndex(a=>a.attemptId===selected!.attemptId),controller=new AbortController();let state:Extract<JournalEvent,{type:"result"}>["state"]="failed",result:unknown=null,outputSha256:string|null=null;
63
68
  try{const accounting=await openResourceBudget(binding.budget).inspect(),parent=[...node.attempts.slice(0,index)].reverse().find(a=>accounting.reservations.some(r=>r.attemptId===a.attemptId))??null,events=await read();let prompt=selected.prompt;if(selected.includeDependencies)for(const dependency of node.dependencies){const dependencyNode=binding.order.nodes.find(n=>n.nodeId===dependency)!,prior=dependencyNode.attempts.map(a=>events.find(e=>e.type==="result"&&e.attemptId===a.attemptId)).find((e):e is Extract<JournalEvent,{type:"result"}>=>e?.type==="result"&&e.state==="completed"),output=(prior?.result as {output?:unknown})?.output;if(!prior||typeof output!=="string"||hash(output)!==prior.outputSha256)throw Error("sealed dependency output unavailable");prompt+=`\n\n<sealed-dependency node=${JSON.stringify(dependency)} sha256=${JSON.stringify(prior.outputSha256)}>\n${output}\n</sealed-dependency>`;}
64
69
  result=await runMeasuredAgentSession({budget:binding.budget,attempt:{attemptId:selected.attemptId,orderId:binding.order.orderId,experimentId:`${binding.order.orderId}:${node.nodeId}`,kind:parent?"retry":"primary",parentAttemptId:parent?.attemptId??null},model:selected.model,thinkingLevel:selected.thinkingLevel,prompt,deadlineMs:selected.deadlineMs,terminationGraceMs:selected.terminationGraceMs,maxRetainedOutputBytes:selected.maxRetainedOutputBytes,maxOutputTokens:selected.maxOutputTokens,signal:controller.signal},host);const qualified=qualifyMeasuredOutput((result as {output:string}).output,selected.qualification);outputSha256=qualified.outputSha256;state=qualified.eligible?"completed":"mismatch";}catch(error){result=error instanceof MeasuredSessionFailure?error.evidence:{errorType:error instanceof Error?error.name:"unknown",errorCode:typeof error==="object"&&error&&"code" in error?String(error.code):null,message:error instanceof Error?error.message:"unknown"};state=error instanceof MeasuredSessionUnknownError?"unknown":error instanceof MeasuredSessionFailure&&(error.evidence as {state?:string})?.state==="cancelled"?"cancelled":"failed";}
65
- await appendJournal(binding,{type:"result",attemptId:selected.attemptId,owner:claim.owner,state,outputSha256,result} as Omit<JournalEvent,"sequence"|"previousDigest"|"digest">);return inspect();},
70
+ let event={type:"result" as const,attemptId:selected.attemptId,owner:claim.owner,state,outputSha256,result} as Omit<JournalEvent,"sequence"|"previousDigest"|"digest">;try{journalKey(event);}catch(error){if(!(error instanceof JournalSerializationError))throw error;event={type:"result",attemptId:selected.attemptId,owner:claim.owner,state:"failed",outputSha256,result:{errorType:"JournalSerializationError",errorCode:"MEASURED_RESULT_SERIALIZATION_FAILED",message:"measured result could not be durably serialized"}} as Omit<JournalEvent,"sequence"|"previousDigest"|"digest">;journalKey(event);}await appendJournal(binding,event);return inspect();},
66
71
  async acknowledgeUnknown(request:MeasuredOrderAcknowledgement,authority:MeasuredOrderAuthority|null){const digest=measuredOrderAcknowledgementDigest(request);if(authority?.authorityDigest!==binding.order.authorityDigest||!authority.acknowledgementDigests.includes(digest)||request.bindingDigest!==binding.digest)throw Error("independent exact acknowledgement authority required");await read();await withFileLock(p.journal,"measured order acknowledgement",async()=>{const handle=await pinnedJournal(binding,true);try{const events=parseJournal(await handle.readFile("utf8")),claim=events.find(e=>e.type==="claim"&&e.attemptId===request.attemptId) as Extract<JournalEvent,{type:"claim"}>|undefined,result=events.find(e=>e.type==="result"&&e.attemptId===request.attemptId) as Extract<JournalEvent,{type:"result"}>|undefined;if(!claim||claim.owner!==request.claimOwner||claim.digest!==request.claimDigest||!(result?.state==="unknown"||!result&&claim.owner!==owner)||events.some(e=>e.type==="acknowledge-unknown"&&e.request.attemptId===request.attemptId))throw Error("exact current unknown claim required");const base={type:"acknowledge-unknown",sequence:events.length,previousDigest:events.at(-1)!.digest,request} as const,record={...base,digest:hash(base)};await linked(binding);await handle.writeFile(intentKey(record)+"\n");await handle.sync();}finally{await handle.close();}});return inspect();}});
67
72
  }
@@ -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
+ }