ultimate-pi 0.18.1 → 0.19.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 (284) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +1 -1
  2. package/.agents/skills/harness-decisions/SKILL.md +1 -2
  3. package/.agents/skills/harness-governor/SKILL.md +6 -5
  4. package/.pi/PACKAGING.md +4 -4
  5. package/.pi/SYSTEM.md +54 -120
  6. package/.pi/agents/harness/incident-recorder.md +0 -1
  7. package/.pi/agents/harness/planning/decompose.md +0 -2
  8. package/.pi/agents/harness/planning/execution-plan-author.md +0 -2
  9. package/.pi/agents/harness/planning/hypothesis-validator.md +0 -2
  10. package/.pi/agents/harness/planning/hypothesis.md +0 -2
  11. package/.pi/agents/harness/planning/implementation-researcher.md +0 -2
  12. package/.pi/agents/harness/planning/plan-adversary.md +0 -2
  13. package/.pi/agents/harness/planning/plan-evaluator.md +1 -3
  14. package/.pi/agents/harness/planning/planning-context.md +0 -2
  15. package/.pi/agents/harness/planning/review-integrator.md +0 -2
  16. package/.pi/agents/harness/planning/sprint-contract-auditor.md +0 -2
  17. package/.pi/agents/harness/planning/stack-researcher.md +0 -2
  18. package/.pi/agents/harness/reviewing/adversary.md +0 -2
  19. package/.pi/agents/harness/reviewing/evaluator.md +0 -2
  20. package/.pi/agents/harness/reviewing/tie-breaker.md +0 -2
  21. package/.pi/agents/harness/running/executor.md +0 -2
  22. package/.pi/agents/harness/sentrux-bootstrap.md +0 -1
  23. package/.pi/agents/harness/sentrux-steward.md +0 -2
  24. package/.pi/agents/harness/trace-librarian.md +0 -1
  25. package/.pi/extensions/00-posthog-network-bootstrap.ts +1 -1
  26. package/.pi/extensions/agt-kill-switch.ts +57 -0
  27. package/.pi/extensions/agt-prompt-guard.ts +32 -0
  28. package/.pi/extensions/custom-footer.ts +46 -145
  29. package/.pi/extensions/custom-header.ts +1 -1
  30. package/.pi/extensions/custom-system-prompt.ts +1 -1
  31. package/.pi/extensions/debate-orchestrator.ts +6 -6
  32. package/.pi/extensions/harness-ask-user.ts +7 -7
  33. package/.pi/extensions/harness-debate-tools.ts +26 -42
  34. package/.pi/extensions/harness-lens.ts +94 -0
  35. package/.pi/extensions/harness-plan-approval.ts +11 -11
  36. package/.pi/extensions/harness-run-context.ts +1070 -876
  37. package/.pi/extensions/harness-subagent-governance.ts +8 -0
  38. package/.pi/extensions/harness-subagent-submit.ts +34 -163
  39. package/.pi/extensions/harness-subagents.ts +3 -3
  40. package/.pi/extensions/harness-telemetry.ts +2 -2
  41. package/.pi/extensions/harness-web-tools.ts +2 -2
  42. package/.pi/extensions/policy-gate.ts +25 -5
  43. package/.pi/extensions/sentrux-rules-sync.ts +1 -1
  44. package/.pi/extensions/subagent-governance.ts +92 -0
  45. package/.pi/extensions/trace-recorder.ts +1 -1
  46. package/.pi/extensions/{ultimate-pi-vcc.ts → vcc-compaction.ts} +1 -1
  47. package/.pi/harness/README.md +6 -2
  48. package/.pi/harness/agents.manifest.json +22 -25
  49. package/.pi/harness/agents.policy.yaml +275 -0
  50. package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +1 -1
  51. package/.pi/harness/docs/adrs/0035-plan-phase-review-gate.md +1 -1
  52. package/.pi/harness/docs/adrs/0045-harness-lens-minimal-contract.md +49 -0
  53. package/.pi/harness/docs/adrs/0046-agt-policy-engine.md +51 -0
  54. package/.pi/harness/docs/adrs/0047-agt-layered-security.md +39 -0
  55. package/.pi/harness/docs/adrs/0048-tool-call-hook-order.md +25 -0
  56. package/.pi/harness/docs/adrs/0049-agents-policy-manifest.md +36 -0
  57. package/.pi/harness/docs/adrs/README.md +5 -0
  58. package/.pi/harness/evolution/README.md +1 -2
  59. package/.pi/harness/examples/agents.policy.project.yaml +19 -0
  60. package/.pi/harness/examples/policies/custom-deny-bash.yaml +9 -0
  61. package/.pi/harness/policies/bash-denylists.yaml +5 -0
  62. package/.pi/harness/policies/defaults.yaml +51 -0
  63. package/.pi/harness/policies/orchestrator.yaml +18 -0
  64. package/.pi/harness/policies/phases.yaml +10 -0
  65. package/.pi/harness/policies/roles.yaml +5 -0
  66. package/.pi/harness/policies/web-guard.yaml +5 -0
  67. package/.pi/harness/policies/workflow-sequences.yaml +9 -0
  68. package/.pi/harness/sentrux/architecture.manifest.json +26 -4
  69. package/.pi/harness/specs/observation.schema.json +2 -1
  70. package/.pi/lib/agents-policy.d.mts +70 -0
  71. package/.pi/lib/agents-policy.mjs +325 -0
  72. package/.pi/lib/agents-policy.ts +19 -0
  73. package/.pi/lib/agt/audit-run-sink.ts +52 -0
  74. package/.pi/lib/agt/build-evaluation-context.ts +285 -0
  75. package/.pi/lib/agt/config.ts +28 -0
  76. package/.pi/lib/agt/delegation.ts +69 -0
  77. package/.pi/lib/agt/evaluate-policy.ts +56 -0
  78. package/.pi/lib/agt/identity-registry.ts +41 -0
  79. package/.pi/lib/agt/index.ts +55 -0
  80. package/.pi/lib/agt/kill-switch-state.ts +11 -0
  81. package/.pi/lib/agt/legacy-evaluate.ts +101 -0
  82. package/.pi/lib/agt/policy-engine.ts +154 -0
  83. package/.pi/lib/agt/rings.ts +21 -0
  84. package/.pi/lib/agt/sre-hooks.ts +45 -0
  85. package/.pi/lib/agt/trust-run-store.ts +26 -0
  86. package/.pi/lib/agt/workflow-history.ts +29 -0
  87. package/.pi/lib/agt-governance-active.ts +14 -0
  88. package/.pi/lib/agt-tool-guard.ts +78 -0
  89. package/.pi/lib/ask-user/dialog.ts +314 -0
  90. package/.pi/{extensions/lib → lib}/debate-bus-core.ts +10 -10
  91. package/.pi/{extensions/lib → lib}/debate-bus-state.ts +1 -1
  92. package/.pi/{extensions/lib → lib}/extension-load-guard.ts +13 -2
  93. package/.pi/lib/harness-agt-tool-guard.ts +5 -0
  94. package/.pi/{extensions/lib → lib}/harness-artifact-gate.ts +1 -1
  95. package/.pi/lib/harness-debate-core-deps.ts +14 -0
  96. package/.pi/lib/harness-debate-workflow-deps.ts +43 -0
  97. package/.pi/lib/harness-lens/.gitattributes +1 -0
  98. package/.pi/lib/harness-lens/clients/edit-autopatch.ts +88 -0
  99. package/.pi/lib/harness-lens/clients/file-kinds.ts +380 -0
  100. package/.pi/lib/harness-lens/clients/file-time.ts +215 -0
  101. package/.pi/lib/harness-lens/clients/file-utils.ts +484 -0
  102. package/.pi/lib/harness-lens/clients/format-service.ts +276 -0
  103. package/.pi/lib/harness-lens/clients/formatters.ts +1000 -0
  104. package/.pi/lib/harness-lens/clients/git-guard.ts +31 -0
  105. package/.pi/lib/harness-lens/clients/indent-retarget.ts +90 -0
  106. package/.pi/lib/harness-lens/clients/installer/index.ts +2368 -0
  107. package/.pi/lib/harness-lens/clients/latency-logger.ts +80 -0
  108. package/.pi/lib/harness-lens/clients/lens-config.ts +43 -0
  109. package/.pi/lib/harness-lens/clients/lens-events.ts +164 -0
  110. package/.pi/lib/harness-lens/clients/lsp/aggregation.ts +91 -0
  111. package/.pi/lib/harness-lens/clients/lsp/client.ts +1466 -0
  112. package/.pi/lib/harness-lens/clients/lsp/config.ts +216 -0
  113. package/.pi/lib/harness-lens/clients/lsp/edits.ts +297 -0
  114. package/.pi/lib/harness-lens/clients/lsp/index.ts +1355 -0
  115. package/.pi/lib/harness-lens/clients/lsp/interactive-install.ts +424 -0
  116. package/.pi/lib/harness-lens/clients/lsp/language.ts +223 -0
  117. package/.pi/lib/harness-lens/clients/lsp/launch.ts +939 -0
  118. package/.pi/lib/harness-lens/clients/lsp/lsp-index.ts +11 -0
  119. package/.pi/lib/harness-lens/clients/lsp/path-utils.ts +12 -0
  120. package/.pi/lib/harness-lens/clients/lsp/server-strategies.ts +81 -0
  121. package/.pi/lib/harness-lens/clients/lsp/server.ts +1971 -0
  122. package/.pi/lib/harness-lens/clients/path-utils.ts +182 -0
  123. package/.pi/lib/harness-lens/clients/pipeline.ts +360 -0
  124. package/.pi/lib/harness-lens/clients/project-profile.ts +117 -0
  125. package/.pi/lib/harness-lens/clients/runtime-agent-end.ts +112 -0
  126. package/.pi/lib/harness-lens/clients/runtime-config.ts +33 -0
  127. package/.pi/lib/harness-lens/clients/runtime-coordinator.ts +186 -0
  128. package/.pi/lib/harness-lens/clients/runtime-tool-result.ts +171 -0
  129. package/.pi/lib/harness-lens/clients/safe-spawn.ts +339 -0
  130. package/.pi/lib/harness-lens/clients/secrets-scanner.ts +214 -0
  131. package/.pi/lib/harness-lens/clients/tool-policy.ts +2072 -0
  132. package/.pi/lib/harness-lens/clients/types.ts +59 -0
  133. package/.pi/lib/harness-lens/clients/widget-state.ts +283 -0
  134. package/.pi/lib/harness-lens/index.ts +532 -0
  135. package/.pi/lib/harness-lens/tools/lsp-diagnostics.ts +706 -0
  136. package/.pi/lib/harness-lens/tools/lsp-navigation.ts +1246 -0
  137. package/.pi/{extensions/lib → lib}/harness-posthog.ts +3 -0
  138. package/.pi/lib/harness-run-context-responses.ts +9 -0
  139. package/.pi/lib/harness-run-context.ts +0 -2
  140. package/.pi/{extensions/lib/spawn-policy.ts → lib/harness-spawn-policy.ts} +1 -0
  141. package/.pi/{extensions/lib → lib}/harness-spawn-topology.ts +1 -1
  142. package/.pi/lib/harness-subagent-auth.ts +51 -0
  143. package/.pi/{extensions/lib → lib}/harness-subagent-precheck.ts +10 -7
  144. package/.pi/{extensions/lib → lib}/harness-subagent-submit-pipeline.ts +3 -3
  145. package/.pi/lib/harness-subagent-submit-register.ts +163 -0
  146. package/.pi/{extensions/lib → lib}/harness-subagent-submit-registry.ts +1 -37
  147. package/.pi/{extensions/lib → lib}/harness-subagents-bridge.ts +53 -14
  148. package/.pi/{extensions/lib → lib}/harness-subprocess-bootstrap.ts +1 -1
  149. package/.pi/{extensions/lib → lib}/plan-approval/create-plan.ts +2 -2
  150. package/.pi/{extensions/lib → lib}/plan-approval/format-plan.ts +2 -2
  151. package/.pi/{extensions/lib → lib}/plan-approval/plan-review.ts +162 -201
  152. package/.pi/{extensions/lib → lib}/plan-approval/render.ts +1 -1
  153. package/.pi/{extensions/lib → lib}/plan-approval/resolve-disk.ts +2 -2
  154. package/.pi/{extensions/lib → lib}/plan-approval/types.ts +1 -1
  155. package/.pi/{extensions/lib → lib}/plan-approval/validate.ts +3 -3
  156. package/.pi/{extensions/lib → lib}/plan-debate-envelope.ts +1 -1
  157. package/.pi/{extensions/lib → lib}/plan-debate-gate.ts +1 -1
  158. package/.pi/{extensions/lib → lib}/plan-debate-lane.ts +1 -4
  159. package/.pi/{extensions/lib → lib}/plan-messenger.ts +1 -1
  160. package/.pi/prompts/harness-plan.md +1 -1
  161. package/.pi/prompts/harness-setup.md +37 -64
  162. package/.pi/scripts/README.md +2 -5
  163. package/.pi/scripts/generate-agents-policy-yaml.mjs +148 -0
  164. package/.pi/scripts/harness-agents-manifest.mjs +60 -3
  165. package/.pi/scripts/harness-agt-doctor.ts +36 -0
  166. package/.pi/scripts/harness-cli-verify.sh +9 -2
  167. package/.pi/scripts/harness-verify.mjs +113 -39
  168. package/.pi/scripts/harness-web-policy-guard.mjs +2 -2
  169. package/.pi/scripts/validate-plan-dag.mjs +65 -74
  170. package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +2 -2
  171. package/.pi/scripts/vendor-sync-pi-vcc.sh +1 -1
  172. package/.pi/skills/architecture/broker-domain/SKILL.md +65 -0
  173. package/.pi/skills/architecture/cqrs/SKILL.md +63 -0
  174. package/.pi/skills/architecture/event-driven/SKILL.md +60 -0
  175. package/.pi/skills/architecture/hexagonal-ports-adapters/SKILL.md +66 -0
  176. package/.pi/skills/architecture/layered/SKILL.md +68 -0
  177. package/.pi/skills/architecture/microkernel/SKILL.md +62 -0
  178. package/.pi/skills/architecture/microservices/SKILL.md +64 -0
  179. package/.pi/skills/architecture/modular-monolith/SKILL.md +65 -0
  180. package/.pi/skills/architecture/orchestration-driven-soa/SKILL.md +61 -0
  181. package/.pi/skills/architecture/pipeline/SKILL.md +63 -0
  182. package/.pi/skills/architecture/service-based/SKILL.md +64 -0
  183. package/.pi/skills/architecture/service-mesh/SKILL.md +60 -0
  184. package/.pi/skills/architecture/space-based/SKILL.md +60 -0
  185. package/.pi/skills/ast-grep/SKILL.md +40 -321
  186. package/.pi/skills/delivery/debugging-discipline/SKILL.md +36 -0
  187. package/.pi/skills/delivery/documentation-update/SKILL.md +33 -0
  188. package/.pi/skills/delivery/requirements-to-implementation/SKILL.md +34 -0
  189. package/.pi/skills/delivery/risk-based-verification/SKILL.md +43 -0
  190. package/.pi/skills/delivery/tradeoff-analysis/SKILL.md +34 -0
  191. package/.pi/skills/engineering/api-contract-design/SKILL.md +38 -0
  192. package/.pi/skills/engineering/cohesion-coupling/SKILL.md +43 -0
  193. package/.pi/skills/engineering/complexity-control/SKILL.md +31 -0
  194. package/.pi/skills/engineering/defensive-programming/SKILL.md +38 -0
  195. package/.pi/skills/engineering/dependency-management/SKILL.md +29 -0
  196. package/.pi/skills/engineering/domain-modeling/SKILL.md +32 -0
  197. package/.pi/skills/engineering/error-handling/SKILL.md +37 -0
  198. package/.pi/skills/engineering/legacy-code-seams/SKILL.md +35 -0
  199. package/.pi/skills/engineering/naming-and-intent/SKILL.md +29 -0
  200. package/.pi/skills/engineering/refactoring-safe-evolution/SKILL.md +35 -0
  201. package/.pi/skills/engineering/routine-function-design/SKILL.md +34 -0
  202. package/.pi/skills/engineering/small-change-discipline/SKILL.md +35 -0
  203. package/.pi/skills/lsp-navigation/SKILL.md +89 -0
  204. package/.pi/skills/quality/code-review-self-check/SKILL.md +35 -0
  205. package/.pi/skills/quality/privacy-data-handling/SKILL.md +26 -0
  206. package/.pi/skills/quality/security-review/SKILL.md +34 -0
  207. package/.pi/skills/quality/test-strategy/SKILL.md +33 -0
  208. package/.pi/skills/quality/testability-design/SKILL.md +33 -0
  209. package/.pi/skills/systems/concurrency-safety/SKILL.md +32 -0
  210. package/.pi/skills/systems/data-modeling-migrations/SKILL.md +31 -0
  211. package/.pi/skills/systems/observability-instrumentation/SKILL.md +32 -0
  212. package/.pi/skills/systems/performance-measurement/SKILL.md +35 -0
  213. package/.pi/skills/systems/reliability-design/SKILL.md +32 -0
  214. package/.sentrux/rules.toml +20 -4
  215. package/AGENTS.md +5 -0
  216. package/CHANGELOG.md +14 -0
  217. package/README.md +3 -12
  218. package/THIRD_PARTY_NOTICES.md +12 -21
  219. package/package.json +15 -7
  220. package/vendor/pi-subagents/src/agents.ts +45 -1
  221. package/vendor/pi-subagents/src/subagents.ts +866 -811
  222. package/vendor/pi-vcc/src/core/brief.ts +68 -99
  223. package/vendor/pi-vcc/src/core/settings.ts +2 -2
  224. package/.agents/skills/caveman/SKILL.md +0 -67
  225. package/.pi/agents/harness/meta-optimizer.md +0 -36
  226. package/.pi/extensions/lib/ask-user/dialog.ts +0 -260
  227. package/.pi/extensions/lib/harness-subagent-auth.ts +0 -207
  228. package/.pi/extensions/lib/harness-subagent-policy.ts +0 -236
  229. package/.pi/extensions/pi-model-router-harness.ts +0 -42
  230. package/.pi/harness/evolution/meta-optimizer.mjs +0 -99
  231. package/.pi/harness/specs/router-tuning-proposal.schema.json +0 -114
  232. package/.pi/model-router.example.json +0 -36
  233. package/.pi/prompts/harness-critic.md +0 -10
  234. package/.pi/prompts/harness-eval.md +0 -10
  235. package/.pi/prompts/harness-router-tune.md +0 -52
  236. package/.pi/scripts/harness-generate-model-router.mjs +0 -327
  237. package/.pi/scripts/harness-model-router-routing.test.mjs +0 -97
  238. package/.pi/scripts/harness-sync-model-router.mjs +0 -97
  239. package/.pi/scripts/vendor-sync-pi-model-router.sh +0 -47
  240. package/vendor/pi-model-router/.prettierignore +0 -4
  241. package/vendor/pi-model-router/.prettierrc +0 -5
  242. package/vendor/pi-model-router/AGENTS.md +0 -39
  243. package/vendor/pi-model-router/LICENSE +0 -21
  244. package/vendor/pi-model-router/README.md +0 -99
  245. package/vendor/pi-model-router/UPSTREAM_PIN.md +0 -10
  246. package/vendor/pi-model-router/docs/ARCHITECTURE.md +0 -54
  247. package/vendor/pi-model-router/extensions/commands.ts +0 -720
  248. package/vendor/pi-model-router/extensions/config.ts +0 -348
  249. package/vendor/pi-model-router/extensions/constants.ts +0 -1
  250. package/vendor/pi-model-router/extensions/index.ts +0 -478
  251. package/vendor/pi-model-router/extensions/provider.ts +0 -580
  252. package/vendor/pi-model-router/extensions/routing.ts +0 -564
  253. package/vendor/pi-model-router/extensions/state.ts +0 -52
  254. package/vendor/pi-model-router/extensions/types.ts +0 -95
  255. package/vendor/pi-model-router/extensions/ui.ts +0 -144
  256. package/vendor/pi-model-router/model-router.example.json +0 -48
  257. package/vendor/pi-model-router/package.json +0 -48
  258. package/vendor/pi-model-router/tsconfig.json +0 -16
  259. /package/.pi/{prompts → harness/docs}/planning-rubrics.md +0 -0
  260. /package/.pi/{extensions/lib → lib}/ask-user/fallback.ts +0 -0
  261. /package/.pi/{extensions/lib → lib}/ask-user/render.ts +0 -0
  262. /package/.pi/{extensions/lib → lib}/ask-user/schema.ts +0 -0
  263. /package/.pi/{extensions/lib → lib}/ask-user/types.ts +0 -0
  264. /package/.pi/{extensions/lib → lib}/ask-user/validate-core.mjs +0 -0
  265. /package/.pi/{extensions/lib → lib}/ask-user/validate.ts +0 -0
  266. /package/.pi/{extensions/lib → lib}/harness-cocoindex-refresh.ts +0 -0
  267. /package/.pi/{extensions/lib → lib}/harness-paths.ts +0 -0
  268. /package/.pi/{extensions/lib → lib}/harness-spawn-budget.ts +0 -0
  269. /package/.pi/{extensions/lib → lib}/harness-vcc-settings.ts +0 -0
  270. /package/.pi/{extensions/lib → lib}/harness-web/run-cli.ts +0 -0
  271. /package/.pi/{extensions/lib → lib}/plan-approval/dialog.ts +0 -0
  272. /package/.pi/{extensions/lib → lib}/plan-approval/schema.ts +0 -0
  273. /package/.pi/{extensions/lib → lib}/plan-approval-readiness.ts +0 -0
  274. /package/.pi/{extensions/lib → lib}/plan-debate-eligibility.ts +0 -0
  275. /package/.pi/{extensions/lib → lib}/plan-debate-focus.ts +0 -0
  276. /package/.pi/{extensions/lib → lib}/plan-debate-id.ts +0 -0
  277. /package/.pi/{extensions/lib → lib}/plan-debate-lanes.ts +0 -0
  278. /package/.pi/{extensions/lib → lib}/plan-debate-round-status.ts +0 -0
  279. /package/.pi/{extensions/lib → lib}/plan-debate-write-guard.ts +0 -0
  280. /package/.pi/{extensions/lib → lib}/plan-review-gate.ts +0 -0
  281. /package/.pi/{extensions/lib → lib}/plan-review-integrator-rules.ts +0 -0
  282. /package/.pi/{extensions/lib → lib}/plan-scope-guard.ts +0 -0
  283. /package/.pi/{extensions/lib → lib}/posthog-client.ts +0 -0
  284. /package/.pi/{extensions/lib → lib}/posthog-node.d.ts +0 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * FileTime Tracking for pi-lens
