@tekmidian/pai 1.0.0 → 1.0.3

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 (219) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +67 -5
  3. package/dist/aibroker-client-BxzeEHPF.mjs +3 -0
  4. package/dist/{aibroker-client-CXEYBzbf.mjs → aibroker-client-CQPGqLxL.mjs} +1 -1
  5. package/dist/atomic-file-BorMa4T3.mjs +21 -0
  6. package/dist/{auto-route-D5SXrMpB.mjs → auto-route-B7Yu6Uam.mjs} +3 -3
  7. package/dist/auto-route-CjjQqU1o.mjs +11 -0
  8. package/dist/budget-Dod6rnA9.mjs +15 -0
  9. package/dist/{caller-identity-CVTuiLSs.mjs → caller-identity-yLFb7CKq.mjs} +6 -5
  10. package/dist/{capacity-DaXrVj3J.mjs → capacity-DEveQdFV.mjs} +3 -3
  11. package/dist/{chain-BcQlIlEd.mjs → chain-7n9Gx56r.mjs} +5 -5
  12. package/dist/cli/index.mjs +1628 -370
  13. package/dist/{cockpit-actions-ODmt8yF9.mjs → cockpit-actions-BBHeyEZI.mjs} +84 -42
  14. package/dist/cockpit-actions-DR9X514g.mjs +20 -0
  15. package/dist/cockpit-memory--dnShXBA.mjs +20 -0
  16. package/dist/{config-BSXKyeJ1.mjs → config-Bu1_vbY8.mjs} +42 -3
  17. package/dist/{config-B955yYmo.mjs → config-Cxi-aBf4.mjs} +2 -2
  18. package/dist/{config-TaWmdTLx.mjs → config-DtENJ6cL.mjs} +1 -1
  19. package/dist/{config-D5chi2LO.mjs → config-XpZWOYOG.mjs} +2 -2
  20. package/dist/{controls-BGkK_Bdi.mjs → controls-CPoeudou.mjs} +37 -22
  21. package/dist/daemon/index.mjs +43 -42
  22. package/dist/daemon-DKdBNKfa.mjs +45 -0
  23. package/dist/{daemon-CorGh6Ub.mjs → daemon-DPAo6WTa.mjs} +103 -55
  24. package/dist/daemon-mcp/index.mjs +40 -39
  25. package/dist/{define-DGFu-sCf.mjs → define-CjJsmq-6.mjs} +1 -1
  26. package/dist/{detect-C1d5cUkT.mjs → detect-9CZNO0yf.mjs} +2 -2
  27. package/dist/detect-D1ziFFfA.mjs +10 -0
  28. package/dist/{embed-pass-CeQD8kRE.mjs → embed-pass-DIWjA68P.mjs} +5 -5
  29. package/dist/{embedding-gate-BHuBsgHD.mjs → embedding-gate-6rZrWRXa.mjs} +5 -5
  30. package/dist/{embedding-gate-Z7CJSOJk.mjs → embedding-gate-Cnk4YCyX.mjs} +3 -3
  31. package/dist/{embeddings-D4t6L-EH.mjs → embeddings-BVq3esBF.mjs} +1 -1
  32. package/dist/{embeddings-OF9Pt4by.mjs → embeddings-CIkJBKd-.mjs} +1 -1
  33. package/dist/{endreason-x1KOSrcO.mjs → endreason-C577xIQz.mjs} +7 -6
  34. package/dist/{env-DArqGq-Z.mjs → env-BE8mrsxr.mjs} +1 -1
  35. package/dist/factory-Bsib-qDG.mjs +9 -0
  36. package/dist/{factory-BtjbQ0np.mjs → factory-DjAAsqr_.mjs} +15 -13
  37. package/dist/{fallback-BIyEpPsQ.mjs → fallback-DFlItw1Z.mjs} +5 -5
  38. package/dist/{fmt-age-BiSHrTKN.mjs → fmt-age-ByDkvkoU.mjs} +1 -1
  39. package/dist/{health-C6jZWvRG.mjs → health-Dnym-qcT.mjs} +4 -4
  40. package/dist/{helpers-DFmYRCOa.mjs → helpers-BD6V-p9w.mjs} +2 -2
  41. package/dist/helpers-CMGjy_0N.mjs +10 -0
  42. package/dist/{helpers-CJpb_MNB.mjs → helpers-CRuBUas3.mjs} +87 -38
  43. package/dist/hooks/capture-all-events.mjs +52 -11
  44. package/dist/hooks/capture-session-summary.mjs +64 -47
  45. package/dist/hooks/capture-tool-output.mjs +49 -8
  46. package/dist/hooks/context-compression-hook.mjs +30 -10
  47. package/dist/hooks/initialize-session.mjs +1 -0
  48. package/dist/hooks/load-core-context.mjs +1 -1
  49. package/dist/hooks/load-project-context.mjs +32 -29
  50. package/dist/hooks/security-validator.mjs +45 -13
  51. package/dist/hooks/stop-hook.mjs +54 -16
  52. package/dist/hooks/supervision-banner.mjs +9 -0
  53. package/dist/hooks/sync-todo-to-md.mjs +147 -113
  54. package/dist/hooks/worker-edit-repair.mjs +2 -1
  55. package/dist/hooks/worker-guard.mjs +40 -16
  56. package/dist/hooks/worker-note.mjs +12 -5
  57. package/dist/hooks/worker-path-repair.mjs +1 -1
  58. package/dist/hooks/worker-pool-host.mjs +329 -231
  59. package/dist/hooks/worker-status-line.mjs +195 -114
  60. package/dist/index.d.mts +18 -0
  61. package/dist/index.mjs +12 -12
  62. package/dist/{indexer-backend-BM2CUSu8.mjs → indexer-backend-Cll7le4P.mjs} +149 -96
  63. package/dist/{ipc-client-BMqWw_gv.mjs → ipc-client-CaiGJnSM.mjs} +1 -1
  64. package/dist/{ipc-client-YYDD6zzU.mjs → ipc-client-ZRmDKd5Z.mjs} +1 -1
  65. package/dist/kill-tree-BG7dRVtp.mjs +10 -0
  66. package/dist/{kill-tree-BjYz5BxK.mjs → kill-tree-EWYaEk5v.mjs} +8 -8
  67. package/dist/{launch-CLp0OJxV.mjs → launch-CXVlZjHt.mjs} +8 -8
  68. package/dist/{launch-DgqQdAjI.mjs → launch-Jh5NVeNe.mjs} +3 -3
  69. package/dist/local-candidate-DLdwQt0s.mjs +112 -0
  70. package/dist/local-instances-DOxSgT_L.mjs +210 -0
  71. package/dist/local-instances-ZeRkkuS5.mjs +6 -0
  72. package/dist/{local-tiers-DPZYqv9m.mjs → local-tiers-BE4jGaMa.mjs} +2 -2
  73. package/dist/{local-tiers-DnBIAkbK.mjs → local-tiers-DNMI5W86.mjs} +3 -3
  74. package/dist/{main-config-ops-C0SflOWk.mjs → main-config-ops-BEMpuhDz.mjs} +3 -3
  75. package/dist/{main-resolver-DVPpSWVy.mjs → main-resolver-3Ns1HPmS.mjs} +18 -17
  76. package/dist/{migrate-BUJHadA8.mjs → migrate-DrA_OwlZ.mjs} +45 -14
  77. package/dist/min-max-DrSRUaWQ.mjs +16 -0
  78. package/dist/{model-inventory-Dasber3S.mjs → model-inventory-BFVSNFT3.mjs} +13 -12
  79. package/dist/{models-add-Bt7aUHDs.mjs → models-add-C1QIc1DA.mjs} +39 -38
  80. package/dist/operator-BTqvTIc8.mjs +7 -0
  81. package/dist/{operator-DZxcP-g8.mjs → operator-CyuB3aVw.mjs} +3 -3
  82. package/dist/{pai-marker-kIYuNt3c.mjs → pai-marker-BcZyJguM.mjs} +7 -6
  83. package/dist/{pane-audit-OGYrOAik.mjs → pane-audit-DKQt71s4.mjs} +7 -7
  84. package/dist/{paths-DoRuFc60.mjs → paths-CFHTloSe.mjs} +3 -3
  85. package/dist/{paths-DoIyRnhx.mjs → paths-CHifubDh.mjs} +3 -3
  86. package/dist/{planner-DLGOYlVb.mjs → planner-BmFACdLa.mjs} +31 -30
  87. package/dist/{pool-CZ5MZaXg.mjs → pool-Dh-q3iJi.mjs} +45 -6
  88. package/dist/{pool-cli-BxNSPzzY.mjs → pool-cli-BggU8GRp.mjs} +5 -5
  89. package/dist/{pool-cli-CbJfl37y.mjs → pool-cli-DQ6OzRy2.mjs} +6 -6
  90. package/dist/postgres-BdBNkL_l.mjs +7 -0
  91. package/dist/{postgres-CDnd_k5f.mjs → postgres-C6ti4haA.mjs} +55 -34
  92. package/dist/{project-setup-DM4npnL3.mjs → project-setup-jvfONJTQ.mjs} +131 -74
  93. package/dist/providers-BeDiVEb-.mjs +16 -0
  94. package/dist/{providers-UsKtLcUR.mjs → providers-CblTFrhz.mjs} +6 -6
  95. package/dist/{query-feedback-I-txSnld.mjs → query-feedback-BRfPtqki.mjs} +2 -2
  96. package/dist/registrable-BzH4TObg.mjs +9 -0
  97. package/dist/{registrable-LrRIVYQc.mjs → registrable-DpyTtajg.mjs} +4 -4
  98. package/dist/{render-BDbb4CF-.mjs → render-BYmQkuLd.mjs} +8 -8
  99. package/dist/render-CoMvwMPs.mjs +13 -0
  100. package/dist/{reranker-B4r4vykM.mjs → reranker-8g6xMwP7.mjs} +1 -1
  101. package/dist/{reranker-DxxMCtA0.mjs → reranker-CqCbpGnX.mjs} +1 -1
  102. package/dist/root-FtJsxYnm.mjs +36 -0
  103. package/dist/{root-BMeJuHOb.mjs → root-Xglu0OPD.mjs} +11 -11
  104. package/dist/route-table-B7zpoypi.mjs +15 -0
  105. package/dist/{router-L8Nu-OHF.mjs → router-CQSc3lk3.mjs} +17 -7
  106. package/dist/{router-D6Ec6Fid.mjs → router-DwHJRgG9.mjs} +1 -1
  107. package/dist/{routing-CUyiglfu.mjs → routing-BAAST9vT.mjs} +27 -308
  108. package/dist/{routing-ZuGlc0ge.mjs → routing-CENt4HH1.mjs} +13 -12
  109. package/dist/{run-ulAi5dFD.mjs → run-CSC7mgPf.mjs} +35 -33
  110. package/dist/{scan-B7I0gPe-.mjs → scan-C_Y5W9py.mjs} +6 -6
  111. package/dist/{local-candidate-jOjTNlOL.mjs → scheduler-CtUyWJ3O.mjs} +7 -109
  112. package/dist/scheduler-dmHXoRvm.mjs +3 -0
  113. package/dist/{scope-D5VSQxe4.mjs → scope-D-h6F6DN.mjs} +4 -4
  114. package/dist/{search-CPoE0830.mjs → search-RSyeQqj1.mjs} +18 -18
  115. package/dist/{session-CNXF6DLP.mjs → session-8iqQDkqb.mjs} +55 -31
  116. package/dist/session-BeqAbNPd.mjs +35 -0
  117. package/dist/session-scan-BJI_ZO8M.mjs +9 -0
  118. package/dist/{session-scan-BDLEc4w1.mjs → session-scan-W-gbwJHM.mjs} +6 -6
  119. package/dist/{sources-CjhUKKtO.mjs → sources-ESUkZaWv.mjs} +1 -1
  120. package/dist/{status-DYydKQUI.mjs → status-Dm4dYciN.mjs} +4 -4
  121. package/dist/{status-CvVi9cDf.mjs → status-ggJ7lgVg.mjs} +17 -9
  122. package/dist/{stop-words-SdSL1WKH.mjs → stop-words-DSEYQdNe.mjs} +1 -1
  123. package/dist/{test-isolation-CoDeOVXb.mjs → test-isolation-lqSmYxr0.mjs} +1 -1
  124. package/dist/{timeouts-OwEONmc8.mjs → timeouts-1MLooEBB.mjs} +1 -1
  125. package/dist/{tool-errors-AeRQuG3Y.mjs → tool-errors-7E13StWd.mjs} +4 -3
  126. package/dist/{tool-errors-R5E0fCgf.mjs → tool-errors-CJWODvlo.mjs} +6 -6
  127. package/dist/viewer-CqozWW39.mjs +22 -0
  128. package/dist/{viewer-DtWj5Vti.mjs → viewer-mDXUNHNy.mjs} +15 -15
  129. package/dist/{wait-BMUR-U-U.mjs → wait-ByV0OyMZ.mjs} +3 -3
  130. package/dist/{worker-config-BLqa1yiD.mjs → worker-config-CJhEMk19.mjs} +294 -92
  131. package/dist/worker-config-DESkIjMF.mjs +41 -0
  132. package/dist/{workers-config-DEI9lrV4.mjs → workers-config-f8KrkY4L.mjs} +2 -2
  133. package/dist/{worktree-zmRf556h.mjs → worktree-CRs2gyg0.mjs} +37 -7
  134. package/dist/{zettelkasten-nigq7fqs.mjs → zettelkasten-B_rRRRvD.mjs} +5 -5
  135. package/dist/{zettelkasten-Cr_Xj0mG.mjs → zettelkasten-BzMm8REH.mjs} +29 -19
  136. package/docs/README.md +2 -0
  137. package/docs/cockpit.md +10 -4
  138. package/docs/commands/README.md +4 -4
  139. package/docs/commands/backup.md +2 -0
  140. package/docs/commands/install.md +29 -4
  141. package/docs/commands/providers.md +1 -1
  142. package/docs/commands/restore.md +4 -0
  143. package/docs/commands/setup.md +29 -4
  144. package/docs/commands/worker.md +1 -1
  145. package/docs/config.md +2 -0
  146. package/docs/features.md +299 -0
  147. package/docs/images/architecture.drawio +51 -0
  148. package/docs/images/architecture.png +0 -0
  149. package/docs/images/architecture.svg +3 -0
  150. package/docs/images/cockpit-01-system-tokens.jpg +0 -0
  151. package/docs/images/cockpit-02-totals-cache.jpg +0 -0
  152. package/docs/images/cockpit-03-local-servers-models-pool.jpg +0 -0
  153. package/docs/images/cockpit-04-workers-memory.jpg +0 -0
  154. package/docs/images/cockpit-05-performance.jpg +0 -0
  155. package/docs/images/cockpit-06-runs-speed-routing.jpg +0 -0
  156. package/docs/images/cockpit-07-settings-registry.jpg +0 -0
  157. package/docs/install/index.md +29 -0
  158. package/docs/three-tier-coding.md +1 -1
  159. package/gemini-extension.json +1 -1
  160. package/package.json +1 -5
  161. package/pai-plugin.json +1 -1
  162. package/plugins/context-preservation/plugin.json +1 -1
  163. package/plugins/core/plugin.json +1 -1
  164. package/plugins/core/skills/setup/SKILL.md +41 -9
  165. package/plugins/creative/plugin.json +1 -1
  166. package/plugins/observability/plugin.json +1 -1
  167. package/plugins/productivity/plugin.json +1 -1
  168. package/plugins/semantic-search/plugin.json +1 -1
  169. package/plugins/ui/plugin.json +1 -1
  170. package/plugins/zettelkasten/plugin.json +1 -1
  171. package/src/hooks/session-autosave.sh +8 -1
  172. package/src/hooks/session-stop.sh +8 -1
  173. package/src/hooks/ts/capture-all-events.ts +7 -12
  174. package/src/hooks/ts/lib/context-fill.ts +2 -1
  175. package/src/hooks/ts/lib/path-repair.ts +1 -1
  176. package/src/hooks/ts/lib/project-utils/notify.ts +1 -0
  177. package/src/hooks/ts/lib/project-utils/session-notes.ts +23 -6
  178. package/src/hooks/ts/lib/raw-capture.ts +36 -0
  179. package/src/hooks/ts/lib/session-summary.ts +129 -0
  180. package/src/hooks/ts/lib/todo-sync.ts +120 -0
  181. package/src/hooks/ts/lib/worker-git-guard.ts +1 -1
  182. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +6 -10
  183. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +5 -105
  184. package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
  185. package/src/hooks/ts/pre-tool-use/security-validator.ts +35 -14
  186. package/src/hooks/ts/session-end/capture-session-summary.ts +20 -128
  187. package/src/hooks/ts/session-start/load-core-context.ts +3 -3
  188. package/src/hooks/ts/session-start/load-project-context.ts +18 -22
  189. package/src/hooks/ts/stop/stop-hook.ts +13 -5
  190. package/src/hooks/ztk-auto.sh +7 -2
  191. package/dist/aibroker-client-JjE74jo0.mjs +0 -3
  192. package/dist/auto-route-BRk8NEFv.mjs +0 -11
  193. package/dist/budget-DuhQ2zzG.mjs +0 -14
  194. package/dist/cockpit-actions-DcQ-ucU8.mjs +0 -19
  195. package/dist/cockpit-memory-KV45PPO2.mjs +0 -19
  196. package/dist/daemon-C7yt_ptC.mjs +0 -44
  197. package/dist/detect-DRUkmHnb.mjs +0 -10
  198. package/dist/factory-DAGG9_n8.mjs +0 -9
  199. package/dist/helpers-DPJEHPq6.mjs +0 -10
  200. package/dist/kill-tree-LV0Q7Mte.mjs +0 -10
  201. package/dist/operator-CfL0HiBK.mjs +0 -7
  202. package/dist/postgres-DgqWyqhN.mjs +0 -7
  203. package/dist/providers-CTu2s1z2.mjs +0 -15
  204. package/dist/registrable-D9aNZOTh.mjs +0 -9
  205. package/dist/render-BSfVlYHj.mjs +0 -13
  206. package/dist/root-xI9DqVIe.mjs +0 -35
  207. package/dist/route-table-HjvnLVEU.mjs +0 -14
  208. package/dist/session-BJHzmUg2.mjs +0 -34
  209. package/dist/session-scan-YORKE1NO.mjs +0 -9
  210. package/dist/viewer-B8709jDN.mjs +0 -22
  211. package/dist/worker-config-D8tRuvo7.mjs +0 -40
  212. package/docs/images/cockpit-limits-routing.jpg +0 -0
  213. package/docs/images/cockpit-models.jpg +0 -0
  214. package/docs/images/cockpit-usage.jpg +0 -0
  215. package/docs/images/cockpit-workers-system.jpg +0 -0
  216. package/scripts/build-docs.mjs +0 -41
  217. package/scripts/build-hooks.mjs +0 -311
  218. package/scripts/build-skill-stubs.mjs +0 -216
  219. package/scripts/copy-defaults.mjs +0 -46
