gentle-pi 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +352 -128
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
package/docs/yolo-mode.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # YOLO permission
2
2
 
3
- > 🚀 **Full speed, destructive actions still ask.** YOLO lets the agent implement, commit, push and open PRs within the already authorized task without asking each time, which suits long autonomous runs. Destructive operations still require fresh confirmation.
3
+ > 🚀 **Full speed, destructive actions still ask.** YOLO lets the agent implement, commit, push and open PRs within the already authorized task without asking each time, which suits long autonomous runs. Destructive operations still require fresh confirmation; the one exception is recursive removal of the session's own temp scratch (see [Temp scratch removal](#temp-scratch-removal)).
4
4
 
5
5
  Use `/gentle:yolo enable` in the interactive primary Pi TUI to stop repeated permission questions for ordinary work **within your already authorized task**. YOLO is **OFF** unless you select it or save YOLO as the user-global default. [Normal, Auto and global defaults](permission-modes.md) are managed separately from the live-session toggle. The active status and a separate editor widget both say **🚀 YOLO ON 🔥 — destructive confirmations remain**, even when Gentle Shell hides its status layout.
6
6
 
@@ -29,17 +29,29 @@ The live YOLO toggle does not save preferences. The separate **Default permissio
29
29
 
30
30
  YOLO supplies standing human permission for ordinary already-scoped implementation, checks, commits, non-force pushes and PR creation. Its active-only system instruction qualifies the default commit/push/PR confirmation clauses; it also tells the primary agent to make ordinary reversible implementation choices without needless interviews.
31
31
 
32
- Only a single plain `git push` can skip the shell guard's routine default confirmation. Simple remote/ref arguments and `-u`/`--set-upstream` are eligible. Compound commands, wrappers, repository-changing options and other push options conservatively retain normal guard behavior. Full command evaluation, including the recognized data-loss guards, runs first. Explicit push confirmations or blocks in either configuration layer remain restrictions, including when legacy persistent autonomy or its environment override would ignore them. YOLO never changes persistent `autonomousMode`.
32
+ The shell guard's routine default confirmation is skipped for reversible delivery only. Every guarded segment of the command is judged on its own, so `git commit -m "..." && git push -u origin feature`, `git push && gh pr create --fill`, `git -C <dir> push origin main` and `git fetch && git rebase origin/main && git push` run without a prompt, while the other segments keep their ordinary guard (`git push && rm -rf data` still asks for the `rm`). A push segment is eligible with plain remote/ref arguments, `src:dst` refspecs with both sides present, and the options `-u`/`--set-upstream`, `-q`/`--quiet`, `-v`/`--verbose`, `--tags`, `--follow-tags`, `--no-verify` and `--porcelain`. A rebase segment is eligible with plain refs, `--onto`, `--continue`, `--abort`, `--skip` and `--autostash`; it is recoverable from the reflog. Either segment may also carry output redirections that write nothing (`2>&1`, `>&2`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`) and may be piped into the read-only filters `tail`, `head`, `cat`, `grep` and `wc` with plain or literally quoted arguments, so `git push -u origin feature 2>&1 | tail -5` runs without a prompt. Redirection to a file, a pipe into any other command (`| sh`, `| tee file`, `| xargs`) and a trailing background `&` still ask: Git output carries text the remote controls. Anything that deletes or rewrites a remote ref, publishes, or runs arbitrary commands never qualifies: `--delete`/`-d`, `:branch` and `+branch` refspecs, `--mirror`, `--prune`, `--all`, every force spelling (hard-blocked), `--receive-pack`/`--exec`, `--repo`, `-o`, `sudo`, shell wrappers such as `sh -c`, interactive or `--exec` rebases, `git branch -D`, `pi remove` and `npm publish`. Full command evaluation, including the recognized data-loss guards, runs first. Explicit push or rebase confirmations or blocks in either configuration layer remain restrictions, including when legacy persistent autonomy or its environment override would ignore them. YOLO never changes persistent `autonomousMode`.
33
33
 
34
34
  ## What still requires a decision
35
35
 