3
+ *
4
+ * Prevents race conditions when auto-formatting or external tools modify files.
5
+ * Tracks file modification times and sizes to detect external changes.
6
+ *
7
+ * Inspired by OpenCode's FileTime system - ensures agents re-read files
8
+ * that have been modified externally (including by formatters).
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ // --- Types ---
15
+
16
+ export interface FileStamp {
17
+ readAt: Date;
18
+ mtime: number | undefined;
19
+ ctime: number | undefined;
20
+ size: number | undefined;
21
+ }
22
+
23
+ interface FileTimeState {
24
+ reads: Map<string, Map<string, FileStamp>>; // sessionID -> filePath -> stamp
25
+ locks: Map<string, Promise<void>>; // filePath -> lock promise
26
+ }
27
+
28
+ // --- Singleton State ---
29
+
30
+ const globalState: FileTimeState = {
31
+ reads: new Map(),
32
+ locks: new Map(),
33
+ };
34
+
35
+ // --- Public API ---
36
+
37
+ export class FileTime {
38
+ private sessionID: string;
39
+
40
+ constructor(sessionID: string) {
41
+ this.sessionID = sessionID;
42
+ }
43
+
44
+ /**
45
+ * Record a file read with current stats
46
+ * Call this after ANY file modification (including formatting)
47
+ */
48
+ read(filePath: string): FileStamp {
49
+ const absolutePath = path.resolve(filePath);
50
+ const stamp = createStamp(absolutePath);
51
+
52
+ let sessionReads = globalState.reads.get(this.sessionID);
53
+ if (!sessionReads) {
54
+ sessionReads = new Map();
55
+ globalState.reads.set(this.sessionID, sessionReads);
56
+ }
57
+
58
+ sessionReads.set(absolutePath, stamp);
59
+ return stamp;
60
+ }
61
+
62
+ /**
63
+ * Get last recorded stamp for a file
64
+ */
65
+ get(filePath: string): FileStamp | undefined {
66
+ const absolutePath = path.resolve(filePath);
67
+ const sessionReads = globalState.reads.get(this.sessionID);
68
+ return sessionReads?.get(absolutePath);
69
+ }
70
+
71
+ /**
72
+ * Assert file hasn't changed since last read
73
+ * Throws error if file modified externally - forces agent to re-read
74
+ */
75
+ assert(filePath: string): void {
76
+ const absolutePath = path.resolve(filePath);
77
+ const sessionReads = globalState.reads.get(this.sessionID);
78
+ const recorded = sessionReads?.get(absolutePath);
79
+
80
+ if (!recorded) {
81
+ throw new FileTimeError(
82
+ `You must read file ${absolutePath} before modifying it. Use the read tool first.`,
83
+ absolutePath,
84
+ "not-read",
85
+ );
86
+ }
87
+
88
+ const current = createStamp(absolutePath);
89
+ const changed =
90
+ current.mtime !== recorded.mtime ||
91
+ current.ctime !== recorded.ctime ||
92
+ current.size !== recorded.size;
93
+
94
+ if (changed) {
95
+ throw new FileTimeError(
96
+ `File ${absolutePath} has been modified since it was last read.\n` +
97
+ `Last modification: ${new Date(current.mtime ?? Date.now()).toISOString()}\n` +
98
+ `Last read: ${recorded.readAt.toISOString()}\n\n` +
99
+ `Please read the file again before modifying it.`,
100
+ absolutePath,
101
+ "modified",
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Check if file has changed (non-throwing version of assert)
108
+ */
109
+ hasChanged(filePath: string): boolean {
110
+ const absolutePath = path.resolve(filePath);
111
+ const sessionReads = globalState.reads.get(this.sessionID);
112
+ const recorded = sessionReads?.get(absolutePath);
113
+
114
+ if (!recorded) return true; // Never read = changed
115
+
116
+ const current = createStamp(absolutePath);
117
+ return (
118
+ current.mtime !== recorded.mtime ||
119
+ current.ctime !== recorded.ctime ||
120
+ current.size !== recorded.size
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Acquire exclusive lock on file
126
+ * Prevents concurrent modifications to same file
127
+ */
128
+ async withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
129
+ const absolutePath = path.resolve(filePath);
130
+
131
+ // Wait for existing lock
132
+ while (globalState.locks.has(absolutePath)) {
133
+ const existing = globalState.locks.get(absolutePath);
134
+ if (existing) await existing;
135
+ }
136
+
137
+ // Create new lock
138
+ const lockPromise = fn().finally(() => {
139
+ globalState.locks.delete(absolutePath);
140
+ });
141
+
142
+ globalState.locks.set(
143
+ absolutePath,
144
+ lockPromise.then(() => {}),
145
+ );
146
+ return lockPromise;
147
+ }
148
+
149
+ /**
150
+ * Clear all tracked files for this session
151
+ */
152
+ clear(): void {
153
+ globalState.reads.delete(this.sessionID);
154
+ }
155
+
156
+ /**
157
+ * Clear specific file tracking
158
+ */
159
+ clearFile(filePath: string): void {
160
+ const absolutePath = path.resolve(filePath);
161
+ const sessionReads = globalState.reads.get(this.sessionID);
162
+ sessionReads?.delete(absolutePath);
163
+ }
164
+ }
165
+
166
+ // --- Error Type ---
167
+
168
+ export class FileTimeError extends Error {
169
+ readonly filePath: string;
170
+ readonly reason: "not-read" | "modified";
171
+
172
+ constructor(
173
+ message: string,
174
+ filePath: string,
175
+ reason: "not-read" | "modified",
176
+ ) {
177
+ super(message);
178
+ this.name = "FileTimeError";
179
+ this.filePath = filePath;
180
+ this.reason = reason;
181
+ }
182
+ }
183
+
184
+ // --- Utilities ---
185
+
186
+ function createStamp(filePath: string): FileStamp {
187
+ try {
188
+ const stats = fs.statSync(filePath);
189
+ return {
190
+ readAt: new Date(),
191
+ mtime: stats.mtime.getTime(),
192
+ ctime: stats.ctime.getTime(),
193
+ size: stats.size,
194
+ };
195
+ } catch {
196
+ // File doesn't exist - return empty stamp
197
+ return {
198
+ readAt: new Date(),
199
+ mtime: undefined,
200
+ ctime: undefined,
201
+ size: undefined,
202
+ };
203
+ }
204
+ }
205
+
206
+ // --- Global Helpers ---
207
+
208
+ export function createFileTime(sessionID: string): FileTime {
209
+ return new FileTime(sessionID);
210
+ }
211
+
212
+ export function clearAllSessions(): void {
213
+ globalState.reads.clear();
214
+ globalState.locks.clear();
215
+ }
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Shared file path utilities for pi-lens
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { minimatch } from "minimatch";
8
+ import { normalizeFilePath } from "./path-utils.js";
9
+ import { safeSpawnAsync } from "./safe-spawn.js";
10
+
11
+ /**
12
+ * Return the directory where pi-lens stores project-specific data
13
+ * (caches, indexes, worklogs, etc.).
14
+ *
15
+ * Default: store under <project>/.pi/harness/.lens/projects/<project-slug>.
16
+ *
17
+ * Override: set PILENS_DATA_DIR=/some/path — each project gets its own
18
+ * subdirectory named after a sanitized form of its absolute path, e.g.
19
+ * PILENS_DATA_DIR=.pi/harness/.lens/projects
20
+ * → .pi/harness/.lens/projects/home-user-myapp/
21
+ */
22
+ export function getProjectDataDir(cwd: string): string {
23
+ const configuredBase = process.env.PILENS_DATA_DIR?.trim();
24
+ const base =
25
+ configuredBase ||
26
+ path.join(
27
+ path.resolve(cwd || process.cwd()),
28
+ ".pi",
29
+ "harness",
30
+ ".lens",
31
+ "projects",
32
+ );
33
+ const normalized = normalizeFilePath(path.resolve(cwd));
34
+ const slug = normalized
35
+ .replace(/^[a-z]:/i, "") // strip Windows drive letter
36
+ .replace(/\/+/g, "-") // separators → dashes
37
+ .replace(/[^A-Za-z0-9-]/g, "") // strip anything else
38
+ .replace(/^-+/, "") // trim leading dashes
39
+ .replace(/-+$/, ""); // trim trailing dashes
40
+ return path.join(base.trim(), slug || "default");
41
+ }
42
+
43
+ /**
44
+ * Directories to exclude from all scans (build outputs, dependencies, caches).
45
+ * Used consistently across all scanners to avoid noise from generated files.
46
+ */
47
+ export const EXCLUDED_DIRS = [
48
+ "node_modules",
49
+ ".git",
50
+ "dist",
51
+ "build",
52
+ ".turbo",
53
+ ".cache",
54
+ "target",
55
+ "out",
56
+ ".parcel-cache",
57
+ ".svelte-kit",
58
+ ".nuxt",
59
+ ".yarn",
60
+ ".pnpm-store",
61
+ ".gradle",
62
+ ".next",
63
+ ".pi", // pi agent directory, including harness lens runtime data
64
+ ".ruff_cache", // Python linter cache
65
+ ".worktrees",
66
+ ".claude",
67
+ ".codex",
68
+ ".rescue",
69
+ ".agents",
70
+ ".gstack",
71
+ ".superpowers",
72
+ ".guardrails",
73
+ ".playwright-cli",
74
+ ".playwright-mcp",
75
+ ".vscode",
76
+ "venv",
77
+ ".venv",
78
+ "coverage",
79
+ "__pycache__",
80
+ ".tox",
81
+ ".pytest_cache",
82
+ "*.dSYM",
83
+ // Vendored upstream source conventions — universally too large to scan
84
+ "vendor", // Go modules, PHP Composer, Ruby Bundler
85
+ "third_party", // Chromium/Google convention (llama.cpp, sherpa-onnx, gRPC, TF)
86
+ "third-party",
87
+ "vendors",
88
+ ];
89
+
90
+ export interface GitignorePattern {
91
+ pattern: string;
92
+ negated: boolean;
93
+ directoryOnly: boolean;
94
+ rooted: boolean;
95
+ hasSlash: boolean;
96
+ }
97
+
98
+ export interface ProjectIgnoreMatcher {
99
+ rootDir: string;
100
+ patterns: GitignorePattern[];
101
+ isIgnored(filePath: string, isDirectory?: boolean): boolean;
102
+ }
103
+
104
+ function resolveGitIgnoreRoot(startDir: string): string {
105
+ const fallback = path.resolve(startDir);
106
+ let current = fallback;
107
+ while (true) {
108
+ if (fs.existsSync(path.join(current, ".git"))) return current;
109
+ const parent = path.dirname(current);
110
+ if (parent === current) return fallback;
111
+ current = parent;
112
+ }
113
+ }
114
+
115
+ function collapseSlashes(value: string): string {
116
+ let out = "";
117
+ let previousWasSlash = false;
118
+ for (const ch of value) {
119
+ if (ch === "/") {
120
+ if (!previousWasSlash) out += ch;
121
+ previousWasSlash = true;
122
+ continue;
123
+ }
124
+ out += ch === "\\" ? "/" : ch;
125
+ previousWasSlash = false;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function stripLeadingDotSlash(value: string): string {
131
+ return value.startsWith("./") ? value.slice(2) : value;
132
+ }
133
+
134
+ function stripTrailingSlashes(value: string): string {
135
+ let end = value.length;
136
+ while (end > 0 && value[end - 1] === "/") end -= 1;
137
+ return value.slice(0, end);
138
+ }
139
+
140
+ function stripLeadingSlashes(value: string): string {
141
+ let start = 0;
142
+ while (start < value.length && value[start] === "/") start += 1;
143
+ return value.slice(start);
144
+ }
145
+
146
+ function normalizeIgnorePath(value: string): string {
147
+ return collapseSlashes(stripLeadingDotSlash(value));
148
+ }
149
+
150
+ function stripTrailingSpaces(value: string): string {
151
+ // Good-enough gitignore whitespace handling: unescaped trailing spaces are ignored.
152
+ let end = value.length;
153
+ while (end > 0 && value[end - 1] === " " && value[end - 2] !== "\\") end -= 1;
154
+ return value.slice(0, end).replace(/\\ /g, " ");
155
+ }
156
+
157
+ function parseGitignoreContent(content: string): GitignorePattern[] {
158
+ const patterns: GitignorePattern[] = [];
159
+ for (const rawLine of content.split(/\r?\n/)) {
160
+ let line = stripTrailingSpaces(rawLine.trimStart());
161
+ if (!line || line.startsWith("#")) continue;
162
+ let negated = false;
163
+ if (line.startsWith("!")) {
164
+ negated = true;
165
+ line = line.slice(1);
166
+ }
167
+ line = normalizeIgnorePath(line);
168
+ if (!line) continue;
169
+
170
+ const directoryOnly = line.endsWith("/");
171
+ if (directoryOnly) line = stripTrailingSlashes(line);
172
+ const rooted = line.startsWith("/");
173
+ if (rooted) line = stripLeadingSlashes(line);
174
+ if (!line) continue;
175
+
176
+ patterns.push({
177
+ pattern: line,
178
+ negated,
179
+ directoryOnly,
180
+ rooted,
181
+ hasSlash: line.includes("/"),
182
+ });
183
+ }
184
+ return patterns;
185
+ }
186
+
187
+ function expandGitignorePattern(pattern: GitignorePattern): string[] {
188
+ const body = pattern.pattern;
189
+ if (pattern.directoryOnly) {
190
+ if (pattern.rooted || pattern.hasSlash) return [body, `${body}/**`];
191
+ return [body, `${body}/**`, `**/${body}`, `**/${body}/**`];
192
+ }
193
+ if (pattern.rooted || pattern.hasSlash) return [body];
194
+ return [body, `**/${body}`];
195
+ }
196
+
197
+ function matchesGitignorePattern(
198
+ pattern: GitignorePattern,
199
+ relativePath: string,
200
+ isDirectory: boolean,
201
+ ): boolean {
202
+ const candidate = stripLeadingSlashes(normalizeIgnorePath(relativePath));
203
+ if (!candidate) return false;
204
+ const candidates = isDirectory ? [candidate, `${candidate}/`] : [candidate];
205
+ const options = { dot: true, nocase: process.platform === "win32" };
206
+ return expandGitignorePattern(pattern).some((expanded) => {
207
+ if (isDirectory && expanded.endsWith("/**")) {
208
+ const prefix = expanded.slice(0, -3);
209
+ if (candidate === prefix || candidate.startsWith(`${prefix}/`))
210
+ return true;
211
+ }
212
+ return candidates.some((value) => minimatch(value, expanded, options));
213
+ });
214
+ }
215
+
216
+ export function readGitignorePatterns(rootDir: string): GitignorePattern[] {
217
+ const gitignorePath = path.join(rootDir, ".gitignore");
218
+ try {
219
+ return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8"));
220
+ } catch {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ function ancestorDirsBetween(rootDir: string, targetDir: string): string[] {
226
+ const relative = path.relative(rootDir, targetDir);
227
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return [];
228
+ const dirs = [rootDir];
229
+ if (!relative) return dirs;
230
+ let current = rootDir;
231
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
232
+ current = path.join(current, segment);
233
+ dirs.push(current);
234
+ }
235
+ return dirs;
236
+ }
237
+
238
+ function buildProjectIgnoreMatcher(
239
+ resolvedRoot: string,
240
+ patterns: GitignorePattern[],
241
+ ): ProjectIgnoreMatcher {
242
+ const nestedCache = new Map<
243
+ string,
244
+ { gitignoreMtimeMs: number; patterns: GitignorePattern[] }
245
+ >();
246
+ const patternsForDir = (dir: string): GitignorePattern[] => {
247
+ if (dir === resolvedRoot) return patterns;
248
+ const gitignoreMtime = gitignoreMtimeMs(dir);
249
+ const cached = nestedCache.get(dir);
250
+ if (cached?.gitignoreMtimeMs === gitignoreMtime) return cached.patterns;
251
+ const nextPatterns = readGitignorePatterns(dir);
252
+ nestedCache.set(dir, {
253
+ gitignoreMtimeMs: gitignoreMtime,
254
+ patterns: nextPatterns,
255
+ });
256
+ return nextPatterns;
257
+ };
258
+
259
+ return {
260
+ rootDir: resolvedRoot,
261
+ patterns,
262
+ isIgnored(filePath: string, isDirectory = false): boolean {
263
+ const resolved = path.resolve(filePath);
264
+ const rootRelative = path.relative(resolvedRoot, resolved);
265
+ if (
266
+ !rootRelative ||
267
+ rootRelative.startsWith("..") ||
268
+ path.isAbsolute(rootRelative)
269
+ ) {
270
+ return false;
271
+ }
272
+
273
+ let ignored = false;
274
+ const patternDirs = ancestorDirsBetween(
275
+ resolvedRoot,
276
+ path.dirname(resolved),
277
+ );
278
+ for (const dir of patternDirs) {
279
+ const dirPatterns = patternsForDir(dir);
280
+ if (dirPatterns.length === 0) continue;
281
+ const relative = path.relative(dir, resolved);
282
+ const normalized = normalizeIgnorePath(relative);
283
+ for (const pattern of dirPatterns) {
284
+ if (!matchesGitignorePattern(pattern, normalized, isDirectory))
285
+ continue;
286
+ ignored = !pattern.negated;
287
+ }
288
+ }
289
+ return ignored;
290
+ },
291
+ };
292
+ }
293
+
294
+ export function createProjectIgnoreMatcher(
295
+ rootDir: string,
296
+ extraPatterns: string[] = [],
297
+ ): ProjectIgnoreMatcher {
298
+ const resolvedRoot = resolveGitIgnoreRoot(rootDir);
299
+ const patterns = [
300
+ ...readGitignorePatterns(resolvedRoot),
301
+ ...parseGitignoreContent(extraPatterns.join("\n")),
302
+ ];
303
+ return buildProjectIgnoreMatcher(resolvedRoot, patterns);
304
+ }
305
+
306
+ const projectIgnoreMatcherCache = new Map<
307
+ string,
308
+ { gitignoreMtimeMs: number; matcher: ProjectIgnoreMatcher }
309
+ >();
310
+
311
+ function gitignoreMtimeMs(rootDir: string): number {
312
+ try {
313
+ return fs.statSync(path.join(rootDir, ".gitignore")).mtimeMs;
314
+ } catch {
315
+ return -1;
316
+ }
317
+ }
318
+
319
+ export function getProjectIgnoreMatcher(rootDir: string): ProjectIgnoreMatcher {
320
+ const resolvedRoot = resolveGitIgnoreRoot(rootDir);
321
+ const gitignoreMtime = gitignoreMtimeMs(resolvedRoot);
322
+ const cached = projectIgnoreMatcherCache.get(resolvedRoot);
323
+ if (cached?.gitignoreMtimeMs === gitignoreMtime) return cached.matcher;
324
+
325
+ const matcher = createProjectIgnoreMatcher(resolvedRoot);
326
+ projectIgnoreMatcherCache.set(resolvedRoot, {
327
+ gitignoreMtimeMs: gitignoreMtime,
328
+ matcher,
329
+ });
330
+ return matcher;
331
+ }
332
+
333
+ export function isPathIgnoredByProject(
334
+ filePath: string,
335
+ rootDir: string,
336
+ isDirectory = false,
337
+ ): boolean {
338
+ return getProjectIgnoreMatcher(rootDir).isIgnored(filePath, isDirectory);
339
+ }
340
+
341
+ export function getProjectIgnoreGlobs(rootDir: string): string[] {
342
+ return readGitignorePatterns(rootDir)
343
+ .filter((pattern) => !pattern.negated)
344
+ .flatMap((pattern) => expandGitignorePattern(pattern));
345
+ }
346
+
347
+ /**
348
+ * Read simple directory-name entries from a root .gitignore.
349
+ *
350
+ * Prefer createProjectIgnoreMatcher() for path-aware gitignore matching. This
351
+ * helper is kept for callers/tests that only need simple directory names.
352
+ */
353
+ export function readGitignoreDirs(rootDir: string): string[] {
354
+ return readGitignorePatterns(rootDir)
355
+ .filter(
356
+ (entry) =>
357
+ !entry.negated &&
358
+ !entry.pattern.includes("*") &&
359
+ !entry.pattern.includes("?") &&
360
+ !entry.pattern.includes("[") &&
361
+ !entry.pattern.includes("/"),
362
+ )
363
+ .map((entry) => entry.pattern);
364
+ }
365
+
366
+ function globToRegExp(glob: string): RegExp {
367
+ const escaped = glob
368
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
369
+ .replace(/\*/g, ".*")
370
+ .replace(/\?/g, ".");
371
+ return new RegExp(`^${escaped}$`, "i");
372
+ }
373
+
374
+ /**
375
+ * Match directory name against exclusion patterns.
376
+ * Supports exact names and lightweight glob patterns (for example `*.dSYM`).
377
+ */
378
+ export function isExcludedDirName(
379
+ dirName: string,
380
+ extraPatterns: string[] = [],
381
+ ): boolean {
382
+ const candidate = dirName.trim();
383
+ if (!candidate) return false;
384
+
385
+ const patterns = [...EXCLUDED_DIRS, ...extraPatterns]
386
+ .map((p) => p.trim())
387
+ .filter((p) => p.length > 0);
388
+ const candidateLower = candidate.toLowerCase();
389
+
390
+ for (const pattern of patterns) {
391
+ const patLower = pattern.toLowerCase();
392
+ if (!patLower.includes("*") && !patLower.includes("?")) {
393
+ if (candidateLower === patLower) return true;
394
+ continue;
395
+ }
396
+ if (globToRegExp(pattern).test(candidate)) return true;
397
+ }
398
+
399
+ return false;
400
+ }
401
+
402
+ /**
403
+ * Convert excluded directory names into glob patterns used by scanners.
404
+ */
405
+ export function getExcludedDirGlobs(): string[] {
406
+ return EXCLUDED_DIRS.map((dir) => `**/${dir}/**`);
407
+ }
408
+
409
+ /**
410
+ * Shared Knip ignore patterns derived from central exclusions.
411
+ */
412
+ export function getKnipIgnorePatterns(): string[] {
413
+ return [
414
+ ...getExcludedDirGlobs(),
415
+ "**/*.test.ts",
416
+ "**/*.test.tsx",
417
+ "**/*.test.js",
418
+ "**/*.test.jsx",
419
+ "**/*.spec.ts",
420
+ "**/*.spec.tsx",
421
+ "**/*.spec.js",
422
+ "**/*.spec.jsx",
423
+ "**/*.poc.test.ts",
424
+ "**/*.poc.test.tsx",
425
+ "**/__tests__/**",
426
+ "**/tests/**",
427
+ ];
428
+ }
429
+
430
+ /**
431
+ * Spawn a command and detect whether it modified a file on disk.
432
+ * Returns 1 if the file content changed after the command ran, 0 otherwise.
433
+ * Useful for auto-fix tools (ESLint, Stylelint, RuboCop, etc.).
434
+ */
435
+ export async function detectFileChangedAfterCommand(
436
+ filePath: string,
437
+ command: string,
438
+ args: string[],
439
+ cwd: string,
440
+ ignoreStatuses: number[] = [],
441
+ ): Promise<number> {
442
+ let before = "";
443
+ try {
444
+ before = fs.readFileSync(filePath, "utf-8");
445
+ } catch {
446
+ return 0;
447
+ }
448
+
449
+ const result = await safeSpawnAsync(command, args, {
450
+ timeout: 30000,
451
+ cwd,
452
+ });
453
+ if (result.error) return 0;
454
+ if (result.status !== 0 && !ignoreStatuses.includes(result.status ?? -1)) {
455
+ return 0;
456
+ }
457
+
458
+ try {
459
+ const after = fs.readFileSync(filePath, "utf-8");
460
+ return before !== after ? 1 : 0;
461
+ } catch {
462
+ return 0;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Check if file path is a test/fixture/mock file.
468
+ * Used by secrets scanner, rate command, and dispatch runners
469
+ * to skip these files (false positives on fake credentials, etc).
470
+ */
471
+ export function isTestFile(filePath: string): boolean {
472
+ const normalized = filePath.replace(/\\/g, "/");
473
+ return (
474
+ normalized.includes(".test.") ||
475
+ normalized.includes(".spec.") ||
476
+ normalized.includes("/test/") ||
477
+ normalized.includes("/tests/") ||
478
+ normalized.includes("__tests__/") ||
479
+ normalized.includes("test-utils") ||
480
+ normalized.startsWith("test-") ||
481
+ normalized.includes(".fixture.") ||
482
+ normalized.includes(".mock.")
483
+ );
484
+ }