@@ -0,0 +1,120 @@
1
+ /**
2
+ * TODO.md section handling for the TodoWrite sync hook.
3
+ *
4
+ * Only the "## Current Session" section is managed; every other byte of the
5
+ * file (preamble, other sections, blank lines between them) is kept as is.
6
+ */
7
+
8
+ import { writeFileSync, renameSync, unlinkSync } from 'fs';
9
+ import { dirname, join, basename } from 'path';
10
+
11
+ export interface TodoItem {
12
+ content: string;
13
+ status: 'pending' | 'in_progress' | 'completed';
14
+ activeForm: string;
15
+ }
16
+
17
+ const MANAGED_HEADING = /^## Current Session[ \t]*$/;
18
+ const FOOTER = /(\n---[ \t\r\n]*)?(\n\*Last updated:[^\n]*\*[ \t\r\n]*)?$/;
19
+
20
+ /** Format current session todos as markdown. */
21
+ export function formatSessionTodos(todos: TodoItem[]): string {
22
+ const inProgress = todos.filter(t => t.status === 'in_progress');
23
+ const pending = todos.filter(t => t.status === 'pending');
24
+ const completed = todos.filter(t => t.status === 'completed');
25
+
26
+ let content = '';
27
+
28
+ if (inProgress.length > 0) {
29
+ content += `### In Progress\n\n`;
30
+ for (const todo of inProgress) {
31
+ content += `- [ ] **${todo.content}** _(${todo.activeForm})_\n`;
32
+ }
33
+ content += '\n';
34
+ }
35
+
36
+ if (pending.length > 0) {
37
+ content += `### Pending\n\n`;
38
+ for (const todo of pending) {
39
+ content += `- [ ] ${todo.content}\n`;
40
+ }
41
+ content += '\n';
42
+ }
43
+
44
+ if (completed.length > 0) {
45
+ content += `### Completed\n\n`;
46
+ for (const todo of completed) {
47
+ content += `- [x] ${todo.content}\n`;
48
+ }
49
+ content += '\n';
50
+ }
51
+
52
+ if (todos.length === 0) {
53
+ content += `_(No active session tasks)_\n\n`;
54
+ }
55
+
56
+ return content;
57
+ }
58
+
59
+ /**
60
+ * Split into a preamble (text before the first "## " heading, possibly empty)
61
+ * and sections, each starting at its "## " line. Concatenating them
62
+ * reproduces the input exactly.
63
+ */
64
+ function splitSections(content: string): { preamble: string; sections: string[] } {
65
+ const starts: number[] = [];
66
+ let offset = 0;
67
+ for (const line of content.split('\n')) {
68
+ if (line.startsWith('## ')) starts.push(offset);
69
+ offset += line.length + 1;
70
+ }
71
+ if (starts.length === 0) return { preamble: content, sections: [] };
72
+ const sections = starts.map((s, i) => content.slice(s, starts[i + 1] ?? content.length));
73
+ return { preamble: content.slice(0, starts[0]), sections };
74
+ }
75
+
76
+ /** Build the new TODO.md, replacing only the Current Session section. */
77
+ export function buildTodoContent(todos: TodoItem[], existingContent: string, now = new Date().toISOString()): string {
78
+ const body = existingContent.replace(FOOTER, '');
79
+ const managed = `## Current Session\n\n${formatSessionTodos(todos)}`;
80
+ const { preamble, sections } = splitSections(body);
81
+
82
+ let out: string;
83
+ const idx = sections.findIndex(s => MANAGED_HEADING.test(s.split('\n', 1)[0]));
84
+ if (idx >= 0) {
85
+ // Keep the separator that followed the old managed section.
86
+ const old = sections[idx];
87
+ const trailing = old.slice(old.trimEnd().length);
88
+ const replaced = sections.slice();
89
+ replaced[idx] = managed.trimEnd() + (idx < sections.length - 1 ? (trailing.length > 1 ? trailing : '\n\n') : '\n');
90
+ out = preamble + replaced.join('');
91
+ } else if (body.trim() === '') {
92
+ out = `# TODO\n\n${managed}`;
93
+ } else {
94
+ // No managed section yet: put it first, after the title if there is one.
95
+ const titleEnd = /^# [^\n]*\n\n?/.exec(preamble);
96
+ const head = titleEnd ? preamble.slice(0, titleEnd[0].length) : '';
97
+ const rest = preamble.slice(head.length);
98
+ out = head + managed + rest + sections.join('');
99
+ }
100
+
101
+ if (!out.endsWith('\n')) out += '\n';
102
+ return `${out}\n---\n\n*Last updated: ${now}*\n`;
103
+ }
104
+
105
+ /** Fix malformed headings: Remove --- prefix from headings (---# → #). */
106
+ export function fixMalformedHeadings(content: string): string {
107
+ return content.replace(/^---#/gm, '#');
108
+ }
109
+
110
+ /** Write via temp file + rename so a crash never leaves a truncated file. */
111
+ export function writeFileAtomic(path: string, data: string): void {
112
+ const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
113
+ try {
114
+ writeFileSync(tmp, data);
115
+ renameSync(tmp, path);
116
+ } catch (e) {
117
+ try { unlinkSync(tmp); } catch { /* ignore */ }
118
+ throw e;
119
+ }
120
+ }
@@ -95,7 +95,7 @@ const GIT_WRITE = /\bgit\b[^&;|\n]*?\s(?:commit|add|am|cherry-pick|apply|push|ta
95
95
  const UNKNOWN_DIR = "/\0unresolved";
96
96
 
97
97
  /** Splits on &&, ||, ;, | and newlines outside quotes. */
98
- function splitSegments(command: string): string[] {
98
+ export function splitSegments(command: string): string[] {
99
99
  const out: string[] = [];
100
100
  let cur = "";
101
101
  let quote = "";
@@ -7,15 +7,15 @@
7
7
  * for later processing and analysis.
8
8
  */
9
9
 
10
- import { appendFileSync, mkdirSync, existsSync } from 'fs';
11
10
  import { join } from 'path';
12
11
  import { historyDir } from '../lib/pai-paths.js';
12
+ import { appendRawCapture, redactDeep } from '../lib/raw-capture.js';
13
13
 
14
14
  interface ToolUseData {
15
15
  tool_name: string;
16
16
  tool_input: Record<string, any>;
17
17
  tool_response: Record<string, any>;
18
- conversation_id: string;
18
+ session_id: string;
19
19
  timestamp: string;
20
20
  }
21
21
 
@@ -47,24 +47,20 @@ async function main() {
47
47
  const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
48
48
  const yearMonth = today.substring(0, 7); // YYYY-MM
49
49
 
50
- // Ensure capture directory exists
51
50
  const dateDir = join(CAPTURE_DIR, yearMonth);
52
- if (!existsSync(dateDir)) {
53
- mkdirSync(dateDir, { recursive: true });
54
- }
55
51
 
56
52
  // Format output as JSONL (one JSON object per line)
57
53
  const captureFile = join(dateDir, `${today}_tool-outputs.jsonl`);
58
54
  const captureEntry = JSON.stringify({
59
55
  timestamp: data.timestamp || now.toISOString(),
60
56
  tool: data.tool_name,
61
- input: data.tool_input,
62
- output: data.tool_response,
63
- session: data.conversation_id
57
+ input: redactDeep(data.tool_input),
58
+ output: redactDeep(data.tool_response),
59
+ session: data.session_id
64
60
  }) + '\n';
65
61
 
66
62
  // Append to daily log
67
- appendFileSync(captureFile, captureEntry);
63
+ appendRawCapture(captureFile, dateDir, captureEntry);
68
64
 
69
65
  // Exit successfully (code 0 = continue normally)
70
66
  process.exit(0);
@@ -11,8 +11,9 @@
11
11
  */
12
12
 
13
13
  import { isWorkerSession } from "../lib/worker-session.js";
14
- import { writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
15
- import { join } from 'path';
14
+ import { existsSync, readFileSync, mkdirSync } from 'fs';
15
+ import { dirname } from 'path';
16
+ import { buildTodoContent, fixMalformedHeadings, writeFileAtomic, type TodoItem } from '../lib/todo-sync.js';
16
17
  import {
17
18
  findTodoPath,
18
19
  findNotesDir,
@@ -21,12 +22,6 @@ import {
21
22
  type WorkItem
22
23
  } from '../lib/project-utils.js';
23
24
 
24
- interface TodoItem {
25
- content: string;
26
- status: 'pending' | 'in_progress' | 'completed';
27
- activeForm: string;
28
- }
29
-
30
25
  interface HookInput {
31
26
  session_id: string;
32
27
  cwd: string;
@@ -36,101 +31,6 @@ interface HookInput {
36
31
  };
37
32
  }
38
33
 
39
- /**
40
- * Format current session todos as markdown
41
- */
42
- function formatSessionTodos(todos: TodoItem[]): string {
43
- const inProgress = todos.filter(t => t.status === 'in_progress');
44
- const pending = todos.filter(t => t.status === 'pending');
45
- const completed = todos.filter(t => t.status === 'completed');
46
-
47
- let content = '';
48
-
49
- if (inProgress.length > 0) {
50
- content += `### In Progress\n\n`;
51
- for (const todo of inProgress) {
52
- content += `- [ ] **${todo.content}** _(${todo.activeForm})_\n`;
53
- }
54
- content += '\n';
55
- }
56
-
57
- if (pending.length > 0) {
58
- content += `### Pending\n\n`;
59
- for (const todo of pending) {
60
- content += `- [ ] ${todo.content}\n`;
61
- }
62
- content += '\n';
63
- }
64
-
65
- if (completed.length > 0) {
66
- content += `### Completed\n\n`;
67
- for (const todo of completed) {
68
- content += `- [x] ${todo.content}\n`;
69
- }
70
- content += '\n';
71
- }
72
-
73
- if (todos.length === 0) {
74
- content += `_(No active session tasks)_\n\n`;
75
- }
76
-
77
- return content;
78
- }
79
-
80
- /**
81
- * Extract all sections from TODO.md EXCEPT "Current Session"
82
- * These are user-managed sections that should be preserved.
83
- */
84
- function extractPreservedSections(content: string): string {
85
- let preserved = '';
86
-
87
- // Match all ## sections that are NOT "Current Session"
88
- const sectionRegex = /\n(## (?!Current Session)[^\n]+[\s\S]*?)(?=\n## |\n---\n+\*Last updated|$)/g;
89
- const matches = content.matchAll(sectionRegex);
90
-
91
- for (const match of matches) {
92
- preserved += match[1];
93
- }
94
-
95
- return preserved;
96
- }
97
-
98
- /**
99
- * Fix malformed headings: Remove --- prefix from headings (---# → #)
100
- * Claude sometimes incorrectly merges horizontal rules with headings.
101
- */
102
- function fixMalformedHeadings(content: string): string {
103
- return content.replace(/^---#/gm, '#');
104
- }
105
-
106
- /**
107
- * Build new TODO.md preserving user sections
108
- */
109
- function buildTodoContent(todos: TodoItem[], existingContent: string): string {
110
- const now = new Date().toISOString();
111
-
112
- // Get all preserved sections (everything except Current Session)
113
- const preserved = extractPreservedSections(existingContent);
114
-
115
- // Build new content
116
- let content = `# TODO
117
-
118
- ## Current Session
119
-
120
- ${formatSessionTodos(todos)}`;
121
-
122
- // Add preserved sections
123
- if (preserved.trim()) {
124
- content += preserved;
125
- }
126
-
127
- // Ensure we end with exactly one timestamp
128
- content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)*$/, '');
129
- content += `\n---\n\n*Last updated: ${now}*\n`;
130
-
131
- return content;
132
- }
133
-
134
34
  async function main() {
135
35
  if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping
136
36
  try {
@@ -165,7 +65,7 @@ async function main() {
165
65
 
166
66
  // Create TODO.md if it doesn't exist
167
67
  if (!existsSync(todoPath)) {
168
- const parentDir = todoPath.replace(/\/[^/]+$/, '');
68
+ const parentDir = dirname(todoPath);
169
69
  mkdirSync(parentDir, { recursive: true });
170
70
  console.error(`Creating TODO.md at ${todoPath}`);
171
71
  }
@@ -181,7 +81,7 @@ async function main() {
181
81
  // Build and write new content (with heading fix)
182
82
  let newContent = buildTodoContent(todos, existingContent);
183
83
  newContent = fixMalformedHeadings(newContent);
184
- writeFileSync(todoPath, newContent);
84
+ writeFileAtomic(todoPath, newContent);
185
85
 
186
86
  const stats = {
187
87
  inProgress: todos.filter(t => t.status === 'in_progress').length,
@@ -694,7 +694,7 @@ async function main() {
694
694
  let noteContent = readFileSync(newPath, 'utf-8');
695
695
  noteContent = noteContent.replace(
696
696
  /^(# Session \d+:)\s*.*$/m,
697
- `$1 ${title}`
697
+ (_m, h1) => `${h1} ${title}`
698
698
  );
699
699
  writeFileSync(newPath, noteContent);
700
700
  console.error(`Updated note H1 to match rename`);
@@ -18,11 +18,12 @@
18
18
  */
19
19
 
20
20
  import { currentWorkerId } from "../../../workers/worker-id.js";
21
- import { appendFileSync, mkdirSync, existsSync } from 'fs';
21
+ import { appendFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
22
22
  import { dirname, resolve } from 'path';
23
23
  import { securityEventsPath } from '../lib/pai-paths.js';
24
24
  import { isSharedCheckout } from '../lib/shared-checkout.js';
25
- import { decideWorkerGitGuard, workerGitGuardMessage } from '../lib/worker-git-guard.js';
25
+ import { maskSecrets } from '../lib/loop-guard.js';
26
+ import { decideWorkerGitGuard, splitSegments, workerGitGuardMessage } from '../lib/worker-git-guard.js';
26
27
  import { detectUnsafePlutil, plutilGuardMessage } from '../lib/plutil-guard.js';
27
28
  import { readWorkersSection } from '../../../workers/config.js';
28
29
  import { workersLogDir } from '../../../workers/paths.js';
@@ -75,6 +76,7 @@ const DANGEROUS_FILE_OPS_PATTERNS: RegExp[] = [
75
76
  // OPTIONAL: Operations that require confirmation instead of blocking
76
77
  const DANGEROUS_GIT_PATTERNS: RegExp[] = [
77
78
  /\bgit\b.*\spush\b.*(\s-f\b|\s--force(\s|$))/i, // git push -f / --force; --force-with-lease and --follow-tags pass
79
+ /\bgit\b.*\spush\b.*\s\+\S/i, // a plus-prefixed refspec forces the update without -f
78
80
  /\bgit\s+reset\s+--hard/i, // git reset --hard
79
81
  // Add your own git safety patterns here
80
82
  ];
@@ -149,20 +151,35 @@ interface DetectionResult {
149
151
  pattern?: string;
150
152
  }
151
153
 
154
+ /** Quoted strings removed: text inside a commit message or echo is not a command. */
155
+ function dropQuoted(segment: string): string {
156
+ return segment.replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '""');
157
+ }
158
+
159
+ /** A quoted +refspec is still a forced push, so push segments also test with only the quote marks removed. */
160
+ function gitPatternHit(pattern: RegExp, segment: string): boolean {
161
+ const bare = dropQuoted(segment);
162
+ if (pattern.test(bare)) return true;
163
+ return /\bgit\b.*\spush\b/.test(bare) && pattern.test(segment.replace(/["']/g, ''));
164
+ }
165
+
152
166
  function detectAttack(content: string): DetectionResult {
167
+ // A quoted target is the same target: test the text with quote marks removed too.
168
+ const unquoted = content.replace(/["']/g, '');
153
169
  // First check for hard blocks
154
170
  for (const { category, patterns } of ALL_BLOCK_PATTERNS) {
155
171
  for (const pattern of patterns) {
156
- if (pattern.test(content)) {
172
+ if (pattern.test(content) || pattern.test(unquoted)) {
157
173
  return { blocked: true, category, pattern: pattern.source };
158
174
  }
159
175
  }
160
176
  }
161
177
 
162
178
  // Then check for confirmation-required patterns
179
+ const segments = splitSegments(content);
163
180
  for (const { category, patterns } of CONFIRM_PATTERNS) {
164
181
  for (const pattern of patterns) {
165
- if (pattern.test(content)) {
182
+ if (segments.some((seg) => gitPatternHit(pattern, seg))) {
166
183
  return { blocked: false, requiresConfirmation: true, category, pattern: pattern.source };
167
184
  }
168
185
  }
@@ -177,6 +194,7 @@ function detectAttack(content: string): DetectionResult {
177
194
 
178
195
  function logSecurityEvent(event: Record<string, unknown>): void {
179
196
  const logPath = securityEventsPath();
197
+ if (typeof event.command === 'string') event = { ...event, command: maskSecrets(event.command) };
180
198
  const entry = JSON.stringify({ timestamp: new Date().toISOString(), ...event }) + '\n';
181
199
 
182
200
  try {
@@ -184,7 +202,8 @@ function logSecurityEvent(event: Record<string, unknown>): void {
184
202
  if (!existsSync(dir)) {
185
203
  mkdirSync(dir, { recursive: true });
186
204
  }
187
- appendFileSync(logPath, entry);
205
+ appendFileSync(logPath, entry, { mode: 0o600 });
206
+ chmodSync(logPath, 0o600);
188
207
  } catch {
189
208
  // Silently fail - logging should never break the hook
190
209
  }
@@ -198,11 +217,12 @@ async function main(): Promise<void> {
198
217
  let input: HookInput;
199
218
 
200
219
  try {
201
- // Read stdin
220
+ // Read stdin to end of input; the timer only bounds a hung pipe.
202
221
  const chunks: Buffer[] = [];
203
- const timeoutPromise = new Promise<Buffer[]>((_, reject) =>
204
- setTimeout(() => reject(new Error('timeout')), 100)
205
- );
222
+ let timer: NodeJS.Timeout | undefined;
223
+ const timeoutPromise = new Promise<Buffer[]>((_, reject) => {
224
+ timer = setTimeout(() => reject(new Error('timeout')), 2000);
225
+ });
206
226
  const readPromise = (async () => {
207
227
  for await (const chunk of process.stdin) {
208
228
  chunks.push(chunk);
@@ -215,8 +235,10 @@ async function main(): Promise<void> {
215
235
  const result = await Promise.race([readPromise, timeoutPromise]);
216
236
  text = Buffer.concat(result).toString('utf-8');
217
237
  } catch {
218
- console.log(JSON.stringify({ permissionDecision: 'allow' }));
219
- return;
238
+ // Without the payload the delete and git guards cannot run: fail closed.
239
+ deny('The hook input could not be read in time, so the command cannot be checked. Retry the command.');
240
+ } finally {
241
+ clearTimeout(timer);
220
242
  }
221
243
 
222
244
  if (!text.trim()) {
@@ -226,9 +248,8 @@ async function main(): Promise<void> {
226
248
 
227
249
  input = JSON.parse(text);
228
250
  } catch {
229
- // Parse error or timeout - fail open
230
- console.log(JSON.stringify({ permissionDecision: 'allow' }));
231
- return;
251
+ // Unparseable payload: the command is unknown, so the guards cannot run.
252
+ deny('The hook input could not be parsed, so the command cannot be checked. Retry the command.');
232
253
  }
233
254
 
234
255
  // Only validate Bash commands
@@ -8,12 +8,14 @@
8
8
  */
9
9
 
10
10
  import { paiSocketPath } from "../../../runtime-paths.js";
11
- import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync } from 'fs';
11
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, unlinkSync } from 'fs';
12
+ import { tmpdir } from 'os';
12
13
  import { join } from 'path';
13
14
  import { historyDir } from '../lib/pai-paths.js';
15
+ import { analyzeSession, formatSessionDocument } from '../lib/session-summary.js';
14
16
 
15
17
  interface SessionData {
16
- conversation_id: string;
18
+ session_id: string;
17
19
  timestamp: string;
18
20
  [key: string]: any;
19
21
  }
@@ -27,25 +29,35 @@ async function main() {
27
29
  chunks.push(chunk);
28
30
  }
29
31
  const input = Buffer.concat(chunks).toString('utf-8');
30
- if (!input || input.trim() === '') {
32
+ // The detached child has stdin ignored; its payload comes from the temp file.
33
+ if ((!input || input.trim() === '') && !process.env.__PAI_HOOK_INPUT_FILE) {
31
34
  process.exit(0);
32
35
  }
33
36
 
34
- // Fork: re-exec ourselves with --background flag and pipe the stdin data via env.
37
+ // Fork: re-exec ourselves with --background flag and hand the stdin data over via a private temp file
38
+ // (an env var is visible to ps eww and hits E2BIG on large payloads).
35
39
  // This detaches the heavy work (JSONL scan, IPC) from CC's abort signal.
36
40
  if (!process.env.__PAI_HOOK_BG) {
37
41
  const { spawn } = await import('child_process');
42
+ const inputFile = join(tmpdir(), `pai-session-end-${process.pid}-${Date.now()}.json`);
43
+ writeFileSync(inputFile, input, { mode: 0o600 });
38
44
  const child = spawn(process.execPath, [process.argv[1], '--background'], {
39
45
  detached: true,
40
46
  stdio: 'ignore',
41
- env: { ...process.env, __PAI_HOOK_BG: '1', __PAI_HOOK_INPUT: input },
47
+ env: { ...process.env, __PAI_HOOK_BG: '1', __PAI_HOOK_INPUT_FILE: inputFile },
42
48
  });
43
49
  child.unref();
44
50
  process.exit(0); // Return immediately — CC sees success, abort signal is harmless
45
51
  }
46
52
 
47
53
  // Background mode: we're detached, safe from abort signals
48
- const data: SessionData = JSON.parse(process.env.__PAI_HOOK_INPUT || input);
54
+ const inputFile = process.env.__PAI_HOOK_INPUT_FILE;
55
+ let raw = input;
56
+ if (inputFile) {
57
+ raw = readFileSync(inputFile, 'utf-8');
58
+ try { unlinkSync(inputFile); } catch { /* ignore */ }
59
+ }
60
+ const data: SessionData = JSON.parse(raw);
49
61
 
50
62
  // Generate timestamp for filename
51
63
  const now = new Date();
@@ -57,7 +69,7 @@ async function main() {
57
69
  const yearMonth = timestamp.substring(0, 7); // YYYY-MM
58
70
 
59
71
  // Try to extract session info from raw outputs
60
- const sessionInfo = await analyzeSession(data.conversation_id, yearMonth);
72
+ const sessionInfo = await analyzeSession(data.session_id, yearMonth);
61
73
 
62
74
  // Generate filename
63
75
  const filename = `${timestamp}_SESSION_${sessionInfo.focus}.md`;
@@ -75,7 +87,7 @@ async function main() {
75
87
  writeFileSync(join(sessionDir, filename), sessionDoc);
76
88
 
77
89
  // Also store structured summary via daemon IPC for the observations system
78
- await storeStructuredSummary(data.conversation_id, sessionInfo);
90
+ await storeStructuredSummary(data.session_id, sessionInfo);
79
91
 
80
92
  // Exit successfully
81
93
  process.exit(0);
@@ -86,126 +98,6 @@ async function main() {
86
98
  }
87
99
  }
88
100
 
89
- async function analyzeSession(conversationId: string, yearMonth: string): Promise<any> {
90
- // Try to read raw outputs for this session
91
- const rawOutputsDir = join(historyDir(), 'raw-outputs', yearMonth);
92
-
93
- let filesChanged: string[] = [];
94
- let commandsExecuted: string[] = [];
95
- let toolsUsed: Set<string> = new Set();
96
-
97
- try {
98
- if (existsSync(rawOutputsDir)) {
99
- // Only scan today's file — not the entire month (which can be 400MB+).
100
- // JSONL filenames are prefixed with YYYY-MM-DD.
101
- const todayPrefix = new Date().toISOString().substring(0, 10);
102
- const files = readdirSync(rawOutputsDir).filter(
103
- f => f.endsWith('.jsonl') && f.startsWith(todayPrefix)
104
- );
105
-
106
- for (const file of files) {
107
- const filePath = join(rawOutputsDir, file);
108
- const content = readFileSync(filePath, 'utf-8');
109
- const lines = content.split('\n').filter(l => l.trim());
110
-
111
- for (const line of lines) {
112
- try {
113
- const entry = JSON.parse(line);
114
- if (entry.session === conversationId) {
115
- toolsUsed.add(entry.tool);
116
-
117
- // Extract file changes
118
- if (entry.tool === 'Edit' || entry.tool === 'Write') {
119
- if (entry.input?.file_path) {
120
- filesChanged.push(entry.input.file_path);
121
- }
122
- }
123
-
124
- // Extract bash commands
125
- if (entry.tool === 'Bash' && entry.input?.command) {
126
- commandsExecuted.push(entry.input.command);
127
- }
128
- }
129
- } catch (e) {
130
- // Skip invalid JSON lines
131
- }
132
- }
133
- }
134
- }
135
- } catch (error) {
136
- // Silent failure
137
- }
138
-
139
- return {
140
- focus: 'general-work',
141
- filesChanged: [...new Set(filesChanged)].slice(0, 10), // Unique, max 10
142
- commandsExecuted: commandsExecuted.slice(0, 10), // Max 10
143
- toolsUsed: Array.from(toolsUsed),
144
- duration: 0 // Unknown
145
- };
146
- }
147
-
148
- function formatSessionDocument(timestamp: string, data: SessionData, info: any): string {
149
- const date = timestamp.substring(0, 10); // YYYY-MM-DD
150
- const time = timestamp.substring(11).replace(/-/g, ':'); // HH:MM:SS
151
- const da = process.env.DA || 'PAI';
152
-
153
- return `---
154
- capture_type: SESSION
155
- timestamp: ${new Date().toISOString()}
156
- session_id: ${data.conversation_id}
157
- duration_minutes: ${info.duration}
158
- executor: ${da}
159
- ---
160
-
161
- # Session: ${info.focus}
162
-
163
- **Date:** ${date}
164
- **Time:** ${time}
165
- **Session ID:** ${data.conversation_id}
166
-
167
- ---
168
-
169
- ## Session Overview
170
-
171
- **Focus:** General development work
172
- **Duration:** ${info.duration > 0 ? `${info.duration} minutes` : 'Unknown'}
173
-
174
- ---
175
-
176
- ## Tools Used
177
-
178
- ${info.toolsUsed.length > 0 ? info.toolsUsed.map((t: string) => `- ${t}`).join('\n') : '- None recorded'}
179
-
180
- ---
181
-
182
- ## Files Modified
183
-
184
- ${info.filesChanged.length > 0 ? info.filesChanged.map((f: string) => `- \`${f}\``).join('\n') : '- None recorded'}
185
-
186
- **Total Files Changed:** ${info.filesChanged.length}
187
-
188
- ---
189
-
190
- ## Commands Executed
191
-
192
- ${info.commandsExecuted.length > 0 ? '```bash\n' + info.commandsExecuted.join('\n') + '\n```' : 'None recorded'}
193
-
194
- ---
195
-
196
- ## Notes
197
-
198
- This session summary was automatically generated by the UOCS SessionEnd hook.
199
-
200
- For detailed tool outputs, see: \`${join(historyDir(), 'raw-outputs', timestamp.substring(0, 7))}/\`
201
-
202
- ---
203
-
204
- **Session Outcome:** Completed
205
- **Generated:** ${new Date().toISOString()}
206
- `;
207
- }
208
-
209
101
  async function storeStructuredSummary(
210
102
  sessionId: string,
211
103
  info: { focus: string; filesChanged: string[]; commandsExecuted: string[]; toolsUsed: string[]; duration: number }
@@ -74,9 +74,9 @@ async function main() {
74
74
 
75
75
  // Replace placeholders {{DA}}, {{DA_COLOR}}, {{ENGINEER_NAME}}
76
76
  coreContent = coreContent
77
- .replace(/\{\{DA\}\}/g, daName)
78
- .replace(/\{\{DA_COLOR\}\}/g, daColor)
79
- .replace(/\{\{ENGINEER_NAME\}\}/g, engineerName);
77
+ .replace(/\{\{DA\}\}/g, () => daName)
78
+ .replace(/\{\{DA_COLOR\}\}/g, () => daColor)
79
+ .replace(/\{\{ENGINEER_NAME\}\}/g, () => engineerName);
80
80
 
81
81
  console.error(`Read ${coreContent.length} characters from ${coreSkillPath} (Personalized for ${engineerName} & ${daName})`);
82
82