@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,770 @@
1
+ /**
2
+ * Seeding the bundled defaults into the user's Pi agent dir, so a user can edit/extend them
3
+ * and the supervisor always runs the user's copies (they shadow the builtin by discovery
4
+ * precedence). `force` overwrites existing files — that is the "restore originals" command;
5
+ * non-force keeps user edits (first run / pulling in new defaults).
6
+ *
7
+ * Layout mirrors discovery: personas + agents live together under `<user>/agents` (classified
8
+ * by `persona: true`), teams in `<user>/teams.yaml`, the spine pair flat in `<user>` (bundled
9
+ * under `prompts/`, but that is where `resolveSpine` looks), and flows/contracts/presets in
10
+ * their own `<user>/<kind>` dirs. Persona and agent names are one shared namespace at runtime; a
11
+ * colliding seed is reported and the persona wins the single shared user file. The loader then
12
+ * reports the ambiguity instead of silently splitting the namespaces.
13
+ *
14
+ * Pure over node:fs (no Pi imports), so the copy/skip logic is unit-tested with temp dirs.
15
+ */
16
+
17
+ import { createHash, randomUUID } from "node:crypto";
18
+ import {
19
+ closeSync,
20
+ constants,
21
+ copyFileSync,
22
+ existsSync,
23
+ fstatSync,
24
+ linkSync,
25
+ lstatSync,
26
+ mkdirSync,
27
+ openSync,
28
+ readSync,
29
+ readdirSync,
30
+ renameSync,
31
+ rmSync,
32
+ } from "node:fs";
33
+ import { dirname, join } from "node:path";
34
+
35
+ const SPINE_FILES = ["spine.md", "spine.worker.md"] as const;
36
+ type SpineFile = (typeof SPINE_FILES)[number];
37
+
38
+ /** Exact bytes shipped in v1.8.0 and copied by `/persona seed`. v1.8.1 rewrote both prompts, but
39
+ * `on` prefers the seeded user file, so those pristine copies otherwise shadow the rewrite forever.
40
+ * Detection is deliberately read-only: a single user edit changes the digest and is preserved,
41
+ * while an exact legacy copy can be bypassed by resolution without risking a concurrent edit. */
42
+ const LEGACY_SPINE_SHA256: Readonly<Record<SpineFile, string>> = {
43
+ "spine.md": "36c304f0bf9bf1114342cde8cd638adec004a6a50415e17d23c90c82e2394b7b",
44
+ "spine.worker.md": "33dbe81db24602f208a23957c88e638aff1bad38c4038b7356350c75d898f936",
45
+ };
46
+ const LEGACY_SPINE_SIZE: Readonly<Record<SpineFile, number>> = {
47
+ "spine.md": 2714,
48
+ "spine.worker.md": 1436,
49
+ };
50
+
51
+ export interface SpineLegacySelection {
52
+ supervisor: boolean;
53
+ worker: boolean;
54
+ }
55
+
56
+ export interface SpineLegacyResult {
57
+ /** Exact pristine v1.8.0 user files. Callers may skip these as resolution candidates. */
58
+ legacy: string[];
59
+ warnings: string[];
60
+ }
61
+
62
+ /** The small portion of `fs.Stats` needed to reject links and revalidate an opened file. */
63
+ export interface SpineLegacyStat {
64
+ dev: number;
65
+ ino: number;
66
+ nlink: number;
67
+ size: number;
68
+ isFile(): boolean;
69
+ }
70
+
71
+ /** Per-activation filesystem seam. Production leaves it empty; tests can prove rejected files are
72
+ * never opened/read and that the content read is bounded without replacing process-wide `node:fs`. */
73
+ export interface SpineLegacyIO {
74
+ lstat?: (path: string) => SpineLegacyStat;
75
+ open?: (path: string) => number;
76
+ fstat?: (fd: number) => SpineLegacyStat;
77
+ /** Read at most `maxBytes` from offset zero. */
78
+ read?: (fd: number, maxBytes: number) => Buffer;
79
+ close?: (fd: number) => void;
80
+ }
81
+
82
+ export interface SeedResult {
83
+ /** Absolute paths written. */
84
+ copied: string[];
85
+ /** Absolute paths left untouched (already existed, or a persona owned a colliding name). */
86
+ skipped: string[];
87
+ /** Basenames present in both bundled persona and agent sources; the persona wins seeding. */
88
+ collisions?: string[];
89
+ /** Per-file failures (a destination locked by an editor/AV on Windows, EACCES…). One bad
90
+ * file must not abort the whole seed: the rest still lands and each failure is named. */
91
+ warnings?: string[];
92
+ }
93
+
94
+ /** Exact bytes from earlier releases whose seeded copies are known to be stale. A user file is
95
+ * eligible only when BOTH size and digest match. Keep this explicit allow-list deliberately
96
+ * small: it is a migration policy, not a fuzzy upgrader. */
97
+ export interface SeededDefaultSpec {
98
+ size: number;
99
+ sha256: string;
100
+ /** Bundled agent files that must exist before this parent default may be migrated. */
101
+ requiredAdditions?: readonly string[];
102
+ }
103
+
104
+ /** One seeded asset can have several known-pristine historical byte sequences. */
105
+ export type SeededDefaultEntry = SeededDefaultSpec | readonly SeededDefaultSpec[];
106
+
107
+ const ELITE_REQUIRED_ADDITIONS = ["agents/evidence-verifier.md"] as const;
108
+
109
+ export const LEGACY_SEEDED_DEFAULTS: Readonly<Record<string, SeededDefaultEntry>> = {
110
+ "personas/dev.md": [
111
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
112
+ { size: 5907, sha256: "1d7abbc482a26c452e2588e405bdcb60af536df1f81aa0b709dcff70554915d2" },
113
+ { size: 5977, sha256: "a1b46938316636bc90f525d031490eef3ee46363abe3aa199b267449b8bff06a" },
114
+ // Current pre-Exocom-scope prompt (LF and CRLF checkout variants).
115
+ { size: 3170, sha256: "caf493d2df543303c5c49731747be033ce7169749f2e5d2939aa0ae6432ff0b5" },
116
+ { size: 3221, sha256: "40f6f682d0adbeee8e2221b7ab3525666b64816bf40af981fbdc88d6aef46671" },
117
+ { size: 3985, sha256: "fb02263d97c53d6c10b6089c9c65ae7fad27a77c28434b5c892dbb87dd285e5b" },
118
+ // v1.10.5–v1.12.0
119
+ { size: 5194, sha256: "0899ee3b10971c2b324e9e9c868fcab833ea80a80b83d94a1f0a95147475e9b5" },
120
+ // v1.12.1
121
+ { size: 5195, sha256: "54da40a44a777f8a3b36b6c038248116e19be5a6515707747d070dc3f32ead56" },
122
+ ],
123
+ "personas/planner.md": [
124
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
125
+ { size: 3350, sha256: "78a1f1c88d4d779dca46414029a247e8c48db85b3b7266be9366c6dc23714d87" },
126
+ { size: 3397, sha256: "8055b7440f2cf1e79378196eda675bdeb5d58b8941d450f8901672cf82a9e876" },
127
+ ],
128
+ "personas/researcher.md": [
129
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
130
+ { size: 4441, sha256: "a3819ea76afee2a2f14d50c99198a09d4a4bd858e22ea9e4ceb3d868dcbb78c8" },
131
+ { size: 4498, sha256: "2bd9dcaf47bd4ef3d139be361d260fbf7c9caa523144548593cdfc7ea4a2246c" },
132
+ ],
133
+ "personas/audit.md": [
134
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
135
+ { size: 2110, sha256: "ea893333fc128a760455bb4eaac3ee2d384163f40950fca2c201b41860bd9d8a" },
136
+ { size: 2145, sha256: "04f2167437081d69806a704238411275006df98e6ee902bc0a8cb6eb81b0ef14" },
137
+ ],
138
+ "personas/verify.md": [
139
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
140
+ { size: 1927, sha256: "4c670aba28ce15937d60074ec4ac7d50f6db3cccf137b60d9758627d69e82a0f" },
141
+ { size: 1955, sha256: "9696278c243f83c9e8ee5f128faacebb8a89f85464b2c182f1bb77365a6d5643" },
142
+ ],
143
+ // The MAGI cores gained `purpose:` (their verticalization) in v1.10.6. Agents are seeded COPIES,
144
+ // not a live layer read from the package, so without these an existing install keeps three cores
145
+ // that render as bare names — the label feature would ship inert for everyone who already has them.
146
+ "agents/melchior.md": { size: 1503, sha256: "aa7a69296640f1023e00e2f2285b12cfdee158bb89f7b0b3d8c03a6317de9508" },
147
+ "agents/balthasar.md": { size: 1527, sha256: "27273ed49ea265f528efad4724e6abec6d051fa60d1f4fa678982e7383364981" },
148
+ "agents/casper.md": { size: 1508, sha256: "5074341b7e1ae7e9a44bc71de0405a17ccb404eacfaeb2fcf40f2ac4c23828ea" },
149
+ // Current pristine seed before the compact-digest/reusable-artifact prompt update (LF and CRLF).
150
+ "agents/research.md": [
151
+ { size: 3771, sha256: "6d084d11c84a594ddac0986bcf81aea6e5768b9df18196fedc8715bfd3f1eb16" },
152
+ { size: 3825, sha256: "768899c4c9a5f60a8ff18602d7ee5a696414fd35a8d53e6ae2cc9dfa41d7a88f" },
153
+ ],
154
+ // v1.14.1 pristine seed (LF and CRLF checkout variants).
155
+ "agents/evidence-verifier.md": [
156
+ { size: 1465, sha256: "428be556abbbd530826631ef255cd7effa3484b00b0c7c35877e8b0c9a786aef" },
157
+ { size: 1487, sha256: "5e4b2b41700bf7b669769f40dd5e98b41db0d708e1dec75697d06fd6abeaa2a1" },
158
+ ],
159
+ "personas/elite.md": [
160
+ {
161
+ // v1.0.0
162
+ size: 10489,
163
+ sha256: "a898364924b2f8fd2a60e8bd170650bc851c972f330f51f351be7390986629de",
164
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
165
+ },
166
+ {
167
+ // v1.1.0
168
+ size: 10325,
169
+ sha256: "c3d9f969ad31bb6ecb2fef5ccc9e30fc88bfd8a215a865b91c66496976af78aa",
170
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
171
+ },
172
+ {
173
+ // v1.2.0–v1.3.0
174
+ size: 11162,
175
+ sha256: "df2b01eed543eee7064006611e84c08e9c01498d7d18b37b1cd596bf604801ed",
176
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
177
+ },
178
+ {
179
+ // v1.4.0
180
+ size: 12001,
181
+ sha256: "2aca09540abadfaebe4cd52a0803b31694406b9a5795419c43a52a92d53068c9",
182
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
183
+ },
184
+ {
185
+ // v1.5.1–v1.5.2
186
+ size: 12043,
187
+ sha256: "a37f8130c04ab404101a36a145dcf5c27f91e5204562af15addd592849113ec7",
188
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
189
+ },
190
+ {
191
+ // v1.6.0–v1.6.1
192
+ size: 12739,
193
+ sha256: "e688c82b41f8a6d0040c9c9523e7c109c2e3585194af1b43b99b8629be339eda",
194
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
195
+ },
196
+ {
197
+ // v1.6.2–v1.6.3
198
+ size: 13994,
199
+ sha256: "3e1e841da9ddb0c478cbe5e3238fbf17ea032b07c03f319da5b45a1b1add59b2",
200
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
201
+ },
202
+ {
203
+ // v1.7.0–v1.8.1
204
+ size: 15970,
205
+ sha256: "073f3c05f134df5d5daf8cd05df51dc15916300977a19d8b6dda3c8dd36abd1c",
206
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
207
+ },
208
+ {
209
+ // v1.9.0–v1.10.5: migrate only untouched copies to the current hardened prompt.
210
+ size: 16521,
211
+ sha256: "e0ab767934b62e25e712ec31ebbc97dc780dff93d2e3855181f8bbe0086a4940",
212
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
213
+ },
214
+ {
215
+ // v1.11.0–v1.12.1
216
+ size: 18267,
217
+ sha256: "dbe4a4da34338e4210b5abf9a902b4c56bba31eced1f00eb775e30b25ee37b90",
218
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
219
+ },
220
+ {
221
+ // v1.14.1 pristine seed (LF and CRLF checkout variants).
222
+ size: 19417,
223
+ sha256: "e64985de2708ef0715aa01b555f7e322b97b6dfbceca3c26913949f2c4e69f98",
224
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
225
+ },
226
+ {
227
+ size: 19673,
228
+ sha256: "69be3a81b6b20ff51e21f8a6730049a5f740dd00c069f21d1a5a2184fda97f24",
229
+ requiredAdditions: ELITE_REQUIRED_ADDITIONS,
230
+ },
231
+ ],
232
+ // Live-peer collaboration landed in v1.12.2. Without these, a seeded copy from 1.7–1.12.1
233
+ // (and the two earlier untouched hashes) keeps the pre-collaboration prompt forever.
234
+ "personas/swarm.md": [
235
+ // v1.13.4 pristine seed (LF and CRLF checkout variants)
236
+ { size: 2729, sha256: "3d5060277ecaec717e87beb8d95b0fe9f3dc70fe5f0192823aa79b55f9e49507" },
237
+ { size: 2768, sha256: "b81910521e05d62e06abaf0f2a89bd18ad252ba968bb296cc85dea973028e7a5" },
238
+ // Current pre-Exocom-scope prompt (LF and CRLF checkout variants).
239
+ { size: 2188, sha256: "bb7bfcc03f4b5239fb8dcd3552ee517ed3e0a8238edf05e21303085022f4018b" },
240
+ { size: 2220, sha256: "5c0d332d389e2ff59a79f0ca0a28c05424da9b2194fe78a78c667606e9ff806d" },
241
+ { size: 1857, sha256: "77de7f590fdede6ede4848be11b39fb16bfb4733687ac1b1244cc59e626874c6" }, // v1.1.0–v1.4.0
242
+ { size: 1955, sha256: "b7a7caa38c4d31c8cc27b85eb7facbf2339a024ad8d4cd2ba681a2a885de7646" }, // v1.5.1–v1.6.3
243
+ { size: 2285, sha256: "985a1c5455b2c81537a55b31daed216d7f67a897b3b2d5bde35d64f3779d6fe4" }, // v1.7.0–v1.12.1
244
+ ],
245
+ };
246
+
247
+ export interface SeedMigrationResult {
248
+ /** Absolute user paths upgraded from an exact, known-pristine old default. */
249
+ migrated: string[];
250
+ /** Absolute user paths installed as required additions during an eligible migration. */
251
+ installed: string[];
252
+ /** Absolute user paths intentionally left alone (edited, unknown, absent, or unchanged). */
253
+ skipped: string[];
254
+ /** Absolute paths that could not be inspected or replaced safely. */
255
+ warnings: string[];
256
+ }
257
+
258
+ export interface SeedMigrationOptions {
259
+ /** Override the built-in allow-list in tests or for a future explicitly supported release. */
260
+ legacyDefaults?: Readonly<Record<string, SeededDefaultEntry>>;
261
+ /** Filesystem seams for bounded inspection and no-clobber replacement tests. */
262
+ io?: SeedMigrationIO;
263
+ /** Test-only race hook, called after the old target is moved to backup and before install. */
264
+ beforeInstall?: (target: string, backup: string, staged: string) => void;
265
+ /** Test seam for making the random staging suffix deterministic. Production uses randomUUID. */
266
+ idFactory?: () => string;
267
+ }
268
+
269
+ export interface SeedMigrationIO extends SpineLegacyIO {
270
+ copyFile?: (from: string, to: string, flags?: number) => void;
271
+ link?: (from: string, to: string) => void;
272
+ rename?: (from: string, to: string) => void;
273
+ remove?: (path: string) => void;
274
+ }
275
+
276
+ function listByExt(dir: string, ext: string): string[] {
277
+ try {
278
+ return readdirSync(dir).filter((f) => f.toLowerCase().endsWith(ext));
279
+ } catch {
280
+ return []; // a bundled asset folder may be absent — that is fine
281
+ }
282
+ }
283
+
284
+ interface ResolvedSpineLegacyIO {
285
+ lstat(path: string): SpineLegacyStat;
286
+ open(path: string): number;
287
+ fstat(fd: number): SpineLegacyStat;
288
+ read(fd: number, maxBytes: number): Buffer;
289
+ close(fd: number): void;
290
+ }
291
+
292
+ /** Read through a descriptor into a fixed allocation. A file that grows after `fstat` can fill at
293
+ * most expected-size + one byte, which is enough to reject it without ever loading the growth. */
294
+ function readBounded(fd: number, maxBytes: number): Buffer {
295
+ const buffer = Buffer.alloc(maxBytes);
296
+ let offset = 0;
297
+ while (offset < maxBytes) {
298
+ const read = readSync(fd, buffer, offset, maxBytes - offset, offset);
299
+ if (read === 0) break;
300
+ offset += read;
301
+ }
302
+ return buffer.subarray(0, offset);
303
+ }
304
+
305
+ function resolveLegacyIO(io: SpineLegacyIO): ResolvedSpineLegacyIO {
306
+ return {
307
+ lstat: io.lstat ?? lstatSync,
308
+ open: io.open ?? ((path) => openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))),
309
+ fstat: io.fstat ?? fstatSync,
310
+ read: io.read ?? readBounded,
311
+ close: io.close ?? closeSync,
312
+ };
313
+ }
314
+
315
+ interface SpineFingerprint {
316
+ dev: number;
317
+ ino: number;
318
+ nlink: number;
319
+ size: number;
320
+ }
321
+
322
+ type Inspection =
323
+ | { kind: "legacy" }
324
+ | { kind: "skip" }
325
+ | { kind: "warning"; detail: string };
326
+
327
+ function fingerprint(stat: SpineLegacyStat): SpineFingerprint {
328
+ return { dev: stat.dev, ino: stat.ino, nlink: stat.nlink, size: stat.size };
329
+ }
330
+
331
+ function sameFingerprint(left: SpineFingerprint, right: SpineFingerprint): boolean {
332
+ return left.dev === right.dev && left.ino === right.ino && left.nlink === right.nlink && left.size === right.size;
333
+ }
334
+
335
+ function safeLegacyShape(stat: SpineLegacyStat, expectedSize: number): boolean {
336
+ return stat.isFile() && stat.nlink === 1 && stat.size === expectedSize;
337
+ }
338
+
339
+ function errorText(error: unknown): string {
340
+ return error instanceof Error ? error.message : String(error);
341
+ }
342
+
343
+ /** Inspect through an opened descriptor. The user-controlled path is never read until both lstat
344
+ * and fstat establish a single-link regular file of the exact known legacy size. */
345
+ function inspectLegacy(path: string, file: SpineFile, io: ResolvedSpineLegacyIO): Inspection {
346
+ let before: SpineLegacyStat;
347
+ try {
348
+ before = io.lstat(path);
349
+ } catch {
350
+ return { kind: "skip" };
351
+ }
352
+ if (!safeLegacyShape(before, LEGACY_SPINE_SIZE[file])) return { kind: "skip" };
353
+
354
+ let fd: number;
355
+ try {
356
+ fd = io.open(path);
357
+ } catch (error) {
358
+ return { kind: "warning", detail: `open failed: ${errorText(error)}` };
359
+ }
360
+ try {
361
+ const opened = io.fstat(fd);
362
+ if (!safeLegacyShape(opened, LEGACY_SPINE_SIZE[file])) return { kind: "skip" };
363
+ if (!sameFingerprint(fingerprint(before), fingerprint(opened))) return { kind: "skip" };
364
+ const bytes = io.read(fd, LEGACY_SPINE_SIZE[file] + 1);
365
+ if (bytes.byteLength !== LEGACY_SPINE_SIZE[file]) return { kind: "skip" };
366
+ const after = io.fstat(fd);
367
+ if (!safeLegacyShape(after, LEGACY_SPINE_SIZE[file])) return { kind: "skip" };
368
+ if (!sameFingerprint(fingerprint(opened), fingerprint(after))) return { kind: "skip" };
369
+ if (createHash("sha256").update(bytes).digest("hex") !== LEGACY_SPINE_SHA256[file]) return { kind: "skip" };
370
+ return { kind: "legacy" };
371
+ } catch (error) {
372
+ return { kind: "warning", detail: `inspection failed: ${errorText(error)}` };
373
+ } finally {
374
+ try {
375
+ io.close(fd);
376
+ } catch {
377
+ // The descriptor has no further use; a close error cannot change the classification.
378
+ }
379
+ }
380
+ }
381
+
382
+ /** Find exact, pristine v1.8.0 seeded spine files already present in `userDir` without writing.
383
+ * The extension uses the result to fall through to the current bundled prompt for selector `on`.
384
+ * `/persona restore` remains the explicit, user-authorized operation that updates bytes on disk. */
385
+ export function inspectLegacySeededSpines(
386
+ userDir: string,
387
+ selection: SpineLegacySelection,
388
+ ioOverrides: SpineLegacyIO = {},
389
+ ): SpineLegacyResult {
390
+ const result: SpineLegacyResult = { legacy: [], warnings: [] };
391
+ const io = resolveLegacyIO(ioOverrides);
392
+ for (const file of SPINE_FILES) {
393
+ if (file === "spine.md" ? !selection.supervisor : !selection.worker) continue;
394
+ const path = join(userDir, file);
395
+ const inspection = inspectLegacy(path, file, io);
396
+ if (inspection.kind === "legacy") result.legacy.push(path);
397
+ else if (inspection.kind === "warning") result.warnings.push(`spine legacy inspection warning: ${path}: ${inspection.detail}`);
398
+ }
399
+ return result;
400
+ }
401
+
402
+ type SeedInspection =
403
+ | { kind: "legacy"; fingerprint: SpineFingerprint; spec: SeededDefaultSpec }
404
+ | { kind: "skip" }
405
+ | { kind: "warning"; detail: string };
406
+
407
+ interface ResolvedSeedMigrationIO extends ResolvedSpineLegacyIO {
408
+ copyFile(from: string, to: string, flags?: number): void;
409
+ link(from: string, to: string): void;
410
+ rename(from: string, to: string): void;
411
+ remove(path: string): void;
412
+ }
413
+
414
+ function resolveSeedMigrationIO(io: SeedMigrationIO): ResolvedSeedMigrationIO {
415
+ return {
416
+ ...resolveLegacyIO(io),
417
+ copyFile: io.copyFile ?? copyFileSync,
418
+ link: io.link ?? linkSync,
419
+ rename: io.rename ?? renameSync,
420
+ remove: io.remove ?? ((path) => rmSync(path, { force: true })),
421
+ };
422
+ }
423
+
424
+ function seededDefaultSpecs(entry: SeededDefaultEntry): readonly SeededDefaultSpec[] {
425
+ return Array.isArray(entry) ? entry : [entry as SeededDefaultSpec];
426
+ }
427
+
428
+ /** Inspect one explicitly allowlisted target through a bounded descriptor. Unrelated user files
429
+ * never enter this function: the migration loop is keyed only by the explicit allow-list. Multiple
430
+ * historical digests of the same size share one bounded read. */
431
+ function inspectPristineSeededDefault(path: string, entry: SeededDefaultEntry, io: ResolvedSpineLegacyIO): SeedInspection {
432
+ let before: SpineLegacyStat;
433
+ try {
434
+ before = io.lstat(path);
435
+ } catch {
436
+ return { kind: "skip" };
437
+ }
438
+ const candidates = seededDefaultSpecs(entry).filter((spec) => safeLegacyShape(before, spec.size));
439
+ const expectedSize = candidates[0]?.size;
440
+ if (expectedSize === undefined) return { kind: "skip" };
441
+ let fd: number;
442
+ try {
443
+ fd = io.open(path);
444
+ } catch (error) {
445
+ return { kind: "warning", detail: `open failed: ${errorText(error)}` };
446
+ }
447
+ try {
448
+ const opened = io.fstat(fd);
449
+ if (!safeLegacyShape(opened, expectedSize) || !sameFingerprint(fingerprint(before), fingerprint(opened))) return { kind: "skip" };
450
+ const bytes = io.read(fd, expectedSize + 1);
451
+ const after = io.fstat(fd);
452
+ if (!safeLegacyShape(after, expectedSize) || !sameFingerprint(fingerprint(opened), fingerprint(after))) return { kind: "skip" };
453
+ // The sentinel byte rejects growth without ever hashing/retaining unbounded user data.
454
+ if (bytes.byteLength !== expectedSize) return { kind: "skip" };
455
+ const digest = createHash("sha256").update(bytes).digest("hex");
456
+ const matched = candidates.find((spec) => spec.sha256 === digest);
457
+ if (!matched) return { kind: "skip" };
458
+ return { kind: "legacy", fingerprint: fingerprint(after), spec: matched };
459
+ } catch (error) {
460
+ return { kind: "warning", detail: `inspection failed: ${errorText(error)}` };
461
+ } finally {
462
+ try {
463
+ io.close(fd);
464
+ } catch {
465
+ // A descriptor close failure cannot make an inspected file safe to replace.
466
+ }
467
+ }
468
+ }
469
+
470
+ function isMissing(error: unknown): boolean {
471
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
472
+ }
473
+
474
+ function restoreBackupIfTargetMissing(target: string, backup: string, io: ResolvedSeedMigrationIO): boolean {
475
+ try {
476
+ io.lstat(target);
477
+ return false;
478
+ } catch (error) {
479
+ if (!isMissing(error)) return false;
480
+ }
481
+ try {
482
+ io.rename(backup, target);
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+
489
+ type AdditionInstallResult = { kind: "installed" | "present" } | { kind: "failed"; detail: string };
490
+
491
+ function userAgentTarget(userDir: string, relative: string): string | undefined {
492
+ const normalized = relative.replaceAll("\\", "/");
493
+ if (!normalized.startsWith("agents/") || normalized.includes("/../") || normalized.endsWith("/..") || normalized.includes("/./")) return undefined;
494
+ const name = normalized.slice("agents/".length);
495
+ if (!name || name.includes("/")) return undefined;
496
+ return join(userDir, "agents", name);
497
+ }
498
+
499
+ function installMissingAddition(
500
+ source: string,
501
+ target: string,
502
+ io: ResolvedSeedMigrationIO,
503
+ idFactory: () => string,
504
+ ): AdditionInstallResult {
505
+ try {
506
+ io.lstat(target);
507
+ return { kind: "present" };
508
+ } catch (error) {
509
+ if (!isMissing(error)) return { kind: "failed", detail: `target inspection failed: ${errorText(error)}` };
510
+ }
511
+ const staged = `${target}.pi-persona-migrate-${process.pid}-${idFactory()}.tmp`;
512
+ let stagedOwned = false;
513
+ try {
514
+ try {
515
+ io.copyFile(source, staged, constants.COPYFILE_EXCL);
516
+ stagedOwned = true;
517
+ } catch (error) {
518
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return { kind: "failed", detail: `staging path already exists; preserved: ${staged}` };
519
+ return { kind: "failed", detail: `staging failed: ${errorText(error)}` };
520
+ }
521
+ try {
522
+ io.link(staged, target);
523
+ return { kind: "installed" };
524
+ } catch (error) {
525
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return { kind: "present" };
526
+ return { kind: "failed", detail: `install failed: ${errorText(error)}` };
527
+ }
528
+ } finally {
529
+ if (stagedOwned) {
530
+ try {
531
+ io.remove(staged);
532
+ } catch {
533
+ // The addition is already linked; a stale temp is recoverable and diagnosed by parent.
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Upgrade bundled persona/agent files whose user copy is byte-for-byte one of the explicitly
541
+ * known pristine defaults from an older release. This operation is intentionally separate from
542
+ * {@link seedDefaults}: normal seeding preserves every existing file, while activation may call
543
+ * this function once to pick up a security/behavioral default without overwriting user work.
544
+ *
545
+ * The target is checked as a single-link regular file before and after reading, and is checked
546
+ * once more immediately before replacement. A changed file, symlink, hard-link, missing asset,
547
+ * or replacement error is skipped/reported rather than overwritten. The result is suitable for
548
+ * `/doctor` and activation diagnostics; this function never throws for a per-file race.
549
+ */
550
+ export function migratePristineSeededDefaults(
551
+ bundledDir: string,
552
+ userDir: string,
553
+ options: SeedMigrationOptions = {},
554
+ ): SeedMigrationResult {
555
+ const migrated: string[] = [];
556
+ const installed: string[] = [];
557
+ const skipped: string[] = [];
558
+ const warnings: string[] = [];
559
+ const legacyDefaults = options.legacyDefaults ?? LEGACY_SEEDED_DEFAULTS;
560
+ const io = resolveSeedMigrationIO(options.io ?? {});
561
+ const idFactory = options.idFactory ?? randomUUID;
562
+ for (const [relative, entry] of Object.entries(legacyDefaults)) {
563
+ const source = join(bundledDir, relative);
564
+ const name = relative.replace(/^.*[\\/]/, "");
565
+ const target = join(userDir, "agents", name);
566
+ if (!existsSync(source)) {
567
+ skipped.push(target);
568
+ continue;
569
+ }
570
+ const inspection = inspectPristineSeededDefault(target, entry, io);
571
+ if (inspection.kind === "warning") {
572
+ warnings.push(`seed migration inspection warning: ${target}: ${inspection.detail}`);
573
+ continue;
574
+ }
575
+ if (inspection.kind !== "legacy") {
576
+ skipped.push(target);
577
+ continue;
578
+ }
579
+ const spec = inspection.spec;
580
+ let additionsReady = true;
581
+ for (const addition of spec.requiredAdditions ?? []) {
582
+ const additionTarget = userAgentTarget(userDir, addition);
583
+ if (!additionTarget) {
584
+ warnings.push(`seed migration required addition is invalid: ${addition}`);
585
+ additionsReady = false;
586
+ break;
587
+ }
588
+ const additionSource = join(bundledDir, addition);
589
+ if (!existsSync(additionSource)) {
590
+ warnings.push(`seed migration required addition missing from bundle: ${additionSource}`);
591
+ additionsReady = false;
592
+ break;
593
+ }
594
+ const additionResult = installMissingAddition(additionSource, additionTarget, io, idFactory);
595
+ if (additionResult.kind === "failed") {
596
+ warnings.push(`seed migration required addition failed for ${target}: ${additionResult.detail}`);
597
+ additionsReady = false;
598
+ break;
599
+ }
600
+ if (additionResult.kind === "installed") installed.push(additionTarget);
601
+ }
602
+ if (!additionsReady) {
603
+ skipped.push(target);
604
+ continue;
605
+ }
606
+ const suffix = `${process.pid}-${idFactory()}`;
607
+ const staged = `${target}.pi-persona-migrate-${suffix}.tmp`;
608
+ const backup = `${target}.pi-persona-migrate-${suffix}.bak`;
609
+ let stagedOwned = false;
610
+ try {
611
+ // Stage the trusted bundled bytes beside the target with exclusive creation. A stale
612
+ // temp from a crashed prior run is never followed, overwritten, or removed by us.
613
+ try {
614
+ io.copyFile(source, staged, constants.COPYFILE_EXCL);
615
+ stagedOwned = true;
616
+ } catch (error) {
617
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
618
+ warnings.push(`seed migration staging path already exists; preserved: ${staged}`);
619
+ skipped.push(target);
620
+ continue;
621
+ }
622
+ throw error;
623
+ }
624
+ let current: SpineLegacyStat;
625
+ try {
626
+ current = io.lstat(target);
627
+ } catch {
628
+ skipped.push(target);
629
+ continue;
630
+ }
631
+ if (!safeLegacyShape(current, spec.size) || !sameFingerprint(inspection.fingerprint, fingerprint(current))) {
632
+ skipped.push(target);
633
+ continue;
634
+ }
635
+ // Move the old inode to a recoverable backup. Installing with a hard-link below makes
636
+ // target creation atomic-visible and no-clobber: a concurrent writer that claims the
637
+ // path causes link() to fail with EEXIST rather than replacing its bytes. Both paths
638
+ // are siblings, so this is same-volume on POSIX and Windows/NTFS alike.
639
+ io.rename(target, backup);
640
+ const backupInspection = inspectPristineSeededDefault(backup, spec, io);
641
+ if (backupInspection.kind !== "legacy") {
642
+ if (!restoreBackupIfTargetMissing(target, backup, io)) warnings.push(`seed migration backup preserved: ${backup}`);
643
+ warnings.push(`seed migration target changed before replacement: ${target}`);
644
+ skipped.push(target);
645
+ continue;
646
+ }
647
+ options.beforeInstall?.(target, backup, staged);
648
+ try {
649
+ io.link(staged, target);
650
+ } catch (error) {
651
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
652
+ // A concurrent custom edit won the path. Leave it untouched and discard only
653
+ // the old known-pristine backup.
654
+ io.remove(backup);
655
+ warnings.push(`seed migration target changed during migration; preserved: ${target}`);
656
+ skipped.push(target);
657
+ continue;
658
+ }
659
+ if (!restoreBackupIfTargetMissing(target, backup, io)) warnings.push(`seed migration backup preserved: ${backup}`);
660
+ throw error;
661
+ }
662
+ io.remove(backup);
663
+ migrated.push(target);
664
+ } catch (error) {
665
+ warnings.push(`seed migration replacement warning: ${target}: ${errorText(error)}`);
666
+ } finally {
667
+ if (stagedOwned) {
668
+ try {
669
+ io.remove(staged);
670
+ } catch {
671
+ warnings.push(`seed migration temporary file remains: ${staged}`);
672
+ }
673
+ }
674
+ }
675
+ }
676
+ return { migrated, installed, skipped, warnings };
677
+ }
678
+
679
+ /** Copy the bundled defaults under `bundledDir` into `userDir`. Returns what changed. */
680
+ export function seedDefaults(bundledDir: string, userDir: string, force: boolean): SeedResult {
681
+ const copied: string[] = [];
682
+ const skipped: string[] = [];
683
+ const collisions: string[] = [];
684
+ const warnings: string[] = [];
685
+ const place = (src: string, dst: string): void => {
686
+ // Per-file fault isolation, same posture as the migration path below: a locked or
687
+ // unreadable destination is a warning, never an abort of the remaining defaults.
688
+ try {
689
+ placeFile(src, dst);
690
+ } catch (error) {
691
+ warnings.push(`${dst}: ${errorText(error)}`);
692
+ }
693
+ };
694
+ const placeFile = (src: string, dst: string): void => {
695
+ mkdirSync(dirname(dst), { recursive: true });
696
+ if (!force) {
697
+ // `existsSync` follows symlinks and leaves a TOCTOU window. Inspect the directory entry,
698
+ // then still use exclusive creation so an editor/process that wins the race is preserved.
699
+ try {
700
+ lstatSync(dst);
701
+ skipped.push(dst);
702
+ return;
703
+ } catch (error) {
704
+ if (!isMissing(error)) throw error;
705
+ }
706
+ try {
707
+ copyFileSync(src, dst, constants.COPYFILE_EXCL);
708
+ copied.push(dst);
709
+ } catch (error) {
710
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
711
+ skipped.push(dst);
712
+ }
713
+ return;
714
+ }
715
+
716
+ // Restore is explicitly destructive for THIS directory entry, but must never write through
717
+ // a user symlink/hard-link. Stage complete bytes beside it and atomically replace the entry;
718
+ // readers see either old or new content, and another link to the old inode stays untouched.
719
+ const staged = `${dst}.pi-persona-restore-${process.pid}-${randomUUID()}.tmp`;
720
+ let stagedOwned = false;
721
+ try {
722
+ copyFileSync(src, staged, constants.COPYFILE_EXCL);
723
+ stagedOwned = true;
724
+ renameSync(staged, dst);
725
+ stagedOwned = false;
726
+ copied.push(dst);
727
+ } finally {
728
+ if (stagedOwned) rmSync(staged, { force: true });
729
+ }
730
+ };
731
+
732
+ // Personas + agents share <user>/agents. Seed personas first so a colliding basename has one
733
+ // deterministic on-disk owner; seedDefaults returns the collision names for diagnostics.
734
+ const personaFiles = listByExt(join(bundledDir, "personas"), ".md");
735
+ const ownedByPersona = new Set(personaFiles);
736
+ for (const f of personaFiles) place(join(bundledDir, "personas", f), join(userDir, "agents", f));
737
+ for (const f of listByExt(join(bundledDir, "agents"), ".md")) {
738
+ if (ownedByPersona.has(f)) {
739
+ collisions.push(f.slice(0, -3));
740
+ skipped.push(join(userDir, "agents", f));
741
+ continue;
742
+ }
743
+ place(join(bundledDir, "agents", f), join(userDir, "agents", f));
744
+ }
745
+
746
+ // flows / contracts / presets → their own user dirs.
747
+ for (const [sub, ext] of [
748
+ ["flows", ".flow.json"],
749
+ ["contracts", ".contract.json"],
750
+ ["presets", ".preset.json"],
751
+ ] as const) {
752
+ for (const f of listByExt(join(bundledDir, sub), ext)) place(join(bundledDir, sub, f), join(userDir, sub, f));
753
+ }
754
+
755
+ // The spine pair (docs/SPINE.md) — bundled under `prompts/`, seeded FLAT into the data dir,
756
+ // because that is where `PI_PERSONA_SPINE=on` looks for the user's own copy. Without this the
757
+ // documented "your own copy shadows the bundled one" precedence has no gesture that creates
758
+ // the copy, and a user would have to know the two filenames and write them by hand.
759
+ for (const f of SPINE_FILES) {
760
+ const src = join(bundledDir, "prompts", f);
761
+ const dst = join(userDir, f);
762
+ if (existsSync(src)) place(src, dst);
763
+ }
764
+
765
+ // teams.yaml — a single merged file.
766
+ const teamsSrc = join(bundledDir, "teams.yaml");
767
+ if (existsSync(teamsSrc)) place(teamsSrc, join(userDir, "teams.yaml"));
768
+
769
+ return { copied, skipped, ...(collisions.length > 0 ? { collisions } : {}), ...(warnings.length > 0 ? { warnings } : {}) };
770
+ }