ultimate-pi 0.7.0 → 0.9.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 (115) hide show
  1. package/.agents/skills/harness-decisions/SKILL.md +20 -1
  2. package/.agents/skills/harness-eval/SKILL.md +11 -13
  3. package/.agents/skills/harness-orchestration/SKILL.md +36 -30
  4. package/.agents/skills/harness-plan/SKILL.md +13 -18
  5. package/.pi/PACKAGING.md +1 -1
  6. package/.pi/agents/harness/adversary.md +20 -12
  7. package/.pi/agents/harness/evaluator.md +25 -14
  8. package/.pi/agents/harness/executor.md +27 -16
  9. package/.pi/agents/harness/incident-recorder.md +37 -0
  10. package/.pi/agents/harness/meta-optimizer.md +18 -15
  11. package/.pi/agents/harness/planner.md +26 -30
  12. package/.pi/agents/harness/tie-breaker.md +4 -2
  13. package/.pi/agents/harness/trace-librarian.md +18 -11
  14. package/.pi/agents/pi-pi/ext-expert.md +1 -1
  15. package/.pi/agents/pi-pi/keybinding-expert.md +1 -1
  16. package/.pi/agents/pi-pi/tui-expert.md +3 -3
  17. package/.pi/extensions/00-ultimate-pi-system-prompt.ts +2 -2
  18. package/.pi/extensions/budget-guard.ts +47 -18
  19. package/.pi/extensions/custom-footer.ts +8 -3
  20. package/.pi/extensions/custom-header.ts +2 -2
  21. package/.pi/extensions/debate-orchestrator.ts +1 -1
  22. package/.pi/extensions/dotenv-loader.ts +1 -1
  23. package/.pi/extensions/drift-monitor.ts +1 -1
  24. package/.pi/extensions/harness-ask-user.ts +1 -1
  25. package/.pi/extensions/harness-live-widget.ts +1 -1
  26. package/.pi/extensions/harness-run-context.ts +197 -33
  27. package/.pi/extensions/harness-telemetry.ts +1 -1
  28. package/.pi/extensions/harness-web-guard.ts +1 -1
  29. package/.pi/extensions/harness-web-tools.ts +1 -1
  30. package/.pi/extensions/lib/ask-user/dialog.ts +2 -2
  31. package/.pi/extensions/lib/ask-user/fallback.ts +1 -1
  32. package/.pi/extensions/lib/ask-user/render.ts +3 -3
  33. package/.pi/extensions/lib/harness-subagents/agent-loader.ts +1 -1
  34. package/.pi/extensions/lib/harness-subagents/agent-parser.ts +1 -1
  35. package/.pi/extensions/lib/harness-subagents/blackboard-tool.ts +1 -1
  36. package/.pi/extensions/lib/harness-subagents/harness-subagent-policy.ts +134 -0
  37. package/.pi/extensions/lib/harness-subagents/parent-ask-user-bridge.ts +89 -0
  38. package/.pi/extensions/lib/harness-subagents/spawn-policy.ts +20 -2
  39. package/.pi/extensions/lib/harness-subagents/vendored/agent-manager.ts +3 -2
  40. package/.pi/extensions/lib/harness-subagents/vendored/agent-runner.ts +44 -24
  41. package/.pi/extensions/lib/harness-subagents/vendored/context.ts +1 -1
  42. package/.pi/extensions/lib/harness-subagents/vendored/env.ts +1 -1
  43. package/.pi/extensions/lib/harness-subagents/vendored/index.ts +23 -2
  44. package/.pi/extensions/lib/harness-subagents/vendored/output-file.ts +1 -1
  45. package/.pi/extensions/lib/harness-subagents/vendored/schedule.ts +1 -1
  46. package/.pi/extensions/lib/harness-subagents/vendored/settings.ts +1 -1
  47. package/.pi/extensions/lib/harness-subagents/vendored/skill-loader.ts +1 -1
  48. package/.pi/extensions/lib/harness-subagents/vendored/types.ts +2 -2
  49. package/.pi/extensions/lib/harness-subagents/vendored/ui/agent-widget.ts +1 -1
  50. package/.pi/extensions/lib/harness-subagents/vendored/ui/conversation-viewer.ts +2 -2
  51. package/.pi/extensions/lib/harness-subagents/vendored/ui/schedule-menu.ts +1 -1
  52. package/.pi/extensions/observation-bus.ts +1 -1
  53. package/.pi/extensions/pi-model-router-harness.ts +1 -1
  54. package/.pi/extensions/policy-gate.ts +90 -20
  55. package/.pi/extensions/provider-payload-sanitize.ts +1 -1
  56. package/.pi/extensions/review-integrity.ts +76 -22
  57. package/.pi/extensions/sentrux-rules-sync.ts +1 -1
  58. package/.pi/extensions/soundboard.ts +1 -1
  59. package/.pi/extensions/test-diff-integrity.ts +1 -1
  60. package/.pi/extensions/trace-recorder.ts +1 -1
  61. package/.pi/extensions/ultimate-pi-vcc.ts +1 -1
  62. package/.pi/harness/agents.manifest.json +82 -78
  63. package/.pi/harness/docs/adrs/0031-harness-run-context.md +6 -3
  64. package/.pi/harness/docs/adrs/0032-harness-command-orchestration.md +37 -0
  65. package/.pi/harness/docs/adrs/README.md +1 -0
  66. package/.pi/harness/specs/budget-exhausted-event.schema.json +3 -1
  67. package/.pi/harness/specs/harness-spawn-context.schema.json +65 -0
  68. package/.pi/harness/specs/harness-turn.schema.json +18 -0
  69. package/.pi/lib/harness-agent-output.ts +41 -0
  70. package/.pi/lib/harness-run-context.ts +516 -37
  71. package/.pi/lib/harness-ui-state.ts +1 -1
  72. package/.pi/prompts/harness-auto.md +36 -61
  73. package/.pi/prompts/harness-critic.md +15 -28
  74. package/.pi/prompts/harness-eval.md +19 -27
  75. package/.pi/prompts/harness-incident.md +15 -34
  76. package/.pi/prompts/harness-plan.md +28 -49
  77. package/.pi/prompts/harness-review.md +16 -30
  78. package/.pi/prompts/harness-router-tune.md +16 -38
  79. package/.pi/prompts/harness-run.md +21 -38
  80. package/.pi/prompts/harness-setup.md +2 -0
  81. package/.pi/prompts/harness-trace.md +13 -30
  82. package/.pi/scripts/harness-generate-model-router.mjs +16 -13
  83. package/.pi/scripts/harness-verify.mjs +17 -0
  84. package/.pi/scripts/vendor-sync-pi-model-router.sh +10 -10
  85. package/CHANGELOG.md +25 -1
  86. package/README.md +4 -5
  87. package/THIRD_PARTY_NOTICES.md +1 -1
  88. package/package.json +13 -8
  89. package/vendor/pi-model-router/UPSTREAM_PIN.md +1 -1
  90. package/vendor/pi-model-router/extensions/commands.ts +2 -2
  91. package/vendor/pi-model-router/extensions/config.ts +2 -2
  92. package/vendor/pi-model-router/extensions/index.ts +1 -1
  93. package/vendor/pi-model-router/extensions/provider.ts +2 -2
  94. package/vendor/pi-model-router/extensions/routing.ts +2 -2
  95. package/vendor/pi-model-router/extensions/types.ts +1 -1
  96. package/vendor/pi-model-router/extensions/ui.ts +1 -1
  97. package/vendor/pi-model-router/package.json +4 -4
  98. package/vendor/pi-vcc/index.ts +1 -1
  99. package/vendor/pi-vcc/package.json +1 -1
  100. package/vendor/pi-vcc/src/commands/pi-vcc.ts +1 -1
  101. package/vendor/pi-vcc/src/commands/vcc-recall.ts +1 -1
  102. package/vendor/pi-vcc/src/core/content.ts +1 -1
  103. package/vendor/pi-vcc/src/core/load-messages.ts +1 -1
  104. package/vendor/pi-vcc/src/core/normalize.ts +1 -1
  105. package/vendor/pi-vcc/src/core/render-entries.ts +1 -1
  106. package/vendor/pi-vcc/src/core/report.ts +1 -1
  107. package/vendor/pi-vcc/src/core/search-entries.ts +1 -1
  108. package/vendor/pi-vcc/src/core/summarize.ts +1 -1
  109. package/vendor/pi-vcc/src/hooks/before-compact.ts +2 -2
  110. package/vendor/pi-vcc/src/tools/recall.ts +1 -1
  111. package/vendor/pi-vcc/src/types.ts +1 -1
  112. package/vendor/pi-vcc/tests/fixtures.ts +1 -1
  113. package/vendor/pi-vcc/tests/render-entries.test.ts +1 -1
  114. package/vendor/pi-vcc/tests/search-entries.test.ts +1 -1
  115. package/vendor/pi-vcc/tests/support/load-session.ts +2 -2
