pi-pier 0.3.0 → 0.4.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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/lock-core.ts CHANGED
@@ -1,34 +1,31 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D3, M18, S2). */
1
+ /**
2
+ * M18 file-level write locks (S2: soft veto by default, `PI_HERDR_WRITE_LOCK=1` makes it hard).
3
+ *
4
+ * A lock is a *beacon*, not a mutex: the pane editing a file reports a token
5
+ * (`lock-<hash>` → `paneId|path`, see herdr-client.reportLockTokens) and every other pane reads
6
+ * the token table to warn or block. Tokens must match `^[A-Za-z0-9_-]{1,32}$` with ≤16 keys per
7
+ * report, hence the hashed key and the path inside the value.
8
+ */
2
9
  import { resolve, sep } from 'node:path';
3
10
 
4
- /** Why: Preserve the established compatibility and safety behavior. */
5
11
  export const WRITE_LOCK_ENV = 'PI_HERDR_WRITE_LOCK';
6
- /** Why: Preserve the established compatibility and safety behavior. */
7
12
  export const WRITE_TOOLS = ['write', 'edit'] as const;
8
- /** Why: Preserve the established compatibility and safety behavior. */
9
13
  export const LOCK_TOKEN_PREFIX = 'lock-';
10
- /** Why: Preserve the established compatibility and safety behavior. */
11
14
  export const LOCK_BATCH_LIMIT = 16;
12
- /** Why: Preserve the established compatibility and safety behavior. */
13
15
  export const LOCK_TTL_MS = 60 * 60 * 1000;
14
16
 
15
- /** Why: Preserve the established compatibility and safety behavior. */
16
17
  export interface LockAgentView {
17
18
  paneId: string;
18
19
  tokens: Record<string, string | null>;
19
20
  }
20
21
 
21
- /** Why: Preserve the established compatibility and safety behavior. */
22
-
22
+ /** Case-folded, separator-unified absolute path: the key every lock lookup agrees on. */
23
23
  export function normalizeLockPath(p: string, cwd: string): string {
24
24
  const abs = resolve(cwd, p);
25
25
  const unified = sep === '\\' ? abs.replace(/\\/g, '/') : abs;
26
26
  return unified.toLowerCase().replace(/\/+$/, '');
27
27
  }
28
28
 
