pi-pier 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,13 +1,6 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D65, D75). */
1
+ /** Single owner of todo state; every mutation path (tool, human edit, M17 reconcile, archive) lands here (D65, D75). */
2
2
  import { EventEmitter } from 'node:events';
3
- import {
4
- applyTodoEdits,
5
- foldLatestTodosMeta,
6
- listsEqual,
7
- type TodoEdit,
8
- type TodoItem,
9
- } from './todo-core.ts';
10
- import { countCompletedTransitions } from './progress-core.ts';
3
+ import { applyTodoEdits, completionTransitions, foldLatestTodosMeta, listsEqual, type TodoEdit, type TodoItem } from './todo-core.ts';
11
4
  import type { RuntimeRoleManifest } from './tool-gate.ts';
12
5
 
13
6
  export interface TodosConfig {
@@ -15,15 +8,15 @@ export interface TodosConfig {
15
8
  allowParallelInProgress: boolean;
16
9
  }
17
10
 
11
+ export type TodoCompletionSource = 'tool' | 'reconcile' | 'human' | 'archive';
12
+
18
13
  export class TodosService extends EventEmitter {
19
14
  private _items: TodoItem[] = [];
20
- /**
21
- * Live list (read-only). Mutations must go through replace/applyEdits/rebuild.
22
- */
15
+ /** Live list (read-only). Mutations must go through replace/applyEdits/rebuild. */
23
16
  get items(): readonly TodoItem[] {
24
17
  return this._items;
25
18
  }
26
- /** Why: Preserve the established compatibility and safety behavior. */
19
+ /** Clock anchor for staleness/archiving; null until the first write. */
27
20
  lastWriteAt: number | null = null;
28
21
  readonly config: TodosConfig;
29
22
 
@@ -32,56 +25,55 @@ export class TodosService extends EventEmitter {
32
25
  this.config = config;
33
26
  }
34
27
 
35
- /** Why: Preserve the established compatibility and safety behavior (D75). */
28
+ /** D75: subagents are strict (one in_progress); the role manifest may force parallel mode. */
36
29
  static configFromRuntime(manifest: RuntimeRoleManifest | null, isSubagent: boolean): TodosConfig {
37
30
  const strict = isSubagent;
38
31
  const mode = manifest?.services?.todos?.mode;
39
- const allowParallelInProgress = mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict;
40
- return { strict, allowParallelInProgress };
41
- }
42
-
43
- /** Defensive copy for callers that must not mutate service state. */
44
- getSnapshot(): TodoItem[] {
45
- return this._items.map((it) => ({ ...it }));
32
+ return {
33
+ strict,
34
+ allowParallelInProgress: mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict,
35
+ };
46
36
  }
47
37
 
48
- replace(items: readonly TodoItem[]): { changed: boolean } {
38
+ replace(items: readonly TodoItem[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
49
39
  const next = items.map((it) => ({ ...it }));
50
40
  if (listsEqual(this._items, next)) return { changed: false };
51
41
  const before = this._items;
52
42
  this._items = next;
53
43
  this.lastWriteAt = Date.now();
54
- this.emitCompletedTransitions(before, this._items);
44
+ this.emitCompletedTransitions(before, this._items, opts?.source ?? 'tool');
55
45
  this.emit('todo.updated', { items: this._items });
56
46
  return { changed: true };
57
47
  }
58
48
 
59
- applyEdits(edits: readonly TodoEdit[]): { changed: boolean } {
49
+ applyEdits(edits: readonly TodoEdit[], opts?: { source?: TodoCompletionSource }): { changed: boolean } {
60
50
  const before = this._items;
61
51
  const next = applyTodoEdits(this._items, edits);
62
52
  if (listsEqual(before, next)) return { changed: false };
63
53
  this._items = next;
64
54
  this.lastWriteAt = Date.now();
65
- this.emitCompletedTransitions(before, this._items);
55
+ this.emitCompletedTransitions(before, this._items, opts?.source ?? 'reconcile');
66
56
  this.emit('todo.edited', { edits, items: this._items });
67
57
  this.emit('todo.updated', { items: this._items });
68
58
  return { changed: true };
69
59
  }
70
60
 
71
- /** Why: Preserve the established compatibility and safety behavior (M16). */
72
- private emitCompletedTransitions(before: readonly TodoItem[], after: readonly TodoItem[]): void {
73
- const count = countCompletedTransitions(before, after);
74
- if (count > 0) this.emit('todo.completed', { count, at: Date.now() });
61
+ /** M16: ETA and compaction boundaries feed off this single completion diff (same rule as the tool's D36 report). */
62
+ private emitCompletedTransitions(
63
+ before: readonly TodoItem[],
64
+ after: readonly TodoItem[],
65
+ source: TodoCompletionSource = 'tool',
66
+ ): void {
67
+ const count = completionTransitions(before, after).length;
68
+ if (count > 0) this.emit('todo.completed', { count, at: Date.now(), source });
75
69
  }
76
70
 
71
+ /** Branch replay: fold the JSONL to the authoritative list. A branch with no snapshot (/new, or /tree
72
+ * back before the first todo_write) has no list, so the previous session's items must not survive. */
77
73
  rebuild(entries: readonly unknown[]): void {
78
- // Why: Preserve the established compatibility and safety behavior.
79
- // Why: Preserve the established compatibility and safety behavior.
80
74
  const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
81
- if (folded) {
82
- this._items = folded.items;
83
- this.lastWriteAt = folded.writtenAt;
84
- }
75
+ this._items = folded ? folded.items : [];
76
+ this.lastWriteAt = folded ? folded.writtenAt : null;
85
77
  this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
86
78
  }
87
79
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * pi only marks a tool result as failed when `execute()` throws; returning a value never sets the
3
+ * error flag (pi docs/extensions.md, "Signaling errors"). Every hard failure goes through
4
+ * `toolError()`, while deliberate non-failures (empty result set, "no match within Xms", a pane that
5
+ * has not produced output yet) stay normal results.
6
+ *
7
+ * The leading "Error: " text is stripped: the provider's tool-result error channel and pi's red
8
+ * rendering already mark it, and keeping it would double the prefix once pi formats a thrown error.
9
+ */
10
+ export class ToolError extends Error {
11
+ constructor(message: string) {
12
+ super(String(message).replace(/^Error:\s*/, ''));
13
+ this.name = 'ToolError';
14
+ }
15
+ }
16
+
17
+ export function toolError(message: string): never {
18
+ throw new ToolError(message);
19
+ }
package/src/tool-gate.ts CHANGED
@@ -1,31 +1,40 @@
1
- /** Why: Preserve the established compatibility and safety behavior (C7, D76, D77, D79, WS-D6). */
1
+ /**
2
+ * Worker execution enforcement (C7, D76/D77/D82) — pure. RuntimeRoleManifest is THE runtime manifest
3
+ * shape; the file format lives in role-manifest.ts and the composed/persisted shapes derive from it
4
+ * (manifest-compose.ts, role-state.ts).
5
+ */
2
6
  import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
3
7
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
8
  export interface RuntimeRoleManifest {
6
9
  role: string;
7
10
  version?: string;
8
11
  tools: string[];
9
12
  permissions: Record<string, PermissionAction>;
10
- /** Why: Preserve the established compatibility and safety behavior (D82). */
13
+ /** Stance for tools outside `tools`; 'allow' keeps user-installed extensions visible (D82). */
11
14
  unknownTools?: UnknownToolStance;
12
15
  services?: {
13
16
  todos?: {
14
17
  mode?: 'serial' | 'parallel';
15
18
  };
16
19
  };
20
+ /** P0 per-role guidelines (RFC §4.6): injected as a `pier-role` prompt section each turn. */
21
+ guidelines?: string[];
17
22
  }
18
23
 
19
- /** Why: Preserve the established compatibility and safety behavior. */
24
+ /** Parse the env-carried manifest; malformed input is null (fail-open: no gate) rather than a crash. */
20
25
  export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
21
26
  if (!envValue) return null;
22
27
  try {
23
28
  const m = JSON.parse(envValue) as RuntimeRoleManifest;
24
29
  if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
25
- // Why: Preserve the established compatibility and safety behavior (D82).
26
30
  if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
27
31
  m.unknownTools = 'deny';
28
32
  }
33
+ if (m.guidelines !== undefined) {
34
+ m.guidelines = Array.isArray(m.guidelines)
35
+ ? m.guidelines.filter((g) => typeof g === 'string' && g.trim() !== '')
36
+ : undefined;
37
+ }
29
38
  return m;
30
39
  } catch {
31
40
  return null;
@@ -43,7 +52,6 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
43
52
  const perm = manifest.permissions[toolName] ?? manifest.permissions['*'] ?? 'allow';
44
53
  const known = manifest.tools.includes(toolName);
45
54
  const stance = manifest.unknownTools ?? 'deny';
46
- // Why: Preserve the established compatibility and safety behavior (D82).
47
55
  if (perm === 'deny' || (!known && stance === 'deny')) {
48
56
  return {
49
57
  kind: 'deny',
@@ -59,27 +67,36 @@ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | n
59
67
  return { kind: 'allow' };
60
68
  }
61
69
 
62
- /** Why: Preserve the established compatibility and safety behavior (D77, D82). */
63
- export function planActiveTools(
64
- manifestTools: string[],
65
- currentActive: string[],
70
+ /**
71
+ * 'allow' keeps every tool but the explicit deny rules (D82 user-installed-extension axis); 'deny'
72
+ * intersects with the manifest. Shared by planActiveTools (visibility) and role-state's switch
73
+ * planning (all-tools universe).
74
+ */
75
+ export function filterToolsByStance(
76
+ manifestTools: readonly string[],
77
+ universe: readonly string[],
66
78
  opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
67
- ): { next: string[]; changed: boolean } | null {
68
- if (manifestTools.length === 0 || currentActive.length === 0) return null;
79
+ ): string[] {
69
80
  if ((opts?.unknownTools ?? 'deny') === 'allow') {
70
81
  const denied = new Set(
71
82
  Object.entries(opts?.permissions ?? {})
72
- .filter(([, v]) => v === 'deny')
73
- .map(([k]) => k),
83
+ .filter(([, action]) => action === 'deny')
84
+ .map(([name]) => name),
74
85
  );
75
- const next = currentActive.filter((t) => !denied.has(t));
76
- if (next.length === 0) return null;
77
- return { next, changed: next.length !== currentActive.length };
86
+ return universe.filter((name) => !denied.has(name));
78
87
  }
79
88
  const wanted = new Set(manifestTools);
80
- const next = currentActive.filter((t) => wanted.has(t));
81
- if (next.length === 0) return null;
82
- const changed = next.length !== currentActive.length;
83
- return { next, changed };
89
+ return universe.filter((name) => wanted.has(name));
84
90
  }
85
91
 
92
+ /** Null means "keep the current active set": an empty result must never clear every tool (D77). */
93
+ export function planActiveTools(
94
+ manifestTools: readonly string[],
95
+ currentActive: readonly string[],
96
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
97
+ ): { next: string[]; changed: boolean } | null {
98
+ if (manifestTools.length === 0 || currentActive.length === 0) return null;
99
+ const next = filterToolsByStance(manifestTools, currentActive, opts);
100
+ if (next.length === 0) return null;
101
+ return { next, changed: next.length !== currentActive.length };
102
+ }
package/src/vocab.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  /**
2
- * pi-herdr bridge vocabulary (M22: title is the board).
3
- *
4
- * DESIGN.md §4.2: no new transport. Wire = herdr NDJSON; todo authority = pi
5
- * session JSONL; display = pane.report_metadata title / state_labels.
2
+ * pi-herdr bridge vocabulary. DESIGN.md §4.2: no new transport — wire = herdr NDJSON, todo authority
3
+ * = pi session JSONL, display = pane.report_metadata title / state_labels.
6
4
  */
7
5
 
8
- /** Todo five-state (D34: three-state + blocked/abandoned, aligned with OMP). */
6
+ /** Five states (D34): three-state + blocked/abandoned, aligned with OMP. */
9
7
  export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'abandoned';
10
8
 
11
9
  export const TODO_STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'abandoned'];
12
10
 
13
- /** Todo item: content + status; optional blocker (blocked only) and phase (D43). No stable id (last-write-wins). */
11
+ /** Todo item: content + status; blocker only when blocked; optional phase groups (D43). No id (last-write-wins). */
14
12
  export interface TodoItem {
15
13
  content: string;
16
14
  status: TodoStatus;
17
- /** Non-empty string only when status==='blocked' (D34). */
18
15
  blocker?: string;
19
16
  /** Optional group name (D43); non-empty, ≤30 chars. */
20
17
  phase?: string;
@@ -27,55 +24,88 @@ export interface TodoSnapshot {
27
24
  items: TodoItem[];
28
25
  }
29
26
 
30
- /** Snapshot counts (confirmation copy and title projection). */
31
27
  export interface TodoCounts {
32
28
  pending: number;
33
29
  inProgress: number;
34
30
  completed: number;
35
- /** D91: blocked as its own column (title quartet ▶○■✓). */
31
+ /** D91: blocked is its own column (title quartet ▶○■✓). */
36
32
  blocked: number;
37
33
  }
38
34
 
39
35
  /** Source name for pane.report-agent. */
40
36
  export const REPORT_AGENT_SOURCE = 'pi-herdr';
41
37
 
42
- /** First-upgrade key used to clear the old 16-key chunked tokens (M13b leftover). */
38
+ /** First-upgrade key used to clear the old 16-key chunked tokens (M13b). */
43
39
  export const PI_HERDR_META_KEY = 'pi-herdr';
44
40
 
45
- /** Model-facing todo tool name (DSH-compatible). */
46
41
  export const TODO_TOOL_NAME = 'todo_write';
47
42
 
48
43
  /** Snapshot key in tool-result details (persisted in session JSONL). */
49
44
  export const TODO_DETAILS_KEY = 'pi-herdr.todo';
50
45
 
51
46
  /** Confirmation copy (byte-aligned with DSH so model vocab transfers). */
52
- export function formatTodoConfirmation(items: TodoItem[]): string {
47
+ export function formatTodoConfirmation(items: readonly TodoItem[]): string {
53
48
  const c = countTodos(items);
54
- return `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
49
+ const base = `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
50
+ // Append blocked only when non-zero — the model must not read "nothing pending" while items sit
51
+ // blocked on a human. Zero keeps the DSH-aligned wording intact.
52
+ return c.blocked > 0 ? `${base.slice(0, -1)}, ${c.blocked} blocked.` : base;
55
53
  }
56
54
 
57
- export function countTodos(items: TodoItem[]): TodoCounts {
58
- let pending = 0;
59
- let inProgress = 0;
60
- let completed = 0;
61
- let blocked = 0;
55
+ /** Every status the todo model knows; anything else in a snapshot is corrupt data (D34). */
56
+ const STATUS_LOOKUP: Readonly<Record<TodoStatus, true>> = {
57
+ pending: true,
58
+ in_progress: true,
59
+ completed: true,
60
+ blocked: true,
61
+ abandoned: true,
62
+ };
63
+
64
+ /**
65
+ * Corrupt snapshots carry arbitrary JSON as `status` (`extractSnapshotFromDetails` does not validate),
66
+ * so every table lookup keyed by status must pass through this first — `TODO_MARKS['constructor']`
67
+ * would otherwise render a function, and `groups['constructor'].push` would throw.
68
+ */
69
+ export const isTodoStatus = (value: unknown): value is TodoStatus =>
70
+ typeof value === 'string' && Object.hasOwn(STATUS_LOOKUP, value);
71
+
72
+ const COUNTED_STATUSES = {
73
+ pending: 'pending',
74
+ in_progress: 'inProgress',
75
+ completed: 'completed',
76
+ blocked: 'blocked',
77
+ } as const satisfies Partial<Record<TodoStatus, keyof TodoCounts>>;
78
+
79
+ /** The four states that occupy a count column (`abandoned` is terminal and deliberately uncounted). */
80
+ const isCountedStatus = (status: TodoStatus): status is keyof typeof COUNTED_STATUSES =>
81
+ Object.hasOwn(COUNTED_STATUSES, status);
82
+
83
+ export function countTodos(items: readonly TodoItem[]): TodoCounts {
84
+ const counts: TodoCounts = { pending: 0, inProgress: 0, completed: 0, blocked: 0 };
62
85
  for (const it of items) {
63
- if (it.status === 'pending') pending++;
64
- else if (it.status === 'in_progress') inProgress++;
65
- else if (it.status === 'completed') completed++;
66
- else if (it.status === 'blocked') blocked++;
67
- // abandoned is omitted (D34); blocked is its own column (D91)
86
+ const status = it?.status;
87
+ if (isTodoStatus(status) && isCountedStatus(status)) counts[COUNTED_STATUSES[status]] += 1;
68
88
  }
69
- return { pending, inProgress, completed, blocked };
89
+ return counts;
70
90
  }
71
91
 
92
+ /** Why a settlement carried no closing text (p24-class mis-attribution vs a truly silent worker). */
93
+ export type SettlementNullReason = 'silent' | 'attribution-suspect' | 'extraction-failed';
94
+
95
+ const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
96
+ 'attribution-suspect': 'Its closing message could NOT be read — the session attribution is suspect (a wrong transcript may have been read). Check `subagent action output` / the ledger sessionFile before assuming it produced nothing; its work may be complete.',
97
+ 'extraction-failed': 'It appears to have ended with a final report, but the closing text could not be extracted — read it from the session transcript.',
98
+ silent: 'It left no closing message.',
99
+ };
100
+
72
101
  /** Subagent settlement notice (aligned with DSH). */
73
102
  export function formatSettlementNotice(
74
103
  agentId: string,
75
104
  closingMessage: string | null,
105
+ nullReason: SettlementNullReason = 'silent',
76
106
  ): string {
77
107
  const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
78
108
  return closingMessage
79
109
  ? `${head} Its closing message: ${closingMessage}`
80
- : `${head} It left no closing message.`;
110
+ : `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
81
111
  }