@@ -6,7 +6,7 @@
6
6
  * - `.pi/harness/active-run.json` (cross-session pointer)
7
7
  */
8
8
 
9
- import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
10
10
  import { isAbsolute, join, relative, resolve } from "node:path";
11
11
 
12
12
  export type HarnessPhase =
@@ -114,6 +114,412 @@ export function canonicalPlanPath(runId: string, projectRoot: string): string {
114
114
  return join(harnessRunsRoot(projectRoot), runId, "plan-packet.json");
115
115
  }
116
116
 
117
+ const PLAN_PACKET_BASENAME = "plan-packet.json";
118
+
119
+ const MUTATING_FILE_TOOLS = new Set(["write", "edit"]);
120
+
121
+ const PLAN_APPROVE_OPTION =
122
+ /^(approve(d)?(\s+plan)?|yes,?\s+proceed|looks\s+good)$/i;
123
+ const PLAN_CANCEL_OPTION =
124
+ /^(cancel(led)?|revise|request\s+changes|needs?\s+clarification)$/i;
125
+
126
+ export interface PlanUserApproval {
127
+ plan_id: string | null;
128
+ approved_at: string;
129
+ source: "ask_user" | "harness-plan-approval" | "noninteractive";
130
+ }
131
+
132
+ /** Persisted on `input` when user invokes a raw `/harness-*` prompt template. */
133
+ export interface HarnessTurnEntry {
134
+ schema_version: "1.0.0";
135
+ command: string;
136
+ args: string;
137
+ source: "slash";
138
+ invoked_at: string;
139
+ }
140
+
141
+ export const HARNESS_COMMAND_PHASE: Record<string, HarnessPhase> = {
142
+ "harness-plan": "plan",
143
+ "harness-auto": "plan",
144
+ "harness-run": "execute",
145
+ "harness-eval": "evaluate",
146
+ "harness-review": "evaluate",
147
+ "harness-critic": "adversary",
148
+ "harness-trace": "evaluate",
149
+ "harness-incident": "evaluate",
150
+ "harness-drift-replan": "plan",
151
+ "harness-drift-proceed": "execute",
152
+ "harness-abort": "plan",
153
+ "harness-new-run": "plan",
154
+ "harness-run-status": "plan",
155
+ "harness-use-run": "plan",
156
+ "harness-policy-status": "merge",
157
+ "harness-router-tune": "plan",
158
+ "harness-budget-status": "plan",
159
+ "harness-setup": "execute",
160
+ };
161
+
162
+ export interface PlanPhaseMutationDecision {
163
+ allowed: boolean;
164
+ reason?: string;
165
+ isScopedPlanWrite?: boolean;
166
+ }
167
+
168
+ /** Resolve path relative to project root when not absolute. */
169
+ export function normalizeHarnessPath(
170
+ path: string,
171
+ projectRoot: string,
172
+ ): string {
173
+ const trimmed = path.trim();
174
+ if (!trimmed) return resolve(projectRoot);
175
+ if (isAbsolute(trimmed)) return resolve(trimmed);
176
+ return resolve(projectRoot, trimmed);
177
+ }
178
+
179
+ export function isCanonicalPlanPacketPath(
180
+ absPath: string,
181
+ projectRoot: string,
182
+ runId: string,
183
+ ): boolean {
184
+ const expected = resolve(canonicalPlanPath(runId, projectRoot));
185
+ return resolve(absPath) === expected;
186
+ }
187
+
188
+ export function extractWritePathFromToolInput(
189
+ input: Record<string, unknown>,
190
+ ): string {
191
+ const raw =
192
+ (typeof input.path === "string" && input.path) ||
193
+ (typeof input.filePath === "string" && input.filePath) ||
194
+ "";
195
+ return raw.trim();
196
+ }
197
+
198
+ /** True when absPath is the canonical plan-packet.json for the active run. */
199
+ export async function isPlanPhaseScopedWrite(
200
+ absPath: string,
201
+ runCtx: HarnessRunContext | null,
202
+ projectRoot: string,
203
+ ): Promise<boolean> {
204
+ if (!runCtx?.run_id) return false;
205
+ let resolved: string;
206
+ try {
207
+ resolved = await realpath(normalizeHarnessPath(absPath, projectRoot));
208
+ } catch {
209
+ resolved = normalizeHarnessPath(absPath, projectRoot);
210
+ }
211
+ const runsRoot = resolve(harnessRunsRoot(projectRoot));
212
+ let runsReal: string;
213
+ try {
214
+ runsReal = await realpath(runsRoot);
215
+ } catch {
216
+ runsReal = runsRoot;
217
+ }
218
+ const rel = relative(runsReal, resolved);
219
+ if (rel.startsWith("..") || isAbsolute(rel)) return false;
220
+ const parts = rel.split(/[/\\]/);
221
+ if (parts.length !== 2 || parts[1] !== PLAN_PACKET_BASENAME) return false;
222
+ if (parts[0] !== runCtx.run_id) return false;
223
+ return isCanonicalPlanPacketPath(resolved, projectRoot, runCtx.run_id);
224
+ }
225
+
226
+ export function getLatestHarnessTurn(
227
+ entries: unknown[],
228
+ ): HarnessTurnEntry | null {
229
+ for (let i = entries.length - 1; i >= 0; i--) {
230
+ const entry = entries[i] as SessionEntryLike;
231
+ if (entry.type !== "custom" || entry.customType !== "harness-turn") {
232
+ continue;
233
+ }
234
+ const data = entry.data as Partial<HarnessTurnEntry> | undefined;
235
+ if (data?.command && typeof data.command === "string") {
236
+ return {
237
+ schema_version: "1.0.0",
238
+ command: data.command,
239
+ args: typeof data.args === "string" ? data.args : "",
240
+ source: "slash",
241
+ invoked_at:
242
+ typeof data.invoked_at === "string" ? data.invoked_at : nowIso(),
243
+ };
244
+ }
245
+ }
246
+ return null;
247
+ }
248
+
249
+ export function indexOfLastPlanCommand(entries: unknown[]): number {
250
+ for (let i = entries.length - 1; i >= 0; i--) {
251
+ const entry = entries[i] as SessionEntryLike & {
252
+ message?: { role?: string; content?: string | unknown[] };
253
+ };
254
+ if (entry.type === "custom" && entry.customType === "harness-turn") {
255
+ const cmd = (entry.data as { command?: string })?.command;
256
+ if (cmd === "harness-plan" || cmd === "harness-auto") {
257
+ return i;
258
+ }
259
+ }
260
+ if (
261
+ entry.type === "custom" &&
262
+ entry.customType === "harness-plan-attempt"
263
+ ) {
264
+ return i;
265
+ }
266
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
267
+ const content = entry.message.content;
268
+ const text =
269
+ typeof content === "string"
270
+ ? content
271
+ : Array.isArray(content)
272
+ ? content
273
+ .filter(
274
+ (c): c is { type: string; text?: string } =>
275
+ typeof c === "object" &&
276
+ c !== null &&
277
+ (c as { type?: string }).type === "text",
278
+ )
279
+ .map((c) => c.text ?? "")
280
+ .join("\n")
281
+ : "";
282
+ const visible = userVisiblePromptSlice(text);
283
+ const parsed = parseHarnessSlashInput(visible);
284
+ if (
285
+ parsed?.command === "harness-plan" ||
286
+ parsed?.command === "harness-auto"
287
+ ) {
288
+ return i;
289
+ }
290
+ }
291
+ return -1;
292
+ }
293
+
294
+ export function parseAskUserApprovalFromMessage(msg: {
295
+ toolName?: string;
296
+ details?: unknown;
297
+ content?: { type?: string; text?: string }[];
298
+ }): PlanUserApproval | null {
299
+ if (msg.toolName !== "ask_user") return null;
300
+ const details = msg.details as
301
+ | {
302
+ cancelled?: boolean;
303
+ response?: {
304
+ kind?: string;
305
+ text?: string;
306
+ selections?: string[];
307
+ };
308
+ }
309
+ | undefined;
310
+ if (details?.cancelled) return null;
311
+ const response = details?.response;
312
+ if (!response) return null;
313
+ if (response.kind === "freeform") {
314
+ const text = (response.text ?? "").trim();
315
+ if (/^approve(d)?\b/i.test(text)) {
316
+ return {
317
+ plan_id: null,
318
+ approved_at: nowIso(),
319
+ source: "ask_user",
320
+ };
321
+ }
322
+ return null;
323
+ }
324
+ const selection = (response.selections?.[0] ?? "").trim();
325
+ if (!selection || PLAN_CANCEL_OPTION.test(selection)) return null;
326
+ if (PLAN_APPROVE_OPTION.test(selection)) {
327
+ return {
328
+ plan_id: null,
329
+ approved_at: nowIso(),
330
+ source: "ask_user",
331
+ };
332
+ }
333
+ return null;
334
+ }
335
+
336
+ export function getLatestPlanUserApproval(
337
+ entries: unknown[],
338
+ sinceIndex = 0,
339
+ ): PlanUserApproval | null {
340
+ for (let i = entries.length - 1; i >= sinceIndex; i--) {
341
+ const entry = entries[i] as SessionEntryLike & {
342
+ message?: {
343
+ role?: string;
344
+ toolName?: string;
345
+ details?: unknown;
346
+ content?: { type?: string; text?: string }[];
347
+ };
348
+ };
349
+ if (
350
+ entry.type === "custom" &&
351
+ entry.customType === "harness-plan-approval"
352
+ ) {
353
+ const data = entry.data as Partial<PlanUserApproval> | undefined;
354
+ if (data?.approved_at) {
355
+ return {
356
+ plan_id: typeof data.plan_id === "string" ? data.plan_id : null,
357
+ approved_at: data.approved_at,
358
+ source:
359
+ data.source === "noninteractive"
360
+ ? "noninteractive"
361
+ : "harness-plan-approval",
362
+ };
363
+ }
364
+ }
365
+ if (entry.type !== "message" || entry.message?.role !== "toolResult") {
366
+ continue;
367
+ }
368
+ const fromAsk = parseAskUserApprovalFromMessage(entry.message);
369
+ if (fromAsk) return fromAsk;
370
+ }
371
+ return null;
372
+ }
373
+
374
+ export function hasPlanUserApproval(
375
+ entries: unknown[],
376
+ opts?: { planId?: string | null; sincePlanCommand?: boolean },
377
+ ): boolean {
378
+ if (process.env.HARNESS_PLAN_NONINTERACTIVE === "1") {
379
+ return true;
380
+ }
381
+ const since = opts?.sincePlanCommand
382
+ ? Math.max(0, indexOfLastPlanCommand(entries))
383
+ : 0;
384
+ const approval = getLatestPlanUserApproval(entries, since);
385
+ if (!approval) return false;
386
+ if (opts?.planId && approval.plan_id && approval.plan_id !== opts.planId) {
387
+ return false;
388
+ }
389
+ return true;
390
+ }
391
+
392
+ export function isHarnessAutoSession(entries: unknown[]): boolean {
393
+ const since = indexOfLastPlanCommand(entries);
394
+ if (since < 0) return false;
395
+ for (let i = since; i < entries.length; i++) {
396
+ const entry = entries[i] as SessionEntryLike & {
397
+ message?: { role?: string; content?: string };
398
+ };
399
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
400
+ const text =
401
+ typeof entry.message.content === "string"
402
+ ? userVisiblePromptSlice(entry.message.content)
403
+ : "";
404
+ const parsed = parseHarnessSlashInput(text);
405
+ if (parsed?.command === "harness-auto") return true;
406
+ }
407
+ return false;
408
+ }
409
+
410
+ export async function isPlanPhaseAllowedMutation(
411
+ toolName: string,
412
+ input: Record<string, unknown>,
413
+ phase: HarnessPhase,
414
+ runCtx: HarnessRunContext | null,
415
+ projectRoot: string,
416
+ opts: {
417
+ aborted: boolean;
418
+ entries: unknown[];
419
+ ownerSessionId?: string;
420
+ currentSessionId?: string;
421
+ },
422
+ ): Promise<PlanPhaseMutationDecision> {
423
+ if (!MUTATING_FILE_TOOLS.has(toolName)) {
424
+ if (phase === "execute" || phase === "merge") {
425
+ return { allowed: true };
426
+ }
427
+ return {
428
+ allowed: false,
429
+ reason: `policy-gate: ${toolName} blocked in phase '${phase}'.`,
430
+ };
431
+ }
432
+
433
+ if (
434
+ runCtx?.owner_pi_session_id &&
435
+ opts.currentSessionId &&
436
+ runCtx.owner_pi_session_id !== opts.currentSessionId
437
+ ) {
438
+ return {
439
+ allowed: false,
440
+ reason:
441
+ "harness-run-context: this session does not own the active run; plan writes are read-only here.",
442
+ };
443
+ }
444
+
445
+ const target = extractWritePathFromToolInput(input);
446
+ if (!target) {
447
+ return {
448
+ allowed: false,
449
+ reason: "policy-gate: write/edit requires a path.",
450
+ };
451
+ }
452
+
453
+ const scoped = runCtx
454
+ ? await isPlanPhaseScopedWrite(target, runCtx, projectRoot)
455
+ : false;
456
+
457
+ if (scoped) {
458
+ if (!runCtx) {
459
+ return {
460
+ allowed: false,
461
+ reason:
462
+ 'policy-gate: no active harness run. Run /harness-plan "<task>" first.',
463
+ };
464
+ }
465
+ if (
466
+ !hasPlanUserApproval(opts.entries, {
467
+ sincePlanCommand: true,
468
+ planId: runCtx.plan_id,
469
+ })
470
+ ) {
471
+ return {
472
+ allowed: false,
473
+ isScopedPlanWrite: true,
474
+ reason:
475
+ "policy-gate: plan-packet.json write blocked until the user approves via ask_user (present the full plan, then Approve).",
476
+ };
477
+ }
478
+ if (opts.aborted) {
479
+ return { allowed: true, isScopedPlanWrite: true };
480
+ }
481
+ if (phase === "plan") {
482
+ return { allowed: true, isScopedPlanWrite: true };
483
+ }
484
+ if (phase === "execute" || phase === "merge") {
485
+ return { allowed: true, isScopedPlanWrite: true };
486
+ }
487
+ return {
488
+ allowed: false,
489
+ isScopedPlanWrite: true,
490
+ reason: `harness-run-context: plan-packet.json is read-only in phase '${phase}'.`,
491
+ };
492
+ }
493
+
494
+ if (opts.aborted) {
495
+ return {
496
+ allowed: false,
497
+ reason:
498
+ "policy-gate: mutating tool blocked because harness-abort lock is active. Attach a new approved plan via plan-packet.json first.",
499
+ };
500
+ }
501
+
502
+ if (phase === "execute" || phase === "merge") {
503
+ return { allowed: true };
504
+ }
505
+
506
+ if (phase === "plan" && !runCtx) {
507
+ return {
508
+ allowed: false,
509
+ reason:
510
+ 'policy-gate: no active harness run. Run /harness-plan "<task>" first.',
511
+ };
512
+ }
513
+
514
+ const allowedPath = runCtx?.run_id
515
+ ? canonicalPlanPath(runCtx.run_id, projectRoot)
516
+ : ".pi/harness/runs/<run_id>/plan-packet.json";
517
+ return {
518
+ allowed: false,
519
+ reason: `policy-gate: ${toolName} blocked in phase '${phase}'. In plan phase only ${allowedPath} is writable after ask_user approval.`,
520
+ };
521
+ }
522
+
117
523
  export function allocateRunId(sessionId: string): string {
118
524
  return `${sessionId}-${Date.now()}`;
119
525
  }