36
- - Destructive operations retain fresh confirmations or hard denial: recognized SQL DROP/TRUNCATE and broad deletion, recursive removal, destructive Git and other guarded operations are not waived. Explicit configured confirmations/blocks and sensitive-path protection remain.
36
+ - Destructive operations retain fresh confirmations or hard denial: recognized SQL DROP/TRUNCATE and broad deletion, recursive removal, destructive Git and other guarded operations are not waived. The only exception is [temp scratch removal](#temp-scratch-removal). Explicit configured confirmations/blocks and sensitive-path protection remain.
37
37
  - Current explicit human restrictions, repository policy, project trust and authorized scope still apply. Scope expansion, privacy-sensitive disclosure and genuinely unresolved consequential product choices require a human decision.
38
38
  - Ambiguous destinations or credentials remain unresolved. YOLO does not invent a remote, deployment destination, account or credential, nor authorize discovery or reuse of ambient credentials.
39
39
  - `ask_user` tools, provider consent, maintenance/recovery authorization and opaque-token choices are never automatically answered. Native review, RDD and review standing permission remain separate and unchanged.
40
40
 
41
41
  Children retain only their bounded delegated scope. They cannot activate or inherit YOLO, and receive no independent delivery permission.
42
42
 
43
+ ### Temp scratch removal
44
+
45
+ In the primary session with YOLO active, a recursive `rm` whose every target is the session's own temp scratch runs without the data-loss confirmation. For example, `rm -rf /tmp/tmp.8phya1BpFr /tmp/build.log; git log --oneline -1` runs directly when both targets qualify. A target qualifies only when all of these hold:
46
+
47
+ - The segment is a plain `rm` with no wrapper (`sudo`, `env`, `xargs`, assignments), not inside `sh -c` or `find -exec`, and without `--no-preserve-root`.
48
+ - The removal runs before anything else in the command. The command must start with one or more such `rm` segments joined by `;`, `&&`, `||` or newlines. Nothing may precede them: no `mv`, `cp`, `ln`, redirection, subshell or other command. They also may not be backgrounded with `&` or piped with `|`. Otherwise an earlier step could put other data into a target that looked like scratch when the command was checked, so `mv ~/data /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.abcdef1234` still asks. Commands after the removals, such as `cd … && git log`, are allowed.
49
+ - The target is an unquoted literal absolute path made only of letters, digits, `/` and `._-+@%=:,`. It has no `$`, backtick, `~`, glob, brace or redirection characters, no `.`/`..` components and no trailing `/`.
50
+ - The target is exactly one path component under `/tmp`, `/var/tmp` or the resolved `os.tmpdir()`. The temp root itself never qualifies, and a temp root that is `/`, your home directory, one of its ancestors or a directory inside your home is ignored.
51
+ - When the command is checked, the target is missing, a symlink, a regular file you own, or a directory you own named like `mktemp` output (`tmp.` followed by six or more letters or digits).
52
+
53
+ Anything else keeps asking, including another user's directory, a directory not named like `mktemp` output, an unreadable path and `$TMPDIR/...`. Every other effect in the same command keeps its ordinary guard: `rm -rf /tmp/tmp.abcdef1234 && rm -rf data` still asks, and `rm -rf /tmp/tmp.abcdef1234 ~/foo` is still hard-blocked. Normal and Auto modes still ask, and children still block. The check runs when the command is admitted; it does not lock the path until `rm` runs.
54
+
43
55
  ## Lifetime and limits
44
56
 
45
57
  Live permission is held in the extension instance, bound to the exact live SessionManager, session ID and canonical Git common-directory identity, or canonical workspace directory outside Git. Sibling worktrees in one clone may retain it; an unrelated workspace or session identity cannot. Scope loss revokes rather than hiding permission until you return.
@@ -50,7 +62,7 @@ This is permission guidance and shell-guard defense in depth, **not a sandbox or
50
62
 
51
63
  ## Recognized data-loss boundary
52
64
 
53
- Recognized destructive database and broad filesystem commands require **fresh primary confirmation** and are **blocked in package-owned children**, independently of YOLO and existing autonomous-mode configuration.
65
+ Recognized destructive database and broad filesystem commands require **fresh primary confirmation** and are **blocked in package-owned children**, independently of YOLO and existing autonomous-mode configuration. The only YOLO exception is [temp scratch removal](#temp-scratch-removal).
54
66
 
55
67
  | Recognized executable form | Primary session | Delegated child |
56
68
  |---|---|---|
@@ -58,12 +70,13 @@ Recognized destructive database and broad filesystem commands require **fresh pr
58
70
  | SQL `DELETE FROM` without `WHERE`, or a trailing literal `WHERE 1=1` / `WHERE TRUE` | Fresh confirmation | Block |
59
71
  | Recursive `rm` (`-r`, `-R`, combined short flags, `--recursive`), `find -delete`, recognized destructive `find -exec/-execdir` and `xargs` invocations | Fresh confirmation | Block |
60
72
  | Recursive `rm` targeting `/`, `/*`, `.`, `..`, home or home descendants | Hard block | Block |
73
+ | Temp scratch recursive removal ([conditions](#temp-scratch-removal)) | Waived under YOLO (primary only); fresh confirmation otherwise | Block |
61
74
  | Git force push, hard reset, forced clean | Hard block | Block |
62
75
  | Git forced branch deletion, reset/clean/restore/rebase, checkout with `--` or force, stash drop/clear | Existing primary policy remains | Block |
63
76
 
64
77
  SQL recognition is limited to literal arguments passed to `psql`, `mysql`, `mariadb`, or `sqlite3`, literal `echo`/`printf` pipelines into those clients, and simple named heredocs. SQL string literals and comments are excluded from the new SQL recognition; printing or searching destructive prose alone does not trigger it.
65
78
 
66
- Command recognition follows simple shell separators, pipelines and parenthesized command groups. It handles executable paths, leading assignments, common `env`, `sudo`, `command`, `exec`, `nohup`, `timeout` and `xargs` wrappers, and literal `sh/bash/zsh/dash -c` payloads (up to five recognition levels). Every recognized operation in a compound command participates; an earlier allowed delivery action cannot hide later data loss.
79
+ Command recognition follows simple shell separators, pipelines and parenthesized command groups. It handles executable paths, leading assignments, common `env`, `sudo`, `command`, `exec`, `nohup`, `timeout` and `xargs` wrappers, and literal `sh/bash/zsh/dash -c` payloads (up to five recognition levels). A command after the unquoted reserved words `then`, `else`, `elif`, `do`, `{`, `!`, `if`, `while` and `until` is recognized like a command at the start of a segment, so `if true; then rm -rf data; fi` and `for d in a b; do rm -rf "$d"; done` are guarded like `rm -rf data`, and a root or home target behind them is still hard-blocked. Backtick substitutions outside single quotes (also inside double quotes) and literal `eval` payloads, quoted or bare words, are recognized like literal `sh -c` payloads within the same recognition levels; `eval "$x"` and other expanded payloads are not resolved. A recursive `rm` behind a reserved word, in backticks or in `eval` never qualifies for [temp scratch removal](#temp-scratch-removal). Every recognized operation in a compound command participates; an earlier allowed delivery action cannot hide later data loss.
67
80
 
68
81
  Ordinary builds, tests, read-only SQL, predicate-bounded literal deletes, Git status/log and plain pushes are unchanged by the shared data-loss classifier. A build command that explicitly includes recursive deletion is still guarded.
69
82
 
@@ -71,14 +84,15 @@ Ordinary builds, tests, read-only SQL, predicate-bounded literal deletes, Git st
71
84
 
72
85
  1. Existing hard denies and newly recognized hard-deny forms win across the command.
73
86
  2. Explicit configured blocks retain their existing semantics and win before data-loss confirmation. Active YOLO additionally honors explicit push restrictions in either configuration layer.
74
- 3. Recognized database/filesystem data loss always requires fresh confirmation, even when autonomous mode or a matched delivery action is configured to allow.
87
+ 3. Recognized database/filesystem data loss always requires fresh confirmation, even when autonomous mode or a matched delivery action is configured to allow. The only exception is [temp scratch removal](#temp-scratch-removal) under active YOLO in the primary session.
75
88
  4. No UI, cancellation, a non-true answer or a dialog error cannot authorize execution. Approval is not cached; permission and Herdr blocker lifecycle events remain balanced.
76
89
 
77
90
  Children receive the lightweight `child-safety.ts` entry alongside `child-context.ts`; they do not need the full primary extension. The safety entry registers nothing outside `GENTLE_PI_AGENTS_CHILD=1`, preventing duplicate primary prompts during package auto-discovery. If the full primary extension is explicitly loaded in a child, its destructive-command path also blocks instead of prompting.
78
91
 
79
92
  ### Detection limitations
80
93
 
81
- - This is deterministic lexical recognition, **not** shell or SQL evaluation. Aliases, functions, dynamically assembled commands, variable/command expansion inside quoted payloads, unusual wrapper flags, complex heredocs and deeply nested wrappers can evade recognition. Simple recursive removal of a variable target is guarded, but its expanded target cannot reliably be identified as a hard-deny path.
94
+ - This is deterministic lexical recognition, **not** shell or SQL evaluation. Aliases, functions, other reserved words (`time`, `function`, `coproc`), commands inside double-quoted `$(...)`, dynamically assembled commands, variable/command expansion inside quoted payloads, unusual wrapper flags, complex heredocs and deeply nested wrappers can evade recognition. Simple recursive removal of a variable target is guarded, but its expanded target cannot reliably be identified as a hard-deny path.
95
+ - Guarded-command confirmations and hard-denied patterns ignore text that only appears as an argument of a known data command (`grep`, `rg`, `cat`, `echo`, `printf`, `head`, `tail`, `jq`, `sort`, `uniq`, `wc` and similar), including the body of a real heredoc fed to one. Searching a file for a push or printing a reset command does not open a dialog. The exemption applies only when every command in the line is such a data command or an inert one (`cd`, `pwd`, `test`, `mkdir`, `touch`), optionally after plain `VAR=value` assignments or a flagless `env`. Plain assignment segments (`f=x;`) and simple command substitutions whose body is itself only data or inert commands (`f=$(ls -t *.jsonl | head -40); grep -l "git push" $f`) keep the exemption. Anything else anywhere in the line keeps the full lexical match: any other command or wrapper (`sed`, `sh`, `python`, `awk`, `xargs`, `sudo`, `exec`, `at`, `./script`), a command substitution that runs anything else or nests quotes, expansions or another substitution, a command substitution together with a heredoc, process substitution, backticks, a `<<` that is not an unquoted heredoc, and quoting the tokenizer cannot account for. `sed` is excluded because GNU sed can execute its script.
82
96
  - Scripts, migration files, redirected SQL files, arbitrary interpreter programs (Python/Node/etc.), encoded payloads and remote execution are not inspected. Non-recursive wildcard deletion, file truncation, other database clients and nontrivial tautological SQL predicates are not comprehensively classified. A `WHERE` clause is not proof of bounded impact.
83
97
  - Only `bash` tool calls reach this data-loss boundary, including nested calls dispatched through Pi's tool-event pipeline. Direct process execution, user shell commands, MCP tools and other tools are not covered by these hooks.
84
98
  - The older primary regex safeguards remain authoritative and may be more conservative about text than the shared recognizer. Missing or explicitly overridden child extension paths remove the lightweight boundary; a valid package installation must include the safety entry.
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, Input, isKeyRelease, matchesKey, Text, type KeybindingsManager, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { type Static, Type } from "typebox";
5
+ import { captureChoiceAnswer } from "../lib/auto-permission-answers.ts";
5
6
  import { NativeChoiceList } from "../lib/native-choice-list.ts";
6
7
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
7
8
  import { isInteractiveMode, isInteractiveRpcHost } from "../lib/rpc-host.ts";
@@ -226,6 +227,7 @@ export default function askUserChoice(pi: ExtensionAPI): void {
226
227
  parameters: ChoiceParamsSchema,
227
228
  executionMode: "sequential",
228
229
  async execute(_toolCallId, params: ChoiceParams, _signal, _onUpdate, ctx) {
230
+ const captureAnswer = captureChoiceAnswer(ctx, _toolCallId, params, _signal);
229
231
  if (ctx.mode !== "tui") {
230
232
  if (!isInteractiveRpcHost(ctx.mode, process.env)) {
231
233
  throw new Error("ask_user_choice is unavailable outside the interactive TUI");
@@ -238,6 +240,7 @@ export default function askUserChoice(pi: ExtensionAPI): void {
238
240
  finally {
239
241
  pi.events.emit(ASK_USER_CHOICE_BLOCKED_EVENT, { active: false });
240
242
  }
243
+ captureAnswer(rpcSelection);
241
244
  return choiceToolResult(params, rpcSelection);
242
245
  }
243
246
 
@@ -302,6 +305,7 @@ export default function askUserChoice(pi: ExtensionAPI): void {
302
305
  pi.events.emit(ASK_USER_CHOICE_BLOCKED_EVENT, { active: false });
303
306
  }
304
307
 
308
+ captureAnswer(selection);
305
309
  return choiceToolResult(params, selection);
306
310
  },
307
311
  renderCall(args, theme) {
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
+ import { captureQuestionnaireAnswers } from "../lib/auto-permission-answers.ts";
4
5
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
5
6
  import { type QuestionData, type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
6
7
  import {
@@ -264,11 +265,14 @@ export default function askUserQuestion(pi: ExtensionAPI): void {
264
265
  ): Promise<QuestionnaireToolResult> {
265
266
  const error = validateQuestionnaire(params);
266
267
  if (error) return invalidQuestionnaireResult(error);
268
+ const captureAnswers = captureQuestionnaireAnswers(ctx, _toolCallId, params, _signal);
267
269
  if (ctx.mode !== "tui") {
268
270
  if (!isInteractiveRpcHost(ctx.mode, process.env)) return unavailableResult();
269
271
  try {
270
272
  pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
271
- return await askThroughDialogs(ctx, params);
273
+ const result = await askThroughDialogs(ctx, params);
274
+ if (!result.details.cancelled) captureAnswers(result.details.answers);
275
+ return result;
272
276
  }
273
277
  finally {
274
278
  pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
@@ -307,6 +311,7 @@ export default function askUserQuestion(pi: ExtensionAPI): void {
307
311
  details: { cancelled: true },
308
312
  };
309
313
  }
314
+ captureAnswers(selection.answers);
310
315
  return {
311
316
  content: [{ type: "text", text: answersText(selection.answers) }],
312
317
  details: { answers: selection.answers },
@@ -1,7 +1,7 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
 
@@ -38,11 +38,18 @@ export interface CodeGraphRunOptions {
38
38
  maxBuffer: number;
39
39
  }
40
40
 
41
+ interface CodeGraphLocalExclude {
42
+ status: "added" | "present" | "failed";
43
+ path?: string;
44
+ reason?: string;
45
+ }
46
+
41
47
  interface CodeGraphFallbackDetails {
42
48
  status: CodeGraphStatus;
43
49
  operation: CodeGraphOperation;
44
50
  cwd: string;
45
51
  fallback: string;
52
+ localExclude?: CodeGraphLocalExclude;
46
53
  }
47
54
 
48
55
  export type CodeGraphRunner = (
@@ -109,6 +116,55 @@ function assertSafeIndexDirectory(cwd: string): void {
109
116
  }
110
117
  }
111
118
 
119
+ function indexExists(cwd: string): boolean {
120
+ try {
121
+ return lstatSync(join(cwd, ".codegraph")).isDirectory();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function gitOutput(cwd: string, args: readonly string[]): string {
128
+ return execFileSync("git", [...args], {
129
+ cwd,
130
+ encoding: "utf8",
131
+ stdio: ["ignore", "pipe", "ignore"],
132
+ windowsHide: true,
133
+ }).trim();
134
+ }
135
+
136
+ // CodeGraph writes .codegraph/.gitignore with `*` and `!.gitignore`, so that file
137
+ // stays untracked and enters every diff. Keep the whole index out of git through
138
+ // the worktree's local exclude file; tracked files and .gitignore are never edited.
139
+ const LOCAL_EXCLUDE_PATTERN = ".codegraph/";
140
+ const EQUIVALENT_EXCLUDE_PATTERNS = new Set([".codegraph", ".codegraph/", "/.codegraph", "/.codegraph/"]);
141
+
142
+ function excludeIndexLocally(cwd: string): CodeGraphLocalExclude | undefined {
143
+ let path: string | undefined;
144
+ try {
145
+ if (gitOutput(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") return undefined;
146
+ // --git-path resolves the exclude file a linked worktree actually reads.
147
+ path = resolve(cwd, gitOutput(cwd, ["rev-parse", "--git-path", "info/exclude"]));
148
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
149
+ mkdirSync(dirname(path), { recursive: true });
150
+ path = join(realpathSync(dirname(path)), basename(path));
151
+ if (current.split(/\r?\n/).some((line) => EQUIVALENT_EXCLUDE_PATTERNS.has(line.trim()))) {
152
+ return { status: "present", path };
153
+ }
154
+ const separator = current.length === 0 || current.endsWith("\n") ? "" : "\n";
155
+ appendFileSync(path, `${separator}${LOCAL_EXCLUDE_PATTERN}\n`);
156
+ return { status: "added", path };
157
+ } catch (error: unknown) {
158
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
159
+ return { status: "failed", ...(path ? { path } : {}), reason };
160
+ }
161
+ }
162
+
163
+ function withLocalExcludeNote(text: string, localExclude: CodeGraphLocalExclude | undefined): string {
164
+ if (localExclude?.status !== "failed") return text;
165
+ return `${text}\n\n.codegraph/ is not excluded from git locally (${localExclude.reason}). Keep .codegraph/ out of the change.`;
166
+ }
167
+
112
168
  function resolveLimit(limit: number | undefined): number {
113
169
  if (limit === undefined) return DEFAULT_LIMIT;
114
170
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
@@ -304,7 +360,7 @@ export function createCodeGraphTool(
304
360
  signal: AbortSignal | undefined,
305
361
  _onUpdate: undefined,
306
362
  ctx: ExtensionContext,
307
- ): Promise<{ content: { type: "text"; text: string }[]; details: { operation: CodeGraphOperation; cwd: string; args: string[] } | CodeGraphFallbackDetails }> {
363
+ ): Promise<{ content: { type: "text"; text: string }[]; details: { operation: CodeGraphOperation; cwd: string; args: string[]; localExclude?: CodeGraphLocalExclude } | CodeGraphFallbackDetails }> {
308
364
  const cwd = resolveWorkspaceCwd(ctx.cwd);
309
365
  assertSafeIndexDirectory(cwd);
310
366
  const args = commandArguments(parameters, cwd);
@@ -313,18 +369,24 @@ export function createCodeGraphTool(
313
369
  try {
314
370
  const result = await runner(args, { cwd, signal: runSignal, maxBuffer: PROCESS_MAX_BUFFER });
315
371
  const output = truncateOutput([result.stdout, result.stderr].filter(Boolean).join("\n"));
372
+ const localExclude = parameters.operation === CODEGRAPH_OPERATION.INIT || indexExists(cwd)
373
+ ? excludeIndexLocally(cwd)
374
+ : undefined;
316
375
  return {
317
- content: [{ type: "text" as const, text: output || "CodeGraph completed without output." }],
318
- details: { operation: parameters.operation, cwd, args },
376
+ content: [{ type: "text" as const, text: withLocalExcludeNote(output || "CodeGraph completed without output.", localExclude) }],
377
+ details: { operation: parameters.operation, cwd, args, ...(localExclude ? { localExclude } : {}) },
319
378
  };
320
379
  } catch (error: unknown) {
321
380
  const details = codeGraphFailureDetails(error, parameters.operation, cwd);
381
+ // A failed init can still leave a partial index behind.
382
+ const localExclude = indexExists(cwd) ? excludeIndexLocally(cwd) : undefined;
383
+ if (localExclude) details.localExclude = localExclude;
322
384
  const timedOut = timeout?.aborted === true && signal?.aborted !== true;
323
385
  const text = timedOut
324
386
  ? `CodeGraph timed out after ${queryTimeoutMs} ms (a slow filesystem such as a WSL /mnt mount, or a very large workspace). ${FALLBACK_INSTRUCTIONS}`
325
387
  : codeGraphFailureMessage(details.status);
326
388
  return {
327
- content: [{ type: "text" as const, text }],
389
+ content: [{ type: "text" as const, text: withLocalExcludeNote(text, localExclude) }],
328
390
  details,
329
391
  };
330
392
  }
@@ -26,8 +26,9 @@ import { VISUAL_SETTINGS_CHANGED } from "../lib/shell-sidebar.ts";
26
26
  import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
27
27
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
28
28
  import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
29
- import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
30
- import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
29
+ import { AGENT_MODE, PROFILE_SOURCE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode, type ProfileSource } from "../lib/agents-config.ts";
30
+ import { freezeSessionProfileAtStartup, inheritedProfileDriftNotice, resolveSessionProfile, sessionProfileRoutingAt, type InheritedProfileOptions, type SessionProfileResolution } from "../lib/session-profile-freeze.ts";
31
+ import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
31
32
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
32
33
  import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
33
34
  import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
@@ -49,6 +50,7 @@ import { discoverOrchestrators } from "../lib/orchestrator-discovery.ts";
49
50
  import { consultPublishedMetadata, unavailableMetadata, type MetadataReceipt } from "../lib/orchestrator-consultation.ts";
50
51
  import { HelperCostPermission } from "../lib/orchestrator-helper-consent.ts";
51
52
  import { OrchestratorStateCache } from "../lib/orchestrator-state.ts";
53
+ import { TODO_PROGRESS_EVENT, type TodoProgress } from "../lib/shell-todo.ts";
52
54
  import { normalizeTaskSubject } from "../lib/orchestrator-alias.ts";
53
55
  import { decodeWorkDescriptor } from "../lib/orchestrator-work.ts";
54
56
  import { validateWorkFilter, searchPublishedWork } from "../lib/orchestrator-work-search.ts";
@@ -61,7 +63,6 @@ import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
61
63
  import { openInExternalEditor } from "./gentle-shell.ts";
62
64
  import { gentlePiConfigHome } from "../lib/agent-home.ts";
63
65
  import { resolveAgentHomeDirectory } from "../lib/agent-model-resolution.ts";
64
- import { resolveProfilePin, resolveUnversionedProjectProfile } from "../lib/agent-profile-pin.ts";
65
66
  import { allowedEditSurfaces, inheritAllowedEditSurfaces, isBoundedWriter, isDevelopmentSurface, isGenericBoundedWriter, prepareBoundSessionRepository, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sessionRepositoryAuthority } from "../lib/bounded-writer-admission.ts";
66
67
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
67
68
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
@@ -187,6 +188,44 @@ function childExtensionRequest(env: NodeJS.ProcessEnv, curated: string[] | undef
187
188
  return curated && curated.length > 0 ? { extensionPaths: [...curated] } : {};
188
189
  }
189
190
 
191
+ // gentle-shell#2180: a short, stable token for the profile layer that supplied
192
+ // the launch's model, recorded next to the model in the TaskRecord so every
193
+ // task card shows which layer won and stale disk edits below a frozen session
194
+ // become visible. Derived from the resolution the request already performed;
195
+ // the only profile reads are the pin/declaration lookups in the paths where
196
+ // `sessionProfileRoutingAt` itself reads the target's own layers without
197
+ // carrying the origin (a foreign target under a session frozen without a
198
+ // profile, and `follow`/child launches), so no layer is resolved twice to route.
199
+ function modelSourceToken(
200
+ source: ProfileSource,
201
+ resolution: SessionProfileResolution,
202
+ routingTarget: InheritedProfileOptions & { foreignRepository?: boolean },
203
+ ): string | undefined {
204
+ if (source === PROFILE_SOURCE.DEFINITION) return "definition";
205
+ // Unresolved means no layer supplied a model at all: the card already shows
206
+ // `default`, and naming a layer next to it would only be noise.
207
+ if (source === PROFILE_SOURCE.UNRESOLVED) return undefined;
208
+ if (source !== PROFILE_SOURCE.PROFILE) return "default";
209
+ if (resolution.kind === "explicit") return "binding";
210
+ if (resolution.kind === "inherited") {
211
+ if (resolution.profile !== undefined) return `frozen:${resolution.profile.origin}`;
212
+ // Frozen without a profile: a foreign target keeps only its own pin
213
+ // layers, so a winning pin there is named from the same read
214
+ // `sessionProfileRoutingAt` just performed; otherwise the session keeps
215
+ // the materialized config routing.
216
+ if (routingTarget.foreignRepository === true) {
217
+ const pin = resolveProfilePin(routingTarget);
218
+ return pin === undefined ? "profile" : `pin:${pin.source}`;
219
+ }
220
+ return "profile";
221
+ }
222
+ // `follow` and live child launches read the target's own defaults
223
+ // (pin → declaration → global active); name the layer the same read chose.
224
+ const pin = resolveProfilePin(routingTarget);
225
+ if (pin !== undefined) return `pin:${pin.source}`;
226
+ return resolveUnversionedProjectProfile(routingTarget.cwd, routingTarget.configHome) !== undefined ? "declaration" : "profile";
227
+ }
228
+
190
229
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
191
230
  const root = join(agentHome, "gentle-agents");
192
231
  return { sessions: join(root, "sessions"), transcripts: join(root, "transcripts") };
@@ -550,6 +589,28 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
550
589
  scopeCache.clear();
551
590
  publishActivity();
552
591
  });
592
+ // The todo list is the session's progress: publish it for peers so the model
593
+ // spends no turn on orchestrator_session_id. Merged into the owner's curated
594
+ // state; a publication that cannot be made never fails the todo write.
595
+ // Counts only: a todo title is private work text, never published to peers.
596
+ // The last automatic line survives an owner publication that omits `progress`.
597
+ let automaticProgress: { sessionId: string; text: string } | undefined;
598
+ const unsubscribeTodoProgress = pi.events.on(TODO_PROGRESS_EVENT, (event) => {
599
+ const progress = event as Partial<TodoProgress> | undefined;
600
+ if (!sessions || progress?.sessionId !== sessions.getSessionId() || typeof progress.done !== "number" || typeof progress.total !== "number") return;
601
+ const text = `${progress.done}/${progress.total} done`;
602
+ try {
603
+ const owned = stateCache.get(sessions);
604
+ // A state the owner withdrew stays withdrawn until the owner publishes again.
605
+ if (owned?.state === null) return;
606
+ const { progress: previous, ...curated } = owned?.state ?? {};
607
+ // A cleared list has no progress to report: drop the line, publish nothing new.
608
+ if (progress.total === 0 && previous === undefined) return;
609
+ stateCache.publish(sessions, progress.total === 0 ? curated : { ...curated, progress: text }, (type, data) => pi.appendEntry(type, data));
610
+ automaticProgress = progress.total === 0 ? undefined : { sessionId: progress.sessionId, text };
611
+ publishActivity();
612
+ } catch { /* stale session or over the state budget: keep the previous publication */ }
613
+ });
553
614
  let worktrees: SessionWorktreeRegistry | undefined;
554
615
  let worktreeManager: ExtensionContext["sessionManager"] | undefined;
555
616
  let worktreeAuthority: (() => boolean) | undefined;
@@ -595,6 +656,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
595
656
  clearTaskMetrics();
596
657
  unsubscribeMetrics();
597
658
  unsubscribeScope();
659
+ unsubscribeTodoProgress();
598
660
  scopeCache.clear();
599
661
  });
600
662
  let stopAllConfirmation: Promise<void> | undefined;
@@ -1570,21 +1632,26 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1570
1632
  if (isGenericBoundedWriter(agent.name) && !current()) throw new Error("Writer session Git authority changed before admission.");
1571
1633
  let admittedModel: string | undefined;
1572
1634
  const surfaces = allowedEditSurfaces(prompt, context);
1573
- // gentle-shell#1064 slice 2: the binding is read once per task request,
1574
- // before admission, so the admitted model and the launch routing resolve
1575
- // the same session layer and can never disagree about it (#1558: a bound
1576
- // session used to kill its own non-git writer mid-preparation because
1577
- // admission still read only the pin/global layers).
1578
- const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId());
1635
+ // gentle-shell#1064 slice 2: the session profile is resolved once per task
1636
+ // request, before admission, so the admitted model and the launch routing
1637
+ // resolve the same session layer and can never disagree about it (#1558: a
1638
+ // bound session used to kill its own non-git writer mid-preparation because
1639
+ // admission still read only the pin/global layers). Slice 3b-ii: the same
1640
+ // read covers the frozen inherited profile and `follow` mode.
1641
+ const profileConfigHome = gentlePiConfigHome(deps.env);
1642
+ const sessionProfile = resolveSessionProfile({
1643
+ sessionId: ctx.sessionManager.getSessionId(),
1644
+ cwd: originalCwd,
1645
+ configHome: profileConfigHome,
1646
+ resolveWorktree: deps.resolveWorktree,
1647
+ env: deps.env,
1648
+ });
1579
1649
  if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
1580
1650
  const root = safeBootstrapDirectory(originalCwd);
1581
1651
  if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
1582
1652
  const config = withPinnedModelProfiles(
1583
1653
  loadAgentsConfig(roots(ctx)),
1584
- sessionOrPinModelProfiles(
1585
- sessionBinding?.modelProfiles,
1586
- resolveUnversionedProjectProfile(root, gentlePiConfigHome(deps.env))?.modelProfiles,
1587
- ),
1654
+ sessionProfileRoutingAt(sessionProfile, { cwd: root, configHome: profileConfigHome, resolveWorktree: deps.resolveWorktree }),
1588
1655
  );
1589
1656
  const model = resolveAgentProfile(agent, config).model ?? ctx.model;
1590
1657
  const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
@@ -1630,23 +1697,41 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1630
1697
  const pinIdentity: WorktreeResolver = foreign ? resolveSessionWorktree : target !== undefined && parentIdentity !== undefined && target === parentIdentity.root
1631
1698
  ? () => parentIdentity
1632
1699
  : deps.resolveWorktree;
1633
- // gentle-shell#1064 slice 1: a parent-session profile binding outranks the
1634
- // pin layers for launches from that session (`session → p → P → global`),
1635
- // with the same wholesale-replacement contract as the pin. The binding is
1700
+ // gentle-shell#1064: the session profile outranks the shared defaults for
1701
+ // launches from that session (`session → frozen p → P → G`), with the same
1702
+ // wholesale-replacement contract as the pin. Explicit and frozen profiles
1703
+ // belong to the session and apply to every target, a foreign repository
1704
+ // included; only `follow` reads the defaults of the target. The profile is
1636
1705
  // resolved here, at task-request creation, so queued and running children
1637
1706
  // keep the routing frozen into their requests even if the session rebinds.
1707
+ // The routing target this launch reads (#1064), reused to name the winning
1708
+ // layer (#2180) so both answers come from one resolution.
1709
+ const routingTarget = {
1710
+ cwd: target ?? parentCwd,
1711
+ configHome: profileConfigHome,
1712
+ resolveWorktree: pinIdentity,
1713
+ foreignRepository: foreign,
1714
+ };
1638
1715
  const config = withPinnedModelProfiles(
1639
1716
  loadAgentsConfig(roots(ctx)),
1640
- sessionOrPinModelProfiles(
1641
- sessionBinding?.modelProfiles,
1642
- resolveProfilePin({
1643
- cwd: target ?? parentCwd,
1644
- configHome: gentlePiConfigHome(deps.env),
1645
- resolveWorktree: pinIdentity,
1646
- })?.modelProfiles,
1647
- ),
1717
+ sessionProfileRoutingAt(sessionProfile, routingTarget),
1648
1718
  );
1719
+ // Drift is measured against the defaults of the session's own directory,
1720
+ // where the profile was frozen, never against a delegated target.
1721
+ if (ctx.hasUI) {
1722
+ const drift = inheritedProfileDriftNotice({
1723
+ sessionId: ctx.sessionManager.getSessionId(),
1724
+ cwd: parentCwd,
1725
+ configHome: profileConfigHome,
1726
+ resolveWorktree: deps.resolveWorktree,
1727
+ env: deps.env,
1728
+ });
1729
+ if (drift !== undefined) ctx.ui.notify(drift, "info");
1730
+ }
1649
1731
  const profile = resolveAgentProfile(agent, config);
1732
+ // #2180: the winning layer is named from the resolution and routing this
1733
+ // request already performed, never by re-resolving them.
1734
+ const modelSource = modelSourceToken(profile.source.model, sessionProfile, routingTarget);
1650
1735
  if (admittedModel !== undefined) {
1651
1736
  const model = profile.model ?? ctx.model;
1652
1737
  const catalogModel = model?.provider ? ctx.modelRegistry?.find(model.provider, model.id) : ctx.modelRegistry?.getAll().find(candidate => candidate.id === model?.id);
@@ -1687,6 +1772,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1687
1772
  } } : {}),
1688
1773
  ...(target === undefined || foreign ? {} : { onLaunch: () => { registry.register(target, "subagent:spawn"); } }),
1689
1774
  model: profile.model,
1775
+ ...(modelSource === undefined ? {} : { modelSource }),
1690
1776
  thinking: profile.thinking,
1691
1777
  sessionDir,
1692
1778
  resumeSessionPath: resume,
@@ -1826,7 +1912,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1826
1912
  pi.registerTool({
1827
1913
  name: "orchestrator_session_id",
1828
1914
  label: "Orchestrator session ID",
1829
- description: "Return this host session's stable routing ID, Initial alias (FIRST TASK), Current alias (CURRENT TASK), and separate human Session name. Explicit subjects update Current even when named; Initial stays the first declared branch topic, unknown for legacy history. Before delegation or cross-session coordination, declare a short recognizable subject here. Do not require a subject declaration for small direct tasks; do not query all peers. Names never authenticate. Existing Pi names and human renames are preserved. Use a concise non-sensitive label, not a prompt. For work beyond small direct tasks, publish subject and current/next status here; refresh at task changes/completion. Owner-curated state (2048 UTF-8 bytes total): null withdraws, omission leaves unchanged. Never include credentials, internal instructions, or raw prompts. Historical notes are not consent or an owner reply.",
1915
+ description: "Return this host session's stable routing ID, Initial alias (FIRST TASK), Current alias (CURRENT TASK), and separate human Session name. Explicit subjects update Current even when named; Initial stays the first declared branch topic, unknown for legacy history. Before delegation or cross-session coordination, declare a short recognizable subject here. Do not require a subject declaration for small direct tasks; do not query all peers. Names never authenticate. Existing Pi names and human renames are preserved. Use a concise non-sensitive label, not a prompt. Progress is published automatically from the todo list; publish state here only for objective, decisions, blockers, or work, never just to report progress. Owner-curated state (2048 UTF-8 bytes total): null withdraws, omission leaves unchanged. Never include credentials, internal instructions, or raw prompts. Historical notes are not consent or an owner reply.",
1830
1916
  ...orchestratorToolRenderers("session", expandHint),
1831
1917
  parameters: { type: "object", additionalProperties: false, properties: {
1832
1918
  subject: { type: "string", maxLength: 120, description: "Optional short task subject; updates task aliases independently of human names. Also names an unnamed Pi session." },
@@ -1848,7 +1934,20 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1848
1934
  if (!transport) return text("Error: session messaging is not ready.", { error: "not ready" });
1849
1935
  const { subject, state } = params as { subject?: unknown; state?: unknown };
1850
1936
  const declared = normalizeTaskSubject(subject);
1851
- if (state !== undefined || declared) stateCache.publish(ctx.sessionManager, state, (type, data) => pi.appendEntry(type, data), Date.now(), declared);
1937
+ // An owner object without `progress` keeps the automatic todo progress still on
1938
+ // display; null and an explicit `progress` win, and an over-budget merge does not fail the call.
1939
+ const shown = stateCache.get(ctx.sessionManager)?.state?.progress;
1940
+ const merged = state && typeof state === "object" && !Array.isArray(state) && !Object.hasOwn(state, "progress")
1941
+ && automaticProgress?.sessionId === ctx.sessionManager.getSessionId() && shown === automaticProgress.text
1942
+ ? { ...state, progress: shown } : undefined;
1943
+ const append = (type: string, data: unknown) => pi.appendEntry(type, data);
1944
+ if (merged) {
1945
+ try { stateCache.publish(ctx.sessionManager, merged, append, Date.now(), declared); }
1946
+ catch (error) {
1947
+ if ((error as Error).message !== "invalid-published-state") throw error;
1948
+ stateCache.publish(ctx.sessionManager, state, append, Date.now(), declared);
1949
+ }
1950
+ } else if (state !== undefined || declared) stateCache.publish(ctx.sessionManager, state, append, Date.now(), declared);
1852
1951
  if (activeTransportFor(ctx) !== transport) throw new Error("stale-published-state");
1853
1952
  if (declared && !ctx.sessionManager.getSessionName?.()) pi.setSessionName(declared);
1854
1953
  const alias = sanitizeDisplayLabel(ctx.sessionManager.getSessionName?.() ?? "");
@@ -1961,13 +2060,17 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1961
2060
  ? `repository: ${fact.root} · clone: ${fact.cloneHash} · Git resolved at: ${fact.resolvedAt} (${fact.source})`
1962
2061
  : "repository: unknown";
1963
2062
  const rows = peers.map(peer => {
1964
- const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : " · context: unknown";
2063
+ const context = peer.freshness === "recent" ? ` · ${peer.aliases?.currentAlias || peer.label || "unnamed"} · Session name: ${peer.label || "unnamed"} · Current alias (CURRENT TASK): ${peer.aliases?.currentAlias ?? "unknown"} · Initial alias (FIRST TASK): ${peer.aliases?.initialAlias ?? "unknown"} · recorded workspace: ${peer.workspace || "unknown"}` : peer.legacyLabel !== undefined
2064
+ ? ` · ${peer.legacyLabel || "unnamed"} · context: legacy (this session predates discovery publication; restart it to publish its workspace and repository)`
2065
+ : " · context: unknown";
1965
2066
  const tasks = peer.tasks?.map(task => `\n - ${task.label || task.id} [${task.status}] · launch workspace: ${task.workspace || "unknown"} · ${repository(peer.scope?.tasks.find(t => t.id === task.id)?.repository)}`).join("") ?? "";
1966
2067
  const registered = peer.scope?.registered.map(fact => `\n registered: ${repository(fact)}`).join("") ?? "";
1967
2068
  const gaps = peer.scope && !peer.scope.complete ? `\n (scope incomplete: ${peer.scope.omittedTasks} tasks, ${peer.scope.omittedRegistered} registered roots omitted)` : "";
1968
2069
  const catalog = peer.catalog ? `\n recorded catalog (not Git identity): ${JSON.stringify(peer.catalog)}` : "\n recorded catalog: unknown";
1969
2070
  const note = params.recipient_session_id ? `\n owner-curated recorded state (not consent or owner reply; authority none): ${peer.state ? JSON.stringify(peer.state) : "unknown"}` : "";
1970
- return `- ${peer.sessionId}${context} · ${repository(peer.scope?.host)} · metadata: ${peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
2071
+ // A legacy peer never publishes its scope: say so instead of reporting an unknown.
2072
+ const legacy = peer.legacyLabel !== undefined;
2073
+ return `- ${peer.sessionId}${context} · ${legacy ? "repository: not published" : repository(peer.scope?.host)} · metadata: ${legacy ? "legacy" : peer.freshness}${tasks}${registered}${gaps}${catalog}${note}${peer.omitted ? `\n (${peer.omitted} more tasks omitted)` : ""}`;
1971
2074
  });
1972
2075
  return peers.length === 0 ? text("No other sessions are currently advertised. Advertisements have unknown reachability and do not guarantee a live session.") : text(`Advertised sessions (reachability is unknown):\n${rows.join("\n")}`, { gentleAgents: { candidates: peers } });
1973
2076
  } catch {
@@ -2243,6 +2346,21 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
2243
2346
  publishActivity();
2244
2347
  });
2245
2348
  pi.on("session_start", async (event, ctx) => {
2349
+ // gentle-shell#1064 slice 3b-ii: a parent session that has no explicit
2350
+ // profile binding freezes `p → P → G` now. This handler is parent-only (a
2351
+ // child returns before registering it) and runs for every reason Pi
2352
+ // reports, including `startup` for --resume/--continue/--session. The
2353
+ // freeze is in-memory and idempotent per session id: /reload keeps it, a
2354
+ // new process freezes again until slice 3b-i persists it as an entry.
2355
+ try {
2356
+ freezeSessionProfileAtStartup({
2357
+ sessionId: ctx.sessionManager.getSessionId(),
2358
+ cwd: ctx.sessionManager.getCwd(),
2359
+ configHome: gentlePiConfigHome(deps.env),
2360
+ resolveWorktree: deps.resolveWorktree,
2361
+ env: deps.env,
2362
+ });
2363
+ } catch { /* A launch freezes lazily through the same resolver. */ }
2246
2364
  stateCache.load(ctx.sessionManager);
2247
2365
  // A resumed, reloaded, or replaced session starts with an empty completion
2248
2366
  // queue so nothing pending from another session can replay here.