pi-ultracode 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,8 @@
6
6
  * places:
7
7
  * - Built-ins defined below (claude, Explore, Plan, general-purpose, code-reviewer).
8
8
  * - Markdown files with YAML-ish frontmatter discovered under:
9
- * <cwd>/.pi/ultracode/agents/*.md (project)
9
+ * <cwd>/.pi/ultracode/agents/*.md (project-specific)
10
+ * <cwd>/.pi/agents/*.md (project-shared)
10
11
  * ~/.pi/ultracode/agents/*.md (user)
11
12
  * ~/.pi/agent/agents/*.md (shared with other tools)
12
13
  */
@@ -54,7 +55,7 @@ const BUILTIN_AGENT_TYPES: AgentTypeDef[] = [
54
55
  systemPrompt:
55
56
  "You are a read-only exploration subagent. Sweep many files/directories and report the conclusion with precise file:line references. Read excerpts rather than whole files. Do NOT modify anything.",
56
57
  systemPromptMode: "append",
57
- tools: ["read", "grep", "find", "ls", "bash"],
58
+ tools: ["read", "grep", "find", "ls"],
58
59
  source: "builtin",
59
60
  },
60
61
  {
@@ -63,7 +64,7 @@ const BUILTIN_AGENT_TYPES: AgentTypeDef[] = [
63
64
  systemPrompt:
64
65
  "You are a software-architect subagent. Produce a concrete, step-by-step implementation plan: critical files, sequence, trade-offs, and risks. Do NOT modify files.",
65
66
  systemPromptMode: "append",
66
- tools: ["read", "grep", "find", "ls", "bash"],
67
+ tools: ["read", "grep", "find", "ls"],
67
68
  source: "builtin",
68
69
  },
69
70
  {
@@ -76,14 +77,20 @@ const BUILTIN_AGENT_TYPES: AgentTypeDef[] = [
76
77
  },
77
78
  ];
78
79
 
79
- export function discoverAgentTypes(cwd: string): Map<string, AgentTypeDef> {
80
+ export function discoverAgentTypes(cwd: string, projectTrusted = false): Map<string, AgentTypeDef> {
80
81
  const map = new Map<string, AgentTypeDef>();
81
- for (const def of BUILTIN_AGENT_TYPES) map.set(def.name, def);
82
+ for (const def of BUILTIN_AGENT_TYPES) map.set(def.name.toLowerCase(), def);
83
+ const protectedBuiltins = new Set(["explore", "plan"]);
82
84
 
83
85
  const dirs: Array<{ dir: string; source: AgentTypeDef["source"] }> = [
84
86
  { dir: path.join(os.homedir(), ".pi", "agent", "agents"), source: "user" },
85
87
  { dir: path.join(os.homedir(), ".pi", "ultracode", "agents"), source: "user" },
86
- { dir: path.join(cwd, ".pi", "ultracode", "agents"), source: "project" },
88
+ ...(projectTrusted
89
+ ? [
90
+ { dir: path.join(cwd, ".pi", "agents"), source: "project" as const },
91
+ { dir: path.join(cwd, ".pi", "ultracode", "agents"), source: "project" as const },
92
+ ]
93
+ : []),
87
94
  ];
88
95
 
89
96
  for (const { dir, source } of dirs) {
@@ -98,7 +105,9 @@ export function discoverAgentTypes(cwd: string): Map<string, AgentTypeDef> {
98
105
  try {
99
106
  const content = fs.readFileSync(path.join(dir, entry), "utf8");
100
107
  const def = parseAgentTypeFile(content, entry.replace(/\.md$/, ""), source);
101
- if (def) map.set(def.name, def); // project overrides user overrides builtin
108
+ if (def && !protectedBuiltins.has(def.name.toLowerCase())) {
109
+ map.set(def.name.trim().toLowerCase(), def);
110
+ }
102
111
  } catch {
103
112
  // ignore unreadable / malformed agent files
104
113
  }
@@ -112,14 +121,9 @@ export function resolveAgentType(
112
121
  types: Map<string, AgentTypeDef>,
113
122
  ): AgentTypeDef | undefined {
114
123
  if (!agentType) return undefined;
115
- const found = types.get(agentType);
116
- if (found) return found;
117
- // Case-insensitive fallback.
118
- const lower = agentType.toLowerCase();
119
- for (const def of types.values()) {
120
- if (def.name.toLowerCase() === lower) return def;
121
- }
122
- return undefined;
124
+ const normalized = agentType.trim();
125
+ if (!normalized) return undefined;
126
+ return types.get(normalized.toLowerCase());
123
127
  }
124
128
 
125
129
  export function parseAgentTypeFile(
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared hermetic Git environment for workflow Git plumbing.
3
+ *
4
+ * Strips ambient GIT_* influence and pins locale/config so identity and
5
+ * worktree operations observe the same repository view.
6
+ */
7
+
8
+ import * as os from "node:os";
9
+
10
+ /** Build a process env that isolates Git from ambient config and locale. */
11
+ export function hermeticGitEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
12
+ const env: NodeJS.ProcessEnv = {};
13
+ for (const [key, value] of Object.entries(process.env)) {
14
+ if (key.toUpperCase().startsWith("GIT_")) continue;
15
+ env[key] = value;
16
+ }
17
+ env.LC_ALL = "C";
18
+ env.LANG = "C";
19
+ env.GIT_CONFIG_NOSYSTEM = "1";
20
+ env.GIT_CONFIG_GLOBAL = os.devNull;
21
+ env.GIT_ATTR_NOSYSTEM = "1";
22
+ env.GIT_NO_REPLACE_OBJECTS = "1";
23
+ return { ...env, ...overrides };
24
+ }
25
+
26
+ /** Strip a single trailing LF or CRLF without touching other whitespace. */
27
+ export function stripOneLineEnding(value: string): string {
28
+ if (value.endsWith("\r\n")) return value.slice(0, -2);
29
+ if (value.endsWith("\n")) return value.slice(0, -1);
30
+ return value;
31
+ }
@@ -23,21 +23,27 @@ import {
23
23
  import * as crypto from "node:crypto";
24
24
  import { assertWorkflowArgsLimit, assertWorkflowOutputLimit } from "./value-limits.ts";
25
25
 
26
- export const RUN_JOURNAL_VERSION = 3;
26
+ export const RUN_JOURNAL_VERSION = 4;
27
27
  export const MAX_JOURNAL_BYTES = 64 * 1024 * 1024;
28
28
  const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
29
29
 
30
30
  export interface JournalRunMeta {
31
31
  type: "run";
32
- journalVersion?: 3;
32
+ journalVersion?: 4;
33
33
  runId: string;
34
34
  name: string;
35
35
  scriptHash: string;
36
36
  args?: unknown;
37
+ projectTrusted: boolean;
38
+ targetIdentity: string;
37
39
  startedAt: number;
38
40
  maxAgents?: number;
39
41
  }
40
42
 
43
+ export interface JournalResumeOptions {
44
+ validateDelivery?: (record: JournalAgentRecord) => void;
45
+ }
46
+
41
47
  export interface JournalResumeRecord {
42
48
  type: "resume";
43
49
  startedAt: number;
@@ -80,6 +86,13 @@ export interface JournalNestedSourceRecord {
80
86
  sourceHash: string;
81
87
  }
82
88
 
89
+ export interface JournalDeliveryStartRecord {
90
+ type: "delivery-start";
91
+ callPath: string;
92
+ patchPath: string;
93
+ patchHash: string;
94
+ }
95
+
83
96
  export interface JournalAgentRecord {
84
97
  type: "agent";
85
98
  /** Stable structural identity assigned in the script Worker. */
@@ -106,6 +119,9 @@ export interface JournalAgentRecord {
106
119
  agentType?: string;
107
120
  isolation?: string;
108
121
  structuredOutput?: boolean;
122
+ /** Durable material retained to verify a completed worktree side effect on resume. */
123
+ deliveryPatchPath?: string;
124
+ deliveryPatchHash?: string;
109
125
  transcriptPath?: string;
110
126
  startedAt?: number;
111
127
  durationMs?: number;
@@ -128,6 +144,7 @@ export type JournalRecord =
128
144
  | JournalPanelBranchRecord
129
145
  | JournalPanelRecord
130
146
  | JournalNestedSourceRecord
147
+ | JournalDeliveryStartRecord
131
148
  | JournalAgentRecord
132
149
  | JournalResultRecord;
133
150
 
@@ -136,6 +153,10 @@ export function hashString(input: string): string {
136
153
  return crypto.createHash("sha256").update(input).digest("hex");
137
154
  }
138
155
 
156
+ export function hashBytes(input: Uint8Array): string {
157
+ return crypto.createHash("sha256").update(input).digest("hex");
158
+ }
159
+
139
160
  export function agentCallKey(prompt: string, opts: unknown): string {
140
161
  return hashString(`${prompt}\u0000${stableStringify(opts)}`);
141
162
  }
@@ -152,11 +173,14 @@ interface LoadedJournal {
152
173
  completePanels: Map<string, JournalPanelRecord>;
153
174
  panelBranches: Map<string, Map<number, JournalPanelBranchRecord>>;
154
175
  nestedSources: Map<string, string>;
176
+ pendingDeliveries: Map<string, JournalDeliveryStartRecord>;
155
177
  }
156
178
 
157
179
  export class RunJournal {
158
180
  readonly filePath: string;
159
181
  readonly effectiveMaxAgents: number;
182
+ readonly projectTrusted: boolean;
183
+ readonly targetIdentity: string;
160
184
  private readonly fd: number;
161
185
  private readonly priorAgents: Map<string, JournalAgentRecord>;
162
186
  private readonly admittedPaths: Set<string>;
@@ -167,17 +191,21 @@ export class RunJournal {
167
191
  private readonly openPanelBranches = new Map<string, Map<number, JournalPanelBranchRecord>>();
168
192
  private readonly openPanelAdmissions = new Map<string, Set<string>>();
169
193
  private readonly nestedSources: Map<string, string>;
194
+ private readonly pendingDeliveries: Map<string, JournalDeliveryStartRecord>;
170
195
  private _agentsUsed: number;
171
196
  private bytes: number;
172
197
  private fdClosed = false;
173
198
  private closed = false;
174
199
  private writeFailure?: WorkflowPolicyError;
175
200
  private closeFailure?: unknown;
201
+ private resultRecorded = false;
176
202
 
177
203
  private constructor(filePath: string, fd: number, loaded: LoadedJournal) {
178
204
  this.filePath = filePath;
179
205
  this.fd = fd;
180
206
  this.effectiveMaxAgents = loaded.effectiveMaxAgents;
207
+ this.projectTrusted = loaded.header.projectTrusted;
208
+ this.targetIdentity = loaded.header.targetIdentity;
181
209
  this._agentsUsed = loaded.agentsUsed;
182
210
  this.bytes = loaded.bytes;
183
211
  this.priorAgents = loaded.agents;
@@ -187,6 +215,7 @@ export class RunJournal {
187
215
  this.completePanels = loaded.completePanels;
188
216
  this.panelBranches = loaded.panelBranches;
189
217
  this.nestedSources = loaded.nestedSources;
218
+ this.pendingDeliveries = loaded.pendingDeliveries;
190
219
  }
191
220
 
192
221
  get agentsUsed(): number {
@@ -200,6 +229,7 @@ export class RunJournal {
200
229
  const header: JournalRunMeta = {
201
230
  ...meta,
202
231
  journalVersion: RUN_JOURNAL_VERSION,
232
+ projectTrusted: meta.projectTrusted,
203
233
  maxAgents: effectiveMaxAgents,
204
234
  };
205
235
  validateRunRecord(header);
@@ -222,6 +252,7 @@ export class RunJournal {
222
252
  completePanels: new Map(),
223
253
  panelBranches: new Map(),
224
254
  nestedSources: new Map(),
255
+ pendingDeliveries: new Map(),
225
256
  });
226
257
  try {
227
258
  journal.append(header);
@@ -239,7 +270,12 @@ export class RunJournal {
239
270
  }
240
271
 
241
272
  /** Resume the same immutable run ledger, optionally raising its lifetime cap. */
242
- static resume(dir: string, runId: string, meta: JournalRunMeta): RunJournal {
273
+ static resume(
274
+ dir: string,
275
+ runId: string,
276
+ meta: JournalRunMeta,
277
+ options: JournalResumeOptions = {},
278
+ ): RunJournal {
243
279
  assertWorkflowArgsLimit(meta.args);
244
280
  validateRunRecord(meta);
245
281
  ensurePrivateArtifactDirectory(dir);
@@ -256,6 +292,23 @@ export class RunJournal {
256
292
  if (JSON.stringify(loaded.header.args) !== JSON.stringify(meta.args)) {
257
293
  throw new WorkflowPolicyError(`workflow ${runId} has immutable args; start a new run for changed args`);
258
294
  }
295
+ if (loaded.header.projectTrusted !== meta.projectTrusted) {
296
+ throw new WorkflowPolicyError(`workflow ${runId} has an immutable project trust context; start a new run after trust changes`);
297
+ }
298
+ if (loaded.header.targetIdentity !== meta.targetIdentity) {
299
+ throw new WorkflowPolicyError(`workflow ${runId} has an immutable repository/cwd target; start a new run after changing location`);
300
+ }
301
+ if (loaded.pendingDeliveries.size > 0) {
302
+ const pending = [...loaded.pendingDeliveries.values()][0]!;
303
+ throw new WorkflowPolicyError(
304
+ `workflow delivery at ${pending.callPath} requires recovery before resume; patch: ${pending.patchPath}`,
305
+ );
306
+ }
307
+ if (options.validateDelivery) {
308
+ for (const agent of loaded.agents.values()) {
309
+ if (agent.deliveryPatchPath) options.validateDelivery(agent);
310
+ }
311
+ }
259
312
 
260
313
  const requested = meta.maxAgents === undefined
261
314
  ? loaded.effectiveMaxAgents
@@ -299,6 +352,12 @@ export class RunJournal {
299
352
  }
300
353
 
301
354
  lookup(callPath: string, key: string): JournalAgentRecord | undefined {
355
+ const pending = this.pendingDeliveries.get(callPath);
356
+ if (pending) {
357
+ throw new WorkflowPolicyError(
358
+ `workflow delivery at ${callPath} requires recovery before resume; patch: ${pending.patchPath}`,
359
+ );
360
+ }
302
361
  const prior = this.priorAgents.get(callPath);
303
362
  if (!prior) return undefined;
304
363
  if (prior.key !== key) {
@@ -478,19 +537,36 @@ export class RunJournal {
478
537
  return credit;
479
538
  }
480
539
 
540
+ recordDeliveryStart(callPath: string, patchPath: string, patchHash: string): void {
541
+ if (this.pendingDeliveries.has(callPath) || this.priorAgents.has(callPath)) {
542
+ throw new WorkflowPolicyError(`workflow delivery already started at ${callPath}`);
543
+ }
544
+ const record: JournalDeliveryStartRecord = { type: "delivery-start", callPath, patchPath, patchHash };
545
+ validateDeliveryStartRecord(record);
546
+ this.append(record);
547
+ this.pendingDeliveries.set(callPath, record);
548
+ }
549
+
481
550
  recordAgent(record: Omit<JournalAgentRecord, "type">): void {
482
551
  const full: JournalAgentRecord = { type: "agent", ...record };
483
552
  assertWorkflowOutputLimit(full.value, "workflow agent output");
484
553
  validateAgentRecord(full);
554
+ const pending = this.pendingDeliveries.get(full.callPath);
555
+ assertDeliveryCompletionMatches(pending, full);
485
556
  this.append(full);
486
557
  this.priorAgents.set(full.callPath, full);
558
+ this.pendingDeliveries.delete(full.callPath);
487
559
  }
488
560
 
489
561
  recordResult(record: Omit<JournalResultRecord, "type">): void {
562
+ if (this.resultRecorded) {
563
+ throw new WorkflowPolicyError("workflow journal generation already has a terminal result");
564
+ }
490
565
  if (record.ok) assertWorkflowOutputLimit(record.result);
491
566
  const full: JournalResultRecord = { type: "result", ...record };
492
567
  validateResultRecord(full);
493
568
  this.append(full);
569
+ this.resultRecorded = true;
494
570
  }
495
571
 
496
572
  close(): void {
@@ -554,6 +630,10 @@ export class RunJournal {
554
630
  }
555
631
 
556
632
  private append(record: JournalRecord): void {
633
+ if (this.resultRecorded && record.type !== "resume") {
634
+ throw new WorkflowPolicyError("workflow journal generation already has a terminal result");
635
+ }
636
+ if (record.type === "resume") this.resultRecorded = false;
557
637
  if (this.writeFailure) throw this.writeFailure;
558
638
  if (this.closeFailure) throw this.closeFailure;
559
639
  if (this.closed || this.fdClosed) throw new WorkflowPolicyError("workflow journal is already closed");
@@ -617,7 +697,13 @@ function loadJournal(filePath: string): LoadedJournal {
617
697
  const completePanels = new Map<string, JournalPanelRecord>();
618
698
  const panelBranches = new Map<string, Map<number, JournalPanelBranchRecord>>();
619
699
  const nestedSources = new Map<string, string>();
700
+ const pendingDeliveries = new Map<string, JournalDeliveryStartRecord>();
701
+ let generationTerminated = false;
620
702
  for (const record of records.slice(1)) {
703
+ if (generationTerminated && record.type !== "resume") {
704
+ throw new WorkflowPolicyError("workflow journal contains a record after a generation terminal result");
705
+ }
706
+ if (record.type === "resume") generationTerminated = false;
621
707
  switch (record.type) {
622
708
  case "resume": {
623
709
  if (!isNonNegativeFinite(record.startedAt)) invalidJournalRecord("resume");
@@ -648,12 +734,21 @@ function loadJournal(filePath: string): LoadedJournal {
648
734
  admittedPaths.add(record.callPath);
649
735
  admissionHashes.set(record.callPath, record.inputHash);
650
736
  break;
737
+ case "delivery-start":
738
+ validateDeliveryStartRecord(record);
739
+ if (pendingDeliveries.has(record.callPath) || agents.has(record.callPath)) {
740
+ throw new WorkflowPolicyError(`workflow journal contains duplicate delivery start: ${record.callPath}`);
741
+ }
742
+ pendingDeliveries.set(record.callPath, record);
743
+ break;
651
744
  case "agent":
652
745
  validateAgentRecord(record);
653
746
  if (agents.has(record.callPath)) {
654
747
  throw new WorkflowPolicyError(`workflow journal contains duplicate agent result: ${record.callPath}`);
655
748
  }
749
+ assertDeliveryCompletionMatches(pendingDeliveries.get(record.callPath), record);
656
750
  agents.set(record.callPath, record);
751
+ pendingDeliveries.delete(record.callPath);
657
752
  break;
658
753
  case "panel-open":
659
754
  if (
@@ -756,6 +851,7 @@ function loadJournal(filePath: string): LoadedJournal {
756
851
  }
757
852
  case "result":
758
853
  validateResultRecord(record);
854
+ generationTerminated = true;
759
855
  break;
760
856
  case "run":
761
857
  throw new WorkflowPolicyError("workflow journal contains more than one run header");
@@ -793,6 +889,7 @@ function loadJournal(filePath: string): LoadedJournal {
793
889
  completePanels,
794
890
  panelBranches,
795
891
  nestedSources,
892
+ pendingDeliveries,
796
893
  };
797
894
  }
798
895
 
@@ -843,6 +940,9 @@ function validateJournalRecordForWrite(record: JournalRecord): void {
843
940
  invalidJournalRecord("nested-source");
844
941
  }
845
942
  return;
943
+ case "delivery-start":
944
+ validateDeliveryStartRecord(record);
945
+ return;
846
946
  case "agent":
847
947
  validateAgentRecord(record);
848
948
  return;
@@ -863,6 +963,8 @@ function validateRunRecord(record: JournalRunMeta): void {
863
963
  || !isNonEmptyString(record.name)
864
964
  || !isNonEmptyString(record.scriptHash)
865
965
  || !isNonNegativeFinite(record.startedAt)
966
+ || typeof record.projectTrusted !== "boolean"
967
+ || !isNonEmptyString(record.targetIdentity)
866
968
  ) invalidJournalRecord("run header");
867
969
  if (record.journalVersion !== undefined && record.journalVersion !== RUN_JOURNAL_VERSION) {
868
970
  invalidJournalRecord("run header");
@@ -875,6 +977,33 @@ function validateRunRecord(record: JournalRunMeta): void {
875
977
  }
876
978
  }
877
979
 
980
+ function validateDeliveryStartRecord(record: JournalDeliveryStartRecord): void {
981
+ if (
982
+ !isNonEmptyString(record.callPath)
983
+ || !isNonEmptyString(record.patchPath)
984
+ || !isNonEmptyString(record.patchHash)
985
+ ) invalidJournalRecord("delivery-start");
986
+ }
987
+
988
+ function assertDeliveryCompletionMatches(
989
+ pending: JournalDeliveryStartRecord | undefined,
990
+ agent: JournalAgentRecord,
991
+ ): void {
992
+ const hasPath = agent.deliveryPatchPath !== undefined;
993
+ const hasHash = agent.deliveryPatchHash !== undefined;
994
+ if (hasPath !== hasHash) invalidJournalRecord("agent delivery completion");
995
+ if (!pending) {
996
+ if (hasPath) invalidJournalRecord("agent delivery completion");
997
+ return;
998
+ }
999
+ if (
1000
+ agent.deliveryPatchPath !== pending.patchPath
1001
+ || agent.deliveryPatchHash !== pending.patchHash
1002
+ ) {
1003
+ throw new WorkflowPolicyError(`workflow journal agent delivery does not match intent: ${agent.callPath}`);
1004
+ }
1005
+ }
1006
+
878
1007
  function validateAgentRecord(record: JournalAgentRecord): void {
879
1008
  const integerFields: Array<keyof JournalAgentRecord> = [
880
1009
  "seq",
@@ -914,6 +1043,8 @@ function validateAgentRecord(record: JournalAgentRecord): void {
914
1043
  "effort",
915
1044
  "agentType",
916
1045
  "isolation",
1046
+ "deliveryPatchPath",
1047
+ "deliveryPatchHash",
917
1048
  "transcriptPath",
918
1049
  ] as const) {
919
1050
  if (record[field] !== undefined && typeof record[field] !== "string") invalidJournalRecord("agent");