@@ -122,18 +528,16 @@ export function nowIso(): string {
122
528
  return new Date().toISOString();
123
529
  }
124
530
 
531
+ /** @deprecated Use parseHarnessSlashInput on raw `input` event text only. */
125
532
  export function isHarnessSlashCommand(prompt: string): boolean {
126
- const trimmed = prompt.trim();
127
- if (!trimmed.startsWith("/harness-")) return false;
128
- const match = trimmed.match(/^\/(harness-[a-z0-9-]+)/);
129
- if (!match) return false;
130
- return HARNESS_COMMANDS.has(match[1]);
533
+ return parseHarnessSlashInput(prompt) !== null;
131
534
  }
132
535
 
133
- export function parseHarnessSlashCommand(
134
- prompt: string,
536
+ /** Parse raw user input before prompt-template expansion (`input` hook only). */
537
+ export function parseHarnessSlashInput(
538
+ text: string,
135
539
  ): { command: string; args: string } | null {
136
- const trimmed = prompt.trim();
540
+ const trimmed = text.trim();
137
541
  const match = trimmed.match(/^\/(harness-[a-z0-9-]+)(?:\s+([\s\S]*))?$/);
138
542
  if (!match) return null;
139
543
  const command = match[1];
@@ -141,6 +545,13 @@ export function parseHarnessSlashCommand(
141
545
  return { command, args: (match[2] ?? "").trim() };
142
546
  }
143
547
 
548
+ /** @deprecated Prefer parseHarnessSlashInput on raw input; kept for expanded-prompt fallbacks. */
549
+ export function parseHarnessSlashCommand(
550
+ prompt: string,
551
+ ): { command: string; args: string } | null {
552
+ return parseHarnessSlashInput(userVisiblePromptSlice(prompt));
553
+ }
554
+
144
555
  /** User-visible prompt slice for policy signals (exclude injected blocks). */
145
556
  export function userVisiblePromptSlice(prompt: string): string {
146
557
  const markers = [
@@ -373,7 +784,32 @@ export function planPacketSummary(
373
784
  };
374
785
  }
375
786
 
376
- export function formatPlanContextBlock(ctx: HarnessRunContext): string {
787
+ export function buildHarnessSpawnContextSnippet(
788
+ ctx: HarnessRunContext,
789
+ opts?: { mode?: "create" | "revise"; risk_level?: string; quick?: boolean },
790
+ ): string {
791
+ const mode =
792
+ opts?.mode ??
793
+ (ctx.plan_ready || ctx.status === "aborted" ? "revise" : "create");
794
+ return JSON.stringify(
795
+ {
796
+ schema_version: "1.0.0",
797
+ run_id: ctx.run_id,
798
+ plan_packet_path: ctx.plan_packet_path,
799
+ task_summary: ctx.task_summary,
800
+ mode,
801
+ risk_level: opts?.risk_level ?? "med",
802
+ quick: opts?.quick ?? false,
803
+ },
804
+ null,
805
+ 2,
806
+ );
807
+ }
808
+
809
+ export function formatPlanContextBlock(
810
+ ctx: HarnessRunContext,
811
+ opts?: { mode?: "create" | "revise"; risk_level?: string; quick?: boolean },
812
+ ): string {
377
813
  const lines = [
378
814
  "[HarnessRunContext]",
379
815
  `run_id=${ctx.run_id}`,
@@ -388,6 +824,12 @@ export function formatPlanContextBlock(ctx: HarnessRunContext): string {
388
824
  if (ctx.plan_packet_path) {
389
825
  lines.push(`plan_packet_path=${ctx.plan_packet_path}`);
390
826
  }
827
+ if (ctx.task_summary) {
828
+ lines.push(`task_summary=${ctx.task_summary}`);
829
+ }
830
+ lines.push(
831
+ `HarnessSpawnContext=${buildHarnessSpawnContextSnippet(ctx, opts)}`,
832
+ );
391
833
  return lines.join("\n");
392
834
  }
393
835
 
@@ -471,13 +913,11 @@ export function validatePlanOverridePath(
471
913
  runId: string,
472
914
  projectRoot: string,
473
915
  ): { ok: boolean; reason?: string } {
474
- const absPlan = resolve(planPath);
475
- const runsDir = resolve(harnessRunsRoot(projectRoot), runId);
476
- const rel = relative(runsDir, absPlan);
477
- if (rel.startsWith("..") || isAbsolute(rel)) {
916
+ const absPlan = normalizeHarnessPath(planPath, projectRoot);
917
+ if (!isCanonicalPlanPacketPath(absPlan, projectRoot, runId)) {
478
918
  return {
479
919
  ok: false,
480
- reason: `--plan must be under runs/${runId}/ or use /harness-use-run to switch runs`,
920
+ reason: `--plan must be runs/${runId}/plan-packet.json (canonical plan packet only)`,
481
921
  };
482
922
  }
483
923
  return { ok: true };
@@ -505,7 +945,7 @@ export function shouldReuseHarnessRunId(
505
945
  ctx: HarnessRunContext | null,
506
946
  command: string | null,
507
947
  ): boolean {
508
- if (!command || !isHarnessSlashCommand(prompt)) return false;
948
+ if (!command) return false;
509
949
  if (command === "harness-new-run") return false;
510
950
  if (!ctx) return false;
511
951
  if (command === "harness-plan" || command === "harness-auto") {
@@ -530,27 +970,43 @@ export interface HarnessPolicyState {
530
970
  aborted: boolean;
531
971
  }
532
972
 
973
+ export function inferHarnessPhaseFromTurn(entries: unknown[]): HarnessPhase | null {
974
+ const turn = getLatestHarnessTurn(entries);
975
+ if (!turn) return null;
976
+ return HARNESS_COMMAND_PHASE[turn.command] ?? null;
977
+ }
978
+
979
+ /** Prefer session `harness-turn`; fall back to raw slash in visible prompt only. */
980
+ export function inferHarnessPhase(
981
+ entries: unknown[],
982
+ userPrompt?: string,
983
+ ): HarnessPhase {
984
+ const fromTurn = inferHarnessPhaseFromTurn(entries);
985
+ if (fromTurn) return fromTurn;
986
+ if (userPrompt) {
987
+ const parsed = parseHarnessSlashInput(userVisiblePromptSlice(userPrompt));
988
+ if (parsed && HARNESS_COMMAND_PHASE[parsed.command]) {
989
+ return HARNESS_COMMAND_PHASE[parsed.command];
990
+ }
991
+ }
992
+ return "execute";
993
+ }
994
+
995
+ /** @deprecated Use inferHarnessPhase(entries, prompt) — substring matching causes false plan phase. */
533
996
  export function inferHarnessPhaseFromPrompt(prompt: string): HarnessPhase {
534
- const p = prompt.toLowerCase();
535
- if (
536
- p.includes("/harness-plan") ||
537
- p.includes("harness-plan") ||
538
- p.includes("/harness-auto") ||
539
- p.includes("harness-auto")
540
- ) {
541
- return "plan";
997
+ const p = userVisiblePromptSlice(prompt).toLowerCase();
998
+ const parsed = parseHarnessSlashInput(userVisiblePromptSlice(prompt));
999
+ if (parsed && HARNESS_COMMAND_PHASE[parsed.command]) {
1000
+ return HARNESS_COMMAND_PHASE[parsed.command];
542
1001
  }
543
- if (p.includes("/harness-run") || p.includes("harness-run")) return "execute";
544
- if (p.includes("/harness-eval") || p.includes("harness-eval")) {
545
- return "evaluate";
1002
+ if (p.startsWith("/harness-plan") || p.startsWith("/harness-auto")) {
1003
+ return "plan";
546
1004
  }
547
- if (p.includes("/harness-review") || p.includes("harness-review")) {
1005
+ if (p.startsWith("/harness-run")) return "execute";
1006
+ if (p.startsWith("/harness-eval") || p.startsWith("/harness-review")) {
548
1007
  return "evaluate";
549
1008
  }
550
- if (p.includes("/harness-critic") || p.includes("harness-critic")) {
551
- return "adversary";
552
- }
553
- if (p.includes("adversary")) return "adversary";
1009
+ if (p.startsWith("/harness-critic")) return "adversary";
554
1010
  if (p.includes("merge gate") || p.includes("policy decision")) return "merge";
555
1011
  return "execute";
556
1012
  }
@@ -569,8 +1025,8 @@ export function isValidHarnessPhaseTransition(
569
1025
 
570
1026
  export function getLatestPolicyState(entries: unknown[]): HarnessPolicyState {
571
1027
  const fallback: HarnessPolicyState = {
572
- phase: "execute",
573
- approvedPlan: true,
1028
+ phase: "plan",
1029
+ approvedPlan: false,
574
1030
  planId: null,
575
1031
  aborted: false,
576
1032
  };
@@ -625,7 +1081,7 @@ export function getPolicyTransitionBlock(
625
1081
  return { blocked: false };
626
1082
  }
627
1083
  const state = getLatestPolicyState(entries);
628
- const nextPhase = inferHarnessPhaseFromPrompt(userPrompt);
1084
+ const nextPhase = inferHarnessPhase(entries, userPrompt);
629
1085
  if (!isValidHarnessPhaseTransition(state.phase, nextPhase)) {
630
1086
  return {
631
1087
  blocked: true,
@@ -669,7 +1125,7 @@ export function isNewTaskPlanBlocked(
669
1125
  ): boolean {
670
1126
  if (ctx.status !== "active") return false;
671
1127
  if (isAmendPlanAllowed(ctx, prompt, false)) return false;
672
- const cmd = parseHarnessSlashCommand(prompt);
1128
+ const cmd = parseHarnessSlashInput(userVisiblePromptSlice(prompt));
673
1129
  if (cmd?.command !== "harness-plan") return false;
674
1130
  const taskMatch = prompt.match(/"([^"]+)"/);
675
1131
  if (!taskMatch || !ctx.task_summary) return true;
@@ -701,7 +1157,7 @@ export function nextStepAfterOutcome(input: {
701
1157
  return "/harness-plan or /harness-abort";
702
1158
  }
703
1159
  if (exec === "completed") {
704
- return "New Pi session → /harness-eval";
1160
+ return "/harness-eval";
705
1161
  }
706
1162
  }
707
1163
  if (input.phase === "evaluate") {
@@ -792,3 +1248,26 @@ export function driftGateActive(entries: unknown[]): boolean {
792
1248
  export function phaseTraceFileName(phase: HarnessPhase): string {
793
1249
  return `trace-${phase}.json`;
794
1250
  }
1251
+
1252
+ /** Collect plan approvals from a session entry list (e.g. subagent in-memory session). */
1253
+ export function extractPlanApprovalsFromEntries(
1254
+ entries: unknown[],
1255
+ ): PlanUserApproval[] {
1256
+ const out: PlanUserApproval[] = [];
1257
+ for (let i = 0; i < entries.length; i++) {
1258
+ const entry = entries[i] as SessionEntryLike & {
1259
+ message?: {
1260
+ role?: string;
1261
+ toolName?: string;
1262
+ details?: unknown;
1263
+ content?: { type?: string; text?: string }[];
1264
+ };
1265
+ };
1266
+ if (entry.type !== "message" || entry.message?.role !== "toolResult") {
1267
+ continue;
1268
+ }
1269
+ const fromAsk = parseAskUserApprovalFromMessage(entry.message);
1270
+ if (fromAsk) out.push(fromAsk);
1271
+ }
1272
+ return out;
1273
+ }
@@ -1,4 +1,4 @@
1
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export type HarnessPhase =
4
4
  | "plan"