29
- /** Why: Preserve the established compatibility and safety behavior. */
30
-
31
- /** Why: Preserve the established compatibility and safety behavior. */
32
29
  export function fnv1a64(s: string): string {
33
30
  let h = 0xcbf29ce484222325n;
34
31
  for (let i = 0; i < s.length; i++) {
@@ -52,13 +49,10 @@ export function parseLockTokenValue(v: string): { holderPaneId: string; path: st
52
49
  return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
53
50
  }
54
51
 
55
- /** Why: Preserve the established compatibility and safety behavior. */
56
52
  export function isLockTokenKey(key: string): boolean {
57
53
  return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
58
54
  }
59
55
 
60
- /** Why: Preserve the established compatibility and safety behavior. */
61
-
62
56
  export function writePathsOfTool(toolName: string, input: unknown): string[] {
63
57
  if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
64
58
  const p = (input as { path?: unknown } | null | undefined)?.path;
@@ -66,22 +60,20 @@ export function writePathsOfTool(toolName: string, input: unknown): string[] {
66
60
  }
67
61
 
68
62
  /**
69
- * B7: write-locks only see the write/edit tools, so a `bash` command that redirects into a file
70
- * bypasses the beacon entirely (`> file`, `>> file`, `tee file`, `sed -i`, `truncate -s 0`).
71
- * We do not try to parse shell: this extracts the *obvious* redirect/tee targets so the lock layer
72
- * can raise a soft warning, and the limitation stays documented rather than implied.
73
- *
74
- * Pure and conservative: unknown syntax yields no paths, and a failure here must never block.
63
+ * B7: write-locks only see the write/edit tools, so a `bash` command that redirects into a file bypasses
64
+ * the beacon entirely (`> file`, `>> file`, `tee file`, `sed -i`, `truncate -s 0`). We do not try to parse
65
+ * shell: this extracts the *obvious* targets so the lock layer can warn (never block) — a false negative is
66
+ * cheap, a false block is not.
75
67
  */
76
68
  export function bashWriteTargets(command: unknown): string[] {
77
69
  if (typeof command !== 'string' || command.trim() === '') return [];
78
70
  const found: string[] = [];
79
- // Quoted or bare targets; stops at shell metacharacters. Deliberately ignores process substitution.
71
+ // Quoted or bare targets; stops at shell metacharacters, ignores process substitution.
80
72
  const target = String.raw`(?:'([^']+)'|"([^"]+)"|([^\s;|&<>()]+))`;
81
73
  const patterns = [
82
- new RegExp(String.raw`(?:^|[^0-9>])>>?\s*` + target, 'g'), // > file, >> file
83
- new RegExp(String.raw`\btee\s+(?:-a\s+)?` + target, 'g'), // tee [-a] file
84
- new RegExp(String.raw`\btruncate\s+-s\s*0\s+` + target, 'g'), // truncate -s 0 file
74
+ new RegExp(String.raw`(?:^|[^0-9>])>>?\s*` + target, 'g'), // > file, >> file
75
+ new RegExp(String.raw`\btee\s+(?:-a\s+)?` + target, 'g'), // tee [-a] file
76
+ new RegExp(String.raw`\btruncate\s+-s\s*0\s+` + target, 'g'), // truncate -s 0 file
85
77
  ];
86
78
  for (const re of patterns) {
87
79
  for (const m of command.matchAll(re)) {
@@ -90,8 +82,8 @@ export function bashWriteTargets(command: unknown): string[] {
90
82
  if (isObviousPath(path, quoted)) found.push(path);
91
83
  }
92
84
  }
93
- // `sed -i '' 's/a/b/' file`: the first argument is the (possibly empty) backup suffix and the second
94
- // the expression, so take the LAST argument as the file rather than guessing the argument order.
85
+ // `sed -i '' 's/a/b/' file`: the first argument is the (possibly empty) backup suffix and the
86
+ // second the expression, so the LAST argument is the file.
95
87
  for (const m of command.matchAll(/\bsed\s+-i[^\s]*\s+([^|;&\n]+)/g)) {
96
88
  const args = splitShellWords(m[1] ?? '');
97
89
  for (let i = args.length - 1; i >= 0; i -= 1) {
@@ -114,23 +106,18 @@ function isObviousPath(path: string, quoted = false): boolean {
114
106
  /** Whitespace split that keeps quoted segments together and strips their quotes. */
115
107
  function splitShellWords(input: string): string[] {
116
108
  const out: string[] = [];
117
- const re = /'([^']*)'|"([^"]*)"|(\S+)/g;
118
- for (const m of input.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
109
+ for (const m of input.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
119
110
  return out.filter((s) => s !== '');
120
111
  }
121
112
 
122
113
  /** Soft hint shown after a bash call that wrote into a path another pane has locked. */
123
- export function formatBashLockHint(normPath: string, holders: readonly string[]): string {
114
+ function formatBashLockHint(normPath: string, holders: readonly string[]): string {
124
115
  return `Note: this bash command wrote to ${normPath}, which ${holdersLabel(holders)} is editing. `
125
116
  + `Write-locks only cover the write/edit tools, so this write was not blocked: re-read the file before your next edit.`;
126
117
  }
127
118
 
128
- /** Why: Preserve the established compatibility and safety behavior. */
129
-
130
- /**
131
- * Every *other* pane currently holding a beacon for this path (deduped, in list order).
132
- * A lock is a beacon rather than a mutex, so several panes can hold the same path.
133
- */
119
+ /** Every *other* pane holding a beacon for this path (deduped, in list order — several panes can hold
120
+ * the same path, so callers get the whole list). */
134
121
  export function findLockHolders(
135
122
  agents: readonly LockAgentView[],
136
123
  ownPaneId: string,
@@ -149,24 +136,17 @@ export function findLockHolders(
149
136
  return holders;
150
137
  }
151
138
 
152
- export function findLockConflict(
153
- agents: readonly LockAgentView[],
154
- ownPaneId: string,
155
- normPath: string,
156
- ): { holderPaneId: string } | null {
157
- const [first] = findLockHolders(agents, ownPaneId, normPath);
158
- return first === undefined ? null : { holderPaneId: first };
159
- }
160
-
161
- /** Where to look for the full holder table: humans use the slash command, agents can read herdr's tokens. */
162
- export const LOCK_HOLDERS_HINT =
139
+ /** Where to look for the full holder table: humans use the slash command, agents read herdr tokens. */
140
+ const LOCK_HOLDERS_HINT =
163
141
  'holders: /locks (human view in this pane) · herdr agent list → tokens (agent-readable)';
164
142
 
165
143
  function holdersLabel(holders: readonly string[]): string {
166
144
  return holders.length === 1 ? `pane ${holders[0]}` : `panes ${holders.join(', ')}`;
167
145
  }
168
146
 
169
- /** Why: Preserve the established compatibility and safety behavior. */
147
+ function formatConflictWarning(normPath: string, holders: readonly string[]): string {
148
+ return `⚠️ write conflict: ${normPath} is locked by ${holdersLabel(holders)} (edited in another pane that has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes. ${LOCK_HOLDERS_HINT}`;
149
+ }
170
150
 
171
151
  export type WriteGuardPlan =
172
152
  | { kind: 'skip' }
@@ -182,8 +162,6 @@ export function planWriteGuard(opts: {
182
162
  cwd: string;
183
163
  hard: boolean;
184
164
  }): WriteGuardPlan {
185
- // B7: bash redirects are invisible to the write tools, so warn (never block) when their obvious
186
- // targets collide with a lock beacon held by another pane.
187
165
  const isBash = opts.toolName === 'bash';
188
166
  const raw = isBash
189
167
  ? bashWriteTargets((opts.input as { command?: unknown } | null | undefined)?.command)
@@ -211,8 +189,6 @@ export function planWriteGuard(opts: {
211
189
  return { kind: 'pass', paths };
212
190
  }
213
191
 
214
- /** Why: Preserve the established compatibility and safety behavior. */
215
-
216
192
  export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
217
193
  const out: Record<string, string> = {};
218
194
  for (const p of normPaths) out[lockTokenKey(p)] = lockTokenValue(p, paneId);
@@ -224,7 +200,3 @@ export function releaseTokensFor(normPaths: readonly string[]): Record<string, n
224
200
  for (const p of normPaths) out[lockTokenKey(p)] = null;
225
201
  return out;
226
202
  }
227
-
228
- export function formatConflictWarning(normPath: string, holders: readonly string[]): string {
229
- return `⚠️ write conflict: ${normPath} is locked by ${holdersLabel(holders)} (edited in another pane that has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes. ${LOCK_HOLDERS_HINT}`;
230
- }
@@ -1,21 +1,21 @@
1
- /** Why: Preserve the established compatibility and safety behavior (C7). */
1
+ /**
2
+ * Manifest composition (C7 v2 three-state permissions): role baseline ∪ model suggestion,
3
+ * minus deny rules, with the D82 unknown-tools stance carried through.
4
+ */
2
5
  import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
6
+ import type { RuntimeRoleManifest } from './tool-gate.ts';
3
7
  import { loadRoleConfig } from './role-loader.ts';
4
8
 
5
9
  export interface ManifestSources {
6
10
  roleBaseline: readonly string[];
7
11
  modelSuggested: readonly string[];
8
12
  rulePermissions: Record<string, PermissionAction>;
9
- /** Why: Preserve the established compatibility and safety behavior (D82). */
13
+ /** D82: stance for tools outside the composed set. */
10
14
  unknownTools?: UnknownToolStance;
11
15
  }
12
16
 
13
- export interface ComposedManifest {
14
- tools: string[];
15
- permissions: Record<string, PermissionAction>;
16
- /** Why: Preserve the established compatibility and safety behavior (D82). */
17
- unknownTools: UnknownToolStance;
18
- }
17
+ /** What composeManifest produces: the runtime manifest minus the identity/section fields. */
18
+ export type ComposedManifest = Omit<RuntimeRoleManifest, 'role' | 'version' | 'services' | 'guidelines'>;
19
19
 
20
20
  export class ManifestError extends Error {
21
21
  readonly code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG';
@@ -26,6 +26,11 @@ export class ManifestError extends Error {
26
26
  }
27
27
  }
28
28
 
29
+ export interface ComposedForRole {
30
+ role: RoleManifest;
31
+ manifest: ComposedManifest;
32
+ }
33
+
29
34
  export function composeManifest(sources: ManifestSources): ComposedManifest {
30
35
  const baseline = sources.roleBaseline.filter((t): t is string => typeof t === 'string' && t.trim() !== '');
31
36
  if (baseline.length === 0) {
@@ -40,21 +45,18 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
40
45
  ]);
41
46
  const rules = sources.rulePermissions ?? {};
42
47
  const defaultAction: PermissionAction = rules['*'] ?? 'allow';
43
- const stance: UnknownToolStance = sources.unknownTools ?? 'deny';
44
48
 
45
49
  const tools: string[] = [];
46
50
  const permissions: Record<string, PermissionAction> = {};
47
- // Why: Preserve the established compatibility and safety behavior (D82).
48
- // Why: Preserve the established compatibility and safety behavior.
49
- for (const [k, v] of Object.entries(rules)) {
50
- if (k === '*') continue; // Why: Preserve the established compatibility and safety behavior.
51
- permissions[k] = v;
51
+ // Rule keys outside the candidate set stay in permissions: an allow-stance role keeps its
52
+ // deny rules for tools the model never suggested (D82 excluded families).
53
+ for (const [key, action] of Object.entries(rules)) {
54
+ if (key !== '*') permissions[key] = action;
52
55
  }
53
56
  for (const tool of candidates) {
54
57
  const action = rules[tool] ?? defaultAction;
55
58
  permissions[tool] = action;
56
- if (action === 'deny') continue; // Why: Preserve the established compatibility and safety behavior.
57
- tools.push(tool);
59
+ if (action !== 'deny') tools.push(tool);
58
60
  }
59
61
 
60
62
  if (tools.length === 0) {
@@ -66,16 +68,15 @@ export function composeManifest(sources: ManifestSources): ComposedManifest {
66
68
  );
67
69
  }
68
70
 
69
- tools.sort(); // Why: Preserve the established compatibility and safety behavior.
70
- return { tools, permissions, unknownTools: stance };
71
+ tools.sort();
72
+ return { tools, permissions, unknownTools: sources.unknownTools ?? 'deny' };
71
73
  }
72
74
 
73
- /** Why: Preserve the established compatibility and safety behavior (WS-D8). */
74
75
  export function composeForRole(
75
76
  roleName: string,
76
77
  modelSuggested: readonly string[],
77
78
  opts?: { loadRole?: typeof loadRoleConfig; loadRoleOpts?: Parameters<typeof loadRoleConfig>[1] },
78
- ): { role: RoleManifest; manifest: ComposedManifest } {
79
+ ): ComposedForRole {
79
80
  const load = opts?.loadRole ?? loadRoleConfig;
80
81
  const role = load(roleName, opts?.loadRoleOpts);
81
82
  const manifest = composeManifest({
@@ -86,3 +87,17 @@ export function composeForRole(
86
87
  });
87
88
  return { role, manifest };
88
89
  }
90
+
91
+ /** Project a composed profile onto the runtime shape — the one place role-file fields become runtime fields. */
92
+ export function toRuntimeManifest(composed: ComposedForRole): RuntimeRoleManifest {
93
+ const { role, manifest } = composed;
94
+ return {
95
+ role: role.role,
96
+ version: role.version,
97
+ tools: manifest.tools,
98
+ permissions: manifest.permissions,
99
+ unknownTools: manifest.unknownTools,
100
+ services: role.services ?? {},
101
+ ...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
102
+ };
103
+ }
@@ -1,23 +1,18 @@
1
1
  /**
2
- * D101 ObservationPack Core.
3
- *
4
- * Pure algorithmic functions for large observation detection, deterministic ID derivation,
5
- * complete-line excerpt generation, chunk slicing with UTF-8 safety, and rolling prefix cache
6
- * economics.
7
- *
8
- * No I/O in this core; storage I/O lives in efficiency-store.ts.
2
+ * ObservationPack cores: large-observation detection, deterministic ids, complete-line excerpts,
3
+ * UTF-8-safe chunk slicing, rolling prefix cache economics. Storage I/O is in efficiency-store.ts.
9
4
  */
10
5
  import { createHash } from 'node:crypto';
11
6
  import { TOKEN_ACCOUNT_CACHE_RATIO } from './compact-economics-core.ts';
12
7
  import { FAILURE_SIGNAL } from './reducer-core.ts';
13
8
 
14
- export const DEFAULT_THRESHOLD_BYTES = 10 * 1024; // 10KB
15
- export const DEFAULT_FULL_SENDS = 2;
16
- export const DEFAULT_EXCERPT_BYTES = 1024;
17
- export const CHARS_PER_TOKEN = 4;
18
- export const OBSERVATION_ID_RE = /^obs_[a-f0-9]{24}$/;
9
+ // Fallbacks used only when a caller omits a field; DEFAULT_EFFICIENCY_CONFIG.observationPack is the
10
+ // canonical set (thresholdBytes lives there alone — every pack decision goes through resolved config).
11
+ const DEFAULT_FULL_SENDS = 2;
12
+ const DEFAULT_EXCERPT_BYTES = 1024;
13
+ const CHARS_PER_TOKEN = 4;
19
14
 
20
- /** Marker from Evidence-Preserving Reducer to avoid double packing. */
15
+ /** Marker from the evidence-preserving reducer, used to avoid double packing. */
21
16
  export const REDUCER_RECEIPT_PREFIX = 'sol_pi_evidence_receipt_v1';
22
17
 
23
18
  export function sha256Hex(val: string | Buffer): string {
@@ -38,60 +33,41 @@ export function countLines(text: string): number {
38
33
  }
39
34
 
40
35
  export function isObservationId(id: string): boolean {
41
- return OBSERVATION_ID_RE.test(id);
36
+ return /^obs_[a-f0-9]{24}$/.test(id);
42
37
  }
43
38
 
44
39
  export function deriveObservationId(toolName: string, toolCallId: string, contentHash: string): string {
45
- const combined = `${toolName}\0${toolCallId}\0${contentHash}`;
46
- return `obs_${sha256Hex(combined).slice(0, 24)}`;
40
+ return `obs_${sha256Hex(`${toolName}\0${toolCallId}\0${contentHash}`).slice(0, 24)}`;
47
41
  }
48
42
 
43
+ /** A receipt must be a line of its own — a mere mention inside a log body is not a receipt. */
49
44
  export function containsReducerReceipt(text: string): boolean {
50
45
  if (!text.includes(REDUCER_RECEIPT_PREFIX)) return false;
51
46
  return text.split('\n').some((line) => line.trim() === REDUCER_RECEIPT_PREFIX);
52
47
  }
53
48
 
49
+ /** Whole lines only, from the head or the tail; the scanned window never exceeds budgetBytes * 4 chars. */
54
50
  export function completeLineExcerpt(text: string, budgetBytes: number, fromEnd: boolean): string {
55
51
  if (budgetBytes <= 0 || text.length === 0) return '';
56
52
 
57
- if (!fromEnd) {
58
- // Head excerpt: candidate never exceeds budgetBytes * 4 chars
59
- const candidate = text.slice(0, Math.min(text.length, budgetBytes * 4));
60
- let lastEnd = 0;
61
- let totalBytes = 0;
62
- let searchIndex = 0;
63
- while (searchIndex < candidate.length) {
64
- const nextNewline = candidate.indexOf('\n', searchIndex);
65
- if (nextNewline === -1) break;
66
- const lineEnd = nextNewline + 1;
67
- const line = candidate.slice(lastEnd, lineEnd);
68
- const lineBytes = Buffer.byteLength(line, 'utf8');
69
- if (totalBytes + lineBytes > budgetBytes) break;
70
- totalBytes += lineBytes;
71
- lastEnd = lineEnd;
72
- searchIndex = lineEnd;
73
- }
74
- return candidate.slice(0, lastEnd);
75
- } else {
76
- // Tail excerpt: candidate never exceeds budgetBytes * 4 chars from end
77
- const startOffset = Math.max(0, text.length - budgetBytes * 4);
78
- const candidate = text.slice(startOffset);
79
- const lines = candidate.split(/(?<=\n)/);
80
- const selected: string[] = [];
81
- let selectedBytes = 0;
82
- for (let i = lines.length - 1; i >= 0; i--) {
83
- const line = lines[i]!;
84
- // If startOffset > 0, the first line of candidate might be partial, skip unless it follows a newline
85
- if (i === 0 && startOffset > 0 && text[startOffset - 1] !== '\n') {
86
- break;
87
- }
88
- const lineBytes = Buffer.byteLength(line, 'utf8');
89
- if (selectedBytes + lineBytes > budgetBytes) break;
90
- selected.unshift(line);
91
- selectedBytes += lineBytes;
92
- }
93
- return selected.join('');
53
+ const window = budgetBytes * 4;
54
+ const start = fromEnd ? Math.max(0, text.length - window) : 0;
55
+ const end = fromEnd ? text.length : Math.min(text.length, window);
56
+ const lines = text.slice(start, end).split(/(?<=\n)/);
57
+ const selected: string[] = [];
58
+ let selectedBytes = 0;
59
+ for (let i = fromEnd ? lines.length - 1 : 0; i >= 0 && i < lines.length; i += fromEnd ? -1 : 1) {
60
+ const line = lines[i]!;
61
+ // The head keeps terminated lines only; the tail also keeps the log's final unterminated line.
62
+ if (!fromEnd && !line.endsWith('\n')) break;
63
+ if (fromEnd && i === 0 && start > 0 && text[start - 1] !== '\n') break;
64
+ const lineBytes = Buffer.byteLength(line, 'utf8');
65
+ if (selectedBytes + lineBytes > budgetBytes) break;
66
+ selectedBytes += lineBytes;
67
+ if (fromEnd) selected.unshift(line);
68
+ else selected.push(line);
94
69
  }
70
+ return selected.join('');
95
71
  }
96
72
 
97
73
  export interface ObservationPlaceholderInput {
@@ -104,10 +80,8 @@ export interface ObservationPlaceholderInput {
104
80
  fullSends?: number;
105
81
  excerptBudget?: number;
106
82
  /**
107
- * P0-3 (RFC docs/rfc-jev-integration.md §3): a selected middle window that
108
- * replaces whichever head/tail half carries fewer failure-signal lines.
109
- * Absent (jev off / low confidence / no signal lines) -> byte-identical
110
- * legacy head+tail layout.
83
+ * P0-3 (RFC docs/rfc-jev-integration.md §3): a selected middle window replacing whichever
84
+ * head/tail half carries fewer failure-signal lines. Absent → byte-identical legacy layout.
111
85
  */
112
86
  middle?: { text: string; label: string };
113
87
  }
@@ -133,8 +107,8 @@ export function formatObservationPlaceholder(input: ObservationPlaceholderInput)
133
107
  let tailLabel = `[middle omitted; last complete lines, up to ${tailBudget} bytes]`;
134
108
 
135
109
  if (input.middle) {
136
- // Fewer signal lines loses its slot; ties replace head because the tail
137
- // half usually carries the log's final summary.
110
+ // Fewer signal lines loses its slot; ties replace head because the tail half usually carries
111
+ // the log's final summary.
138
112
  if (signalLineCount(tail) < signalLineCount(head)) {
139
113
  tail = completeLineExcerpt(input.middle.text, tailBudget, false);
140
114
  tailLabel = `[middle omitted; selected excerpt — ${input.middle.label}, up to ${tailBudget} bytes]`;
@@ -168,10 +142,7 @@ export interface RecallSliceResult {
168
142
  eof: boolean;
169
143
  }
170
144
 
171
- /**
172
- * Trims multi-byte UTF-8 sequence from buffer boundary if cut mid-character.
173
- */
174
- export function trimUtf8End(buffer: Buffer, limit: number): number {
145
+ function trimUtf8End(buffer: Buffer, limit: number): number {
175
146
  let end = limit;
176
147
  while (end > 0 && end < buffer.length && ((buffer[end] ?? 0) & 0xc0) === 0x80) {
177
148
  end -= 1;
@@ -179,9 +150,7 @@ export function trimUtf8End(buffer: Buffer, limit: number): number {
179
150
  return end;
180
151
  }
181
152
 
182
- /**
183
- * Slices a memory buffer safely at byte offsets without cutting UTF-8 characters.
184
- */
153
+ /** Byte offsets, never cutting a UTF-8 character. */
185
154
  export function sliceBufferChunk(
186
155
  buf: Buffer,
187
156
  offset: number,
@@ -191,40 +160,30 @@ export function sliceBufferChunk(
191
160
  return { text: '', bytes: 0, lines: 0, nextOffset: buf.length, eof: true };
192
161
  }
193
162
 
194
- const available = buf.length - offset;
195
- let end = Math.min(available, limits.maxBytes);
163
+ let end = Math.min(buf.length - offset, limits.maxBytes);
196
164
  let newlineCount = 0;
197
-
198
165
  for (let i = 0; i < end; i++) {
199
- if (buf[offset + i] === 0x0a) {
200
- newlineCount++;
201
- if (newlineCount === limits.maxLines) {
202
- end = i + 1;
203
- break;
204
- }
166
+ if (buf[offset + i] === 0x0a && ++newlineCount === limits.maxLines) {
167
+ end = i + 1;
168
+ break;
205
169
  }
206
170
  }
207
171
 
208
- const trimmedEnd = trimUtf8End(buf.subarray(offset), end);
209
- const chunkBuf = buf.subarray(offset, offset + trimmedEnd);
172
+ const chunkBuf = buf.subarray(offset, offset + trimUtf8End(buf.subarray(offset), end));
210
173
  const text = chunkBuf.toString('utf8');
211
- const bytes = chunkBuf.length;
212
- const lines = countLines(text);
213
- const nextOffset = offset + bytes;
214
- const eof = nextOffset >= buf.length;
215
-
216
- return { text, bytes, lines, nextOffset, eof };
174
+ const nextOffset = offset + chunkBuf.length;
175
+ return {
176
+ text,
177
+ bytes: chunkBuf.length,
178
+ lines: countLines(text),
179
+ nextOffset,
180
+ eof: nextOffset >= buf.length,
181
+ };
217
182
  }
218
183
 
219
184
  /**
220
- * Evaluates whether replacing an earlier message with a placeholder is profitable
221
- * under rolling prefix cache models.
222
- *
223
- * `cacheWriteReadRatio` must already be resolved by the caller via
224
- * `resolveCacheRatioFromCost` (same value OCC uses). It used to coerce
225
- * `'auto'|null` to a hardcoded 12.5 here, which silently diverged from OCC's
226
- * resolution and made OBS under-pack on implicit-cache models (2026-09-17
227
- * review: 20/72 packs deferred to sendCount 3–175).
185
+ * `cacheWriteReadRatio` must already be resolved by the caller via `resolveCacheRatioFromCost` (the
186
+ * same value OCC uses); a raw `'auto'` would silently diverge from OCC's resolution.
228
187
  */
229
188
  export function shouldPackForCache(opts: {
230
189
  removedTokens: number;
@@ -234,15 +193,8 @@ export function shouldPackForCache(opts: {
234
193
  }): boolean {
235
194
  if (opts.removedTokens <= 0) return false;
236
195
  const ratio = opts.cacheWriteReadRatio ?? TOKEN_ACCOUNT_CACHE_RATIO;
196
+ if (ratio <= 1.0) return true; // No incremental write cost over reads.
237
197
 
238
- if (ratio <= 1.0) {
239
- return true; // No incremental write cost over reads
240
- }
241
-
242
- const incrementalRatio = ratio - 1.0;
243
198
  const remaining = Math.max(1, opts.expectedRemainingRequests);
244
- const benefitTokens = opts.removedTokens * remaining;
245
- const costTokens = opts.tailTokensAfter * incrementalRatio;
246
-
247
- return benefitTokens > costTokens;
199
+ return opts.removedTokens * remaining > opts.tailTokensAfter * (ratio - 1.0);
248
200
  }
package/src/pane-title.ts CHANGED
@@ -1,39 +1,24 @@
1
- /**
2
- * Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68).
3
- *
4
- * The pi session JSONL remains authoritative; this layer only condenses the snapshot into a title and blocked badge.
5
- * herdr truncates title/state_label to 80 characters, so truncate locally to avoid cutting an emoji in half.
6
- */
1
+ /** Projects the pane todo snapshot into a herdr pane header/sidebar (M22, D62/D68); the pi session
2
+ * JSONL stays authoritative and herdr truncates title/state_label at 80 chars, so truncate locally
3
+ * to avoid cutting an emoji in half. */
7
4
  import { countTodos, PI_HERDR_META_KEY, type TodoItem } from './vocab.ts';
8
5
  import { formatAge, isArchived } from './stale-core.ts';
9
6
 
10
7
  /** herdr's character limit for title/state_label (from official documentation and schema measurements). */
11
- export const TITLE_MAX = 80;
12
- /** Empty list: callers send clear_title rather than an empty string. */
13
- export const TITLE_EMPTY = null;
14
- /**
15
- * Valid state_labels keys are only idle|working|blocked|done|unknown
16
- * (herdr socket-api; the early WIRE `todo` value was rejected as invalid_state_label).
17
- */
8
+ const TITLE_MAX = 80;
9
+ /** Valid state_labels keys are only idle|working|blocked|done|unknown (herdr socket-api). */
18
10
  export const BLOCKED_LABEL_KEY = 'blocked';
19
11
 
20
- /**
21
- * D93: custom sidebar token for the todo summary (referenced as `$pi-todo` by herdr `[ui.sidebar.agents]`).
22
- * Keep its value in the same format and source as the pane title (`▶i ○p ■b ✓c (N/M) · activity`).
23
- */
24
- export const SIDEBAR_TODO_TOKEN = 'pi-todo';
12
+ /** D93: custom sidebar token for the todo summary, referenced as `$pi-todo` by herdr
13
+ * `[ui.sidebar.agents]`; its value keeps the pane-title format (`▶i ○p ■b ✓c (N/M) · activity`). */
14
+ const SIDEBAR_TODO_TOKEN = 'pi-todo';
25
15
  /** D95: token marking an ask_user_question wait (the workbench heat scale uses it to distinguish ask from block). */
26
16
  export const SIDEBAR_ASK_TOKEN = 'pi-ask';
27
17
 
28
- /**
29
- * D93: build the pi-todo token patch for report_metadata.
30
- * A title becomes the token value so the sidebar can render `$pi-todo`; no todo becomes an empty string so herdr's
31
- * patch semantics remove the key rather than retaining an old summary.
32
- *
33
- * D96 correction: **do not merge stale**. Stale (16 nulls) is a one-time cleanup sent separately via reportMetadata;
34
- * merging would make 17 entries exceed herdr's tokens maxProperties=16, rejecting the entire request and dropping
35
- * both title and tokens (observed on the user's machine: pane title and sidebar token disappeared; D93 regression root cause).
36
- */
18
+ /** D93: build the pi-todo token patch for report_metadata (no todo = an empty string, because herdr's
19
+ * patch semantics remove empty keys). D96: never merge the stale cleanup in here — stale(16) +
20
+ * pi-todo(1) exceeds herdr's tokens maxProperties=16 and the rejected request would drop both the
21
+ * title and the tokens. */
37
22
  export function sidebarTodoTokens(title: string | null): Record<string, string> {
38
23
  return { [SIDEBAR_TODO_TOKEN]: title ?? '' };
39
24
  }
@@ -45,20 +30,18 @@ function clipTitle(s: string): string {
45
30
  }
46
31
 
47
32
  /**
48
- * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four black-and-white status glyphs).
49
- * activity is the first in_progress item, then fallbackDescription, then only the counts.
50
- * Empty list → null. progressSuffix (M16) is produced by formatProgressSuffix.
51
- *
52
- * Anti-freeze behavior (stale-core D): once all work is complete and the wall clock expires (archived), lower the
53
- * dead list to `✓N done <age>` instead of presenting it as a fully weighted current-state view; report_agent's tool
54
- * badge/activity line explains what is happening, while the todo token reports only the plan's actual state.
33
+ * `▶i ○p ■b ✓c (N/M [~eta]) · <activity>` (D91 unifies the four status glyphs). activity is the
34
+ * first in_progress item, then fallbackDescription, then only the counts. Empty list → null, which
35
+ * callers translate into clear_title; progressSuffix (M16) comes from formatProgressSuffix.
36
+ * Anti-freeze (stale-core D): once all work completed and the wall clock expired (archived), the
37
+ * dead list degrades to `✓N done <age>` instead of presenting itself as a current-state view.
55
38
  */
56
39
  export function formatPaneTitle(
57
40
  items: readonly TodoItem[],
58
41
  fallbackDescription?: string | null,
59
42
  opts?: { progressSuffix?: string | null; lastWriteAt?: number | null; now?: number },
60
43
  ): string | null {
61
- if (items.length === 0) return TITLE_EMPTY;
44
+ if (items.length === 0) return null;
62
45
  const c = countTodos(items);
63
46
  const now = opts?.now ?? Date.now();
64
47
  if (isArchived(items, opts?.lastWriteAt ?? null, now)) {
@@ -82,9 +65,7 @@ export function formatBlockedLabel(items: readonly TodoItem[]): string | null {
82
65
  return clipTitle(text);
83
66
  }
84
67
 
85
- /**
86
- * The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls remain—M13b).
87
- */
68
+ /** The first post-upgrade report clears the old 16-key chunks (herdr merges tokens by key, so omitted nulls would otherwise remain — M13b). */
88
69
  export function staleTokenClearance(): Record<string, null> {
89
70
  const tokens: Record<string, null> = { [PI_HERDR_META_KEY]: null };
90
71
  for (let i = 0; i < STALE_CHUNK_COUNT; i++) {