@1aboveio/skills 1.0.10 → 1.0.12

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 (214) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +298 -216
  3. package/README.md +3 -3
  4. package/package.json +10 -8
  5. package/skills/app-dev/airflow/SKILL.md +1 -1
  6. package/skills/app-dev/android-foundation/SKILL.md +35 -37
  7. package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
  8. package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
  9. package/skills/app-dev/android-foundation/references/setup.md +24 -9
  10. package/skills/app-dev/android-foundation/references/workspace.md +81 -0
  11. package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  12. package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  13. package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  14. package/skills/app-dev/app-debug/SKILL.md +1 -1
  15. package/skills/app-dev/better-auth/SKILL.md +1 -1
  16. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  17. package/skills/app-dev/data-quality/SKILL.md +1 -1
  18. package/skills/app-dev/ios-foundation/SKILL.md +15 -10
  19. package/skills/app-dev/ios-foundation/references/setup.md +25 -9
  20. package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
  21. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
  22. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
  23. package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
  24. package/skills/app-dev/monorepo/SKILL.md +1 -1
  25. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  26. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  27. package/skills/app-dev/pyspark/SKILL.md +1 -1
  28. package/skills/app-dev/python-backend/SKILL.md +1 -1
  29. package/skills/app-dev/shadcn/SKILL.md +1 -1
  30. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  31. package/skills/cicd/cloud-build/SKILL.md +1 -1
  32. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  33. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  34. package/skills/cicd/docker/SKILL.md +1 -1
  35. package/skills/cicd/github-actions/SKILL.md +1 -1
  36. package/skills/cicd/github-flow/SKILL.md +1 -1
  37. package/skills/cicd/google-cloud/SKILL.md +1 -1
  38. package/skills/cicd/mergify/SKILL.md +1 -1
  39. package/skills/cicd/podman/SKILL.md +1 -1
  40. package/skills/content/article-review/SKILL.md +1 -1
  41. package/skills/content/article-to-image/SKILL.md +1 -1
  42. package/skills/content/flowchart-svg/SKILL.md +1 -1
  43. package/skills/content/image-gen/SKILL.md +1 -1
  44. package/skills/content/mermaid/SKILL.md +1 -1
  45. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  46. package/skills/content/wechat-publisher/SKILL.md +1 -1
  47. package/skills/content/wechat-search/SKILL.md +1 -1
  48. package/skills/content/xai-search/SKILL.md +1 -1
  49. package/skills/core/distill-writing/SKILL.md +3 -3
  50. package/skills/core/distill-writing/references/extraction.md +1 -1
  51. package/skills/core/doc-to-md/SKILL.md +1 -1
  52. package/skills/core/editorial-review/SKILL.md +82 -12
  53. package/skills/core/editorial-review/references/checklist.md +26 -1
  54. package/skills/core/house-style/SKILL.md +8 -3
  55. package/skills/core/house-style/references/analytical-reporting.md +123 -0
  56. package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
  57. package/skills/core/house-style/references/analytical-writing-research.md +129 -0
  58. package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
  59. package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
  60. package/skills/core/house-style/references/document-workflow.md +193 -0
  61. package/skills/core/house-style/references/initialization.md +66 -0
  62. package/skills/core/house-style/references/persistence.md +9 -4
  63. package/skills/core/house-style/references/rules.md +11 -4
  64. package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
  65. package/skills/core/house-style/templates/analytical-report.md +54 -0
  66. package/skills/core/house-style/templates/gap-analysis.md +1 -1
  67. package/skills/core/plain-language/SKILL.md +2 -2
  68. package/skills/core/polish-cn/SKILL.md +9 -2
  69. package/skills/core/reg-parser/SKILL.md +3 -3
  70. package/skills/core/terminology/SKILL.md +8 -4
  71. package/skills/core/write-doc/SKILL.md +40 -151
  72. package/skills/core/write-report/SKILL.md +129 -0
  73. package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
  74. package/skills/engineering/better-review/SKILL.md +37 -37
  75. package/skills/engineering/better-review/references/passes.md +35 -41
  76. package/skills/engineering/better-review/references/result.md +10 -9
  77. package/skills/engineering/e2e-test/SKILL.md +17 -17
  78. package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
  79. package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
  80. package/skills/engineering/e2e-test/references/reviewing.md +9 -11
  81. package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
  82. package/skills/engineering/engineering/SKILL.md +201 -0
  83. package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
  84. package/skills/engineering/engineering/references/feature-delivery.md +8 -0
  85. package/skills/engineering/engineering/references/init-scope.md +45 -0
  86. package/skills/engineering/engineering/references/scope-management.md +85 -0
  87. package/skills/engineering/engineering/scripts/init.mjs +160 -0
  88. package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
  89. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  90. package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
  91. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  92. package/skills/engineering/ensure-coverage/SKILL.md +22 -109
  93. package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
  94. package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
  95. package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
  96. package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
  97. package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
  98. package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
  99. package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
  100. package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
  101. package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
  102. package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
  103. package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
  104. package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
  105. package/skills/engineering/golden-path/SKILL.md +1 -1
  106. package/skills/engineering/harness-runtime/SKILL.md +3 -1
  107. package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
  108. package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
  109. package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
  110. package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
  111. package/skills/engineering/resolve-issues/SKILL.md +40 -61
  112. package/skills/engineering/resolve-issues/references/ci.md +13 -3
  113. package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
  114. package/skills/engineering/resolve-issues/references/done.md +11 -2
  115. package/skills/engineering/resolve-issues/references/expire.md +9 -2
  116. package/skills/engineering/resolve-issues/references/implementation.md +33 -17
  117. package/skills/engineering/resolve-issues/references/preflight.md +3 -1
  118. package/skills/engineering/resolve-issues/references/principles.md +52 -0
  119. package/skills/engineering/resolve-issues/references/review.md +25 -15
  120. package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
  121. package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
  122. package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
  123. package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
  124. package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
  125. package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
  126. package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
  127. package/skills/engineering/resolve-release/SKILL.md +1 -1
  128. package/skills/engineering/rush-issues/SKILL.md +7 -5
  129. package/skills/engineering/rush-release/SKILL.md +1 -1
  130. package/skills/engineering/test-quality/SKILL.md +19 -4
  131. package/skills/engineering/test-quality/references/lint.md +1 -1
  132. package/skills/engineering/test-quality/references/principles.md +2 -2
  133. package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
  134. package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
  135. package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
  136. package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
  137. package/skills/risk-compliance/contract-review/SKILL.md +6 -1
  138. package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
  139. package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
  140. package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
  141. package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
  142. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
  143. package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
  144. package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
  145. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
  146. package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
  147. package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
  148. package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
  149. package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
  150. package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
  151. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
  152. package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
  153. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
  154. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
  155. package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
  156. package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
  157. package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
  158. package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
  159. package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
  160. package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
  161. package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
  162. package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
  163. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
  164. package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
  165. package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
  166. package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
  167. package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
  168. package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
  169. package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
  170. package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
  171. package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
  172. package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
  173. package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
  174. package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
  175. package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
  176. package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
  177. package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
  178. package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
  179. package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
  180. package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
  181. package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
  182. package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
  183. package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
  184. package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
  185. package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
  186. package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
  187. package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
  188. package/skills/engineering/contributing/SKILL.md +0 -70
  189. package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
  190. package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
  191. package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
  192. package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
  193. package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
  194. package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
  195. package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
  196. package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
  197. package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
  198. package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
  199. package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
  200. package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
  201. /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
  202. /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
  203. /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
  204. /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
  205. /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
  206. /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
  207. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
  208. /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
  209. /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
  210. /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
  211. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
  212. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
  213. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
  214. /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
@@ -0,0 +1,2154 @@
1
+ // Shared owned-workspace mechanics for workflow and standalone native callers.
2
+ //
3
+ // This module is deliberately a small support module for cleanup-runs.mjs. It does NOT
4
+ // write task-plan.json: the coordinator owns every plan transition through update_plan.
5
+ // Every exported operation returns observations and the next lifecycle value as JSON; the
6
+ // coordinator decides whether to persist it.
7
+ //
8
+ // Ownership authority is the existing task plan. A unit owns exactly what the plan records
9
+ // in `unit.workspace` plus `unit.lifecycle.resources`; anything else under a run is
10
+ // inventory only and is never removed by a glob or by age.
11
+ //
12
+ // Lifecycle shape (version 1):
13
+ //
14
+ // lifecycle: {
15
+ // version: 1,
16
+ // workspace: {
17
+ // path, kind: 'worktree' | 'clone', state: 'preparing' | 'ready',
18
+ // baseSha: <exact 40 or 64 hex>,
19
+ // identity: { realPath, dev, ino, gitCommonDir, headSha }
20
+ // },
21
+ // resources: [
22
+ // { path, kind: 'directory' | 'clone' | 'worktree',
23
+ // role: 'scratch' | 'tools' | 'review', disposable: true,
24
+ // state: 'preparing' | 'ready',
25
+ // identity: { realPath, dev, ino, gitCommonDir? } }
26
+ // ],
27
+ // cleanup: { state: 'pending' | 'applying' | 'complete' | 'retained',
28
+ // headSha, reason?, completedAt?, receipt? }
29
+ // }
30
+ //
31
+ // Safety rules are fail-closed. A path we cannot identify, a tree we cannot read, a worker
32
+ // recorded for this owner or overlapping paths, a process still using the tree, unknown ignored files,
33
+ // nested repositories, stashes, rebases/merges in progress, any unpublished local ref or
34
+ // reflog commit, or evidence that still lives inside the workspace all retain the whole
35
+ // environment. There is no ignored-file stripping.
36
+
37
+ import { execFileSync } from 'node:child_process'
38
+ import {
39
+ existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
40
+ realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync,
41
+ } from 'node:fs'
42
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
43
+
44
+ export const LIFECYCLE_VERSION = 1
45
+ export const CLEANUP_STATES = new Set(['pending', 'applying', 'complete', 'retained'])
46
+ export const WORKSPACE_KINDS = new Set(['worktree', 'clone'])
47
+ export const WORKSPACE_STATES = new Set(['preparing', 'ready'])
48
+ export const RESOURCE_KINDS = new Set(['directory', 'clone', 'worktree'])
49
+ export const RESOURCE_ROLES = new Set(['scratch', 'tools', 'review'])
50
+ export const RECEIPT_VERSION = 1
51
+
52
+ // Bounds for the read-only inventory and for nested-repository / liveness walks. They are
53
+ // deliberately small: this helper runs at the start of a run, not in a filesystem audit.
54
+ export const INVENTORY_MAX_ENTRIES = 5000
55
+ export const INVENTORY_MAX_DEPTH = 8
56
+ const NESTED_SCAN_MAX_ENTRIES = 20000
57
+ const NESTED_SCAN_MAX_DEPTH = 12
58
+ const PROC_SCAN_MAX_PIDS = 5000
59
+ const PROC_SCAN_MAX_FDS = 20000
60
+ const LSOF_TIMEOUT_MS = 3000
61
+
62
+ const RESOLVE_PATH = realpathSync.native ?? realpathSync
63
+ const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
64
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0
65
+
66
+ export class LifecycleError extends Error {
67
+ constructor(code, message) {
68
+ super(message)
69
+ this.code = code
70
+ }
71
+ }
72
+
73
+ // ---- paths and identity ----
74
+
75
+ export function normPath(input) {
76
+ const s = String(input ?? '')
77
+ const stripped = s.replace(/\/+$/, '')
78
+ return stripped || (s.startsWith('/') ? '/' : '')
79
+ }
80
+
81
+ export function containsPath(parent, child) {
82
+ const p = normPath(parent)
83
+ const c = normPath(child)
84
+ if (!p || !c) return false
85
+ return p === c || (p === '/' ? c.startsWith('/') : c.startsWith(`${p}${sep}`))
86
+ }
87
+
88
+ export function isHexSha(value) {
89
+ return typeof value === 'string' && (/^[0-9a-f]{40}$/.test(value) || /^[0-9a-f]{64}$/.test(value))
90
+ }
91
+
92
+ function pathIdentity(input) {
93
+ const recorded = normPath(input)
94
+ const unresolved = (reason) => ({ recorded, real: null, resolved: false, exists: false, isSymlink: false, reason })
95
+ if (!recorded) return unresolved('an empty path names no tree')
96
+ if (!isAbsolute(recorded)) return unresolved(`${recorded} is not an absolute path`)
97
+
98
+ let isSymlink = false
99
+ try {
100
+ isSymlink = lstatSync(recorded).isSymbolicLink()
101
+ } catch { /* missing is handled below */ }
102
+ if (isSymlink) return unresolved(`${recorded} is a symbolic link; owned workspaces and resources must be real directories`)
103
+
104
+ const trailing = []
105
+ let cur = recorded
106
+ for (let depth = 0; depth < 64; depth++) {
107
+ try {
108
+ const real = RESOLVE_PATH(cur)
109
+ return {
110
+ recorded,
111
+ real: normPath(trailing.length ? join(real, ...trailing) : real) || '/',
112
+ resolved: true,
113
+ exists: trailing.length === 0 && existsSync(recorded),
114
+ isSymlink: false,
115
+ reason: null,
116
+ }
117
+ } catch (error) {
118
+ if (error?.code !== 'ENOENT') return unresolved(`cannot resolve ${cur}: ${error?.code ?? error?.message ?? 'unknown error'}`)
119
+ let dangling = false
120
+ try { dangling = lstatSync(cur).isSymbolicLink() } catch { dangling = false }
121
+ if (dangling) return unresolved(`${cur} is a symbolic link whose target does not exist`)
122
+ const parent = dirname(cur)
123
+ if (!parent || parent === cur) return unresolved(`no existing ancestor of ${recorded} could be resolved`)
124
+ trailing.unshift(cur.slice(parent.length + 1))
125
+ cur = parent
126
+ }
127
+ }
128
+ return unresolved(`gave up resolving ${recorded} after 64 levels`)
129
+ }
130
+
131
+ // A symlink anywhere inside the managed root would be a second spelling of an owned path.
132
+ // Ancestors outside the root (macOS `/tmp` -> `/private/tmp`) are normal and are handled by
133
+ // comparing real paths; only the final path and segments below the root are rejected.
134
+ function symlinkBelow(root, candidate) {
135
+ const rootReal = pathIdentity(root)
136
+ const candidateReal = pathIdentity(candidate)
137
+ if (!rootReal.resolved || !candidateReal.resolved) return true
138
+ if (!containsPath(rootReal.real, candidateReal.real)) return true
139
+ if (candidateReal.real === rootReal.real) return false
140
+ // Find the original spelling of the root by walking ancestors, then inspect
141
+ // each lexical child. This permits OS aliases above the root (/var on macOS)
142
+ // without letting realpath erase an owned-root symlink such as run/alias/cache.
143
+ let ancestor = resolve(candidate)
144
+ const tail = []
145
+ while (pathIdentity(ancestor).real !== rootReal.real) {
146
+ const parent = dirname(ancestor)
147
+ if (parent === ancestor) return true
148
+ tail.unshift(ancestor.slice(parent.length + 1))
149
+ ancestor = parent
150
+ }
151
+ try { if (lstatSync(ancestor).isSymbolicLink()) return true } catch { return true }
152
+ let cur = ancestor
153
+ for (const part of tail) {
154
+ cur = join(cur, part)
155
+ try {
156
+ if (lstatSync(cur).isSymbolicLink()) return true
157
+ } catch (error) {
158
+ // A path that does not exist yet is not a symlink. Only an unreadable existing
159
+ // segment is uncertainty, and uncertainty is refused.
160
+ if (error?.code === 'ENOENT') return false
161
+ return true
162
+ }
163
+ }
164
+ return false
165
+ }
166
+
167
+ function fileIdentity(path) {
168
+ const st = lstatSync(path)
169
+ return {
170
+ realPath: pathIdentity(path).real,
171
+ dev: typeof st.dev === 'number' ? st.dev : null,
172
+ ino: typeof st.ino === 'number' ? st.ino : null,
173
+ }
174
+ }
175
+
176
+ function sameIdentity(recorded, current) {
177
+ if (!isObject(recorded) || !isObject(current)) return false
178
+ if (recorded.realPath !== current.realPath) return false
179
+ for (const key of ['dev', 'ino']) {
180
+ if (!Number.isSafeInteger(recorded[key]) || recorded[key] !== current[key]) return false
181
+ }
182
+ return true
183
+ }
184
+
185
+ // ---- git ----
186
+
187
+ function git(dir, args, { timeout = 30000, maxBuffer = 16 * 1024 * 1024 } = {}) {
188
+ try {
189
+ const out = execFileSync('git', ['-C', dir, ...args], {
190
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout, maxBuffer,
191
+ // A dry run must not refresh the index merely by reading it. This disables optional
192
+ // locks/writes; commands that must write (fetch, worktree add/remove) still do.
193
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' },
194
+ })
195
+ return { ok: true, out: String(out).trimEnd() }
196
+ } catch (error) {
197
+ return { ok: false, out: String(error?.stdout ?? '').trimEnd(), err: String(error?.stderr ?? error?.message ?? '').trim() }
198
+ }
199
+ }
200
+
201
+ function gitToplevel(dir) {
202
+ const r = git(dir, ['rev-parse', '--show-toplevel'])
203
+ return r.ok ? r.out : null
204
+ }
205
+
206
+ function gitCommonDir(dir) {
207
+ const r = git(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
208
+ return r.ok ? normPath(r.out) : null
209
+ }
210
+
211
+ function resolveCommit(repo, ref) {
212
+ if (!nonEmpty(ref)) return null
213
+ const r = git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
214
+ return r.ok && isHexSha(r.out) ? r.out : null
215
+ }
216
+
217
+ function currentHead(dir) {
218
+ const r = git(dir, ['rev-parse', '--verify', 'HEAD'])
219
+ return r.ok && isHexSha(r.out) ? r.out : null
220
+ }
221
+
222
+ function currentBranch(dir) {
223
+ const r = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
224
+ return r.ok ? r.out : null
225
+ }
226
+
227
+ function remoteUrl(dir, name = 'origin') {
228
+ // Preserve repository identity before Git's insteadOf transport rewrite.
229
+ const r = git(dir, ['config', '--get', `remote.${name}.url`])
230
+ return r.ok && nonEmpty(r.out) ? r.out : null
231
+ }
232
+
233
+ function sameRepoUrl(a, b) {
234
+ if (!a || !b) return false
235
+ const clean = (value) => String(value).trim().replace(/^file:\/\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '')
236
+ const left = clean(a)
237
+ const right = clean(b)
238
+ if (left === right) return true
239
+ // Compare local paths as trees, so `/tmp/x` and `/private/tmp/x` are one remote.
240
+ try {
241
+ const l = pathIdentity(left)
242
+ const r = pathIdentity(right)
243
+ if (l.resolved && r.resolved) return l.real === r.real
244
+ } catch { /* fall through to the text compare */ }
245
+ return false
246
+ }
247
+
248
+ function slugFromUrl(url) {
249
+ if (!url) return null
250
+ const text = String(url).trim().replace(/\.git$/i, '').replace(/\/+$/, '')
251
+ const match = text.match(/(?:github\.com[:/])([^/]+)\/([^/]+)$/i)
252
+ return match ? `${match[1]}/${match[2]}` : null
253
+ }
254
+
255
+ function worktreeList(repo) {
256
+ const r = git(repo, ['worktree', 'list', '--porcelain'])
257
+ if (!r.ok) return null
258
+ return r.out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => normPath(l.slice('worktree '.length)))
259
+ }
260
+
261
+ function isRegisteredWorktree(repo, workspace) {
262
+ const list = worktreeList(repo)
263
+ if (!list) return false
264
+ const target = pathIdentity(workspace)
265
+ if (!target.resolved) return false
266
+ return list.some((entry) => {
267
+ const ident = pathIdentity(entry)
268
+ return ident.resolved && ident.real === target.real
269
+ })
270
+ }
271
+
272
+ // ---- plan reading, ownership, and validation ----
273
+
274
+ export function planPathFor(run) {
275
+ return join(resolve(run), 'task-plan.json')
276
+ }
277
+
278
+ export function readPlan(run) {
279
+ const path = planPathFor(run)
280
+ if (!existsSync(path)) throw new LifecycleError('plan-missing', `no task plan at ${path}`)
281
+ let plan
282
+ try {
283
+ plan = JSON.parse(readFileSync(path, 'utf8'))
284
+ } catch (error) {
285
+ throw new LifecycleError('plan-invalid', `cannot read ${path}: ${error.message}`)
286
+ }
287
+ if (!isObject(plan)) throw new LifecycleError('plan-invalid', `${path} is not a JSON object`)
288
+ if (plan.units !== undefined && !Array.isArray(plan.units)) throw new LifecycleError('plan-invalid', `${path} has a non-array units field`)
289
+ if (plan.issues !== undefined && !Array.isArray(plan.issues)) throw new LifecycleError('plan-invalid', `${path} has a non-array issues field`)
290
+ if (Array.isArray(plan.units) && Array.isArray(plan.issues) && JSON.stringify(plan.units) !== JSON.stringify(plan.issues)) {
291
+ throw new LifecycleError('plan-contradictory', `${path} has both units and issues, and they disagree; the plan does not say which roster owns the run`)
292
+ }
293
+ const units = Array.isArray(plan.units) ? plan.units : (Array.isArray(plan.issues) ? plan.issues : [])
294
+ if (!Array.isArray(units) || units.length === 0) throw new LifecycleError('plan-invalid', `${path} has no units`)
295
+ return { path, plan, units }
296
+ }
297
+
298
+ export function unitOf(plan, unitId) {
299
+ const units = Array.isArray(plan?.units) ? plan.units : (Array.isArray(plan?.issues) ? plan.issues : [])
300
+ const matches = units.filter((u) => isObject(u) && String(u.id) === String(unitId))
301
+ if (matches.length === 0) throw new LifecycleError('unit-missing', `unit ${unitId} is not in the plan`)
302
+ if (matches.length > 1) throw new LifecycleError('unit-duplicate', `unit ${unitId} appears ${matches.length} times in the plan`)
303
+ return matches[0]
304
+ }
305
+
306
+ export function planRevisionOf(plan) {
307
+ if (!Number.isSafeInteger(plan?.planRevision) || plan.planRevision < 0) {
308
+ throw new LifecycleError('revision-missing', 'the plan has no nonnegative integer planRevision; bind it through update_plan first')
309
+ }
310
+ return plan.planRevision
311
+ }
312
+
313
+ export function requireRevision(plan, revision) {
314
+ const expected = planRevisionOf(plan)
315
+ const actual = Number(revision)
316
+ if (!Number.isSafeInteger(actual) || actual < 0) throw new LifecycleError('revision-invalid', `--revision must be a nonnegative integer: ${JSON.stringify(revision)}`)
317
+ if (actual !== expected) {
318
+ throw new LifecycleError('revision-mismatch', `plan revision is ${expected}, not ${actual}; reread the plan and retry with its persisted revision`)
319
+ }
320
+ return expected
321
+ }
322
+
323
+ // The repository a run belongs to: plan.repoRoot when present, else the nearest ancestor
324
+ // holding `.resolve-issues/rush`. The run must actually sit under that root.
325
+ export function repositoryOf(plan, run) {
326
+ const runReal = pathIdentity(run)
327
+ if (!runReal.resolved) throw new LifecycleError('run-unresolved', `cannot resolve run directory ${run}: ${runReal.reason}`)
328
+ if (nonEmpty(plan?.repoRoot) && !isAbsolute(plan.repoRoot)) {
329
+ throw new LifecycleError('no-owner', `plan.repoRoot must be an absolute path, not ${JSON.stringify(plan.repoRoot)}`)
330
+ }
331
+ let repo = nonEmpty(plan?.repoRoot) ? resolve(plan.repoRoot) : null
332
+ if (!repo) {
333
+ const marker = `${sep}.resolve-issues${sep}rush${sep}`
334
+ const at = `${runReal.real}${sep}`.indexOf(marker)
335
+ if (at >= 0) repo = runReal.real.slice(0, at)
336
+ }
337
+ if (!repo) throw new LifecycleError('no-owner', `cannot tell which repository owns ${run}, and the plan records no repoRoot`)
338
+ const repoReal = pathIdentity(repo)
339
+ if (!repoReal.resolved) throw new LifecycleError('no-owner', `the repository root ${repo} does not resolve: ${repoReal.reason}`)
340
+ const runsRoot = pathIdentity(join(repoReal.real, '.resolve-issues', 'rush'))
341
+ if (!runsRoot.resolved || !containsPath(runsRoot.real, runReal.real) || !containsPath(repoReal.real, runReal.real)) {
342
+ throw new LifecycleError('no-owner', `${run} is not under ${join(repo, '.resolve-issues', 'rush')}`)
343
+ }
344
+ return repoReal.real
345
+ }
346
+
347
+ export function canonicalRunDir(repo, run) {
348
+ const runReal = pathIdentity(run)
349
+ if (!runReal.resolved) throw new LifecycleError('run-unresolved', `cannot resolve run directory ${run}: ${runReal.reason}`)
350
+ const runsRoot = pathIdentity(join(repo, '.resolve-issues', 'rush'))
351
+ if (!runsRoot.resolved || !containsPath(runsRoot.real, runReal.real)) {
352
+ throw new LifecycleError('containment', `${run} is outside ${join(repo, '.resolve-issues', 'rush')}`)
353
+ }
354
+ return runReal.real
355
+ }
356
+
357
+ const lifecycleResources = (unit) => {
358
+ const fromLifecycle = unit?.lifecycle?.resources
359
+ if (Array.isArray(fromLifecycle)) return fromLifecycle
360
+ if (Array.isArray(unit?.resources)) return unit.resources
361
+ return []
362
+ }
363
+
364
+ function validateLifecycleShape(lifecycle, unit) {
365
+ const errors = []
366
+ if (lifecycle === undefined || lifecycle === null) return errors
367
+ if (!isObject(lifecycle)) return [{ code: 'lifecycle-invalid', message: `unit ${unit} has a non-object lifecycle` }]
368
+ if (lifecycle.version !== LIFECYCLE_VERSION) errors.push({ code: 'lifecycle-version', message: `unit ${unit} lifecycle.version must be ${LIFECYCLE_VERSION}` })
369
+ const ws = lifecycle.workspace
370
+ if (!isObject(ws)) errors.push({ code: 'lifecycle-workspace', message: `unit ${unit} lifecycle.workspace is missing` })
371
+ else {
372
+ if (!WORKSPACE_KINDS.has(ws.kind)) errors.push({ code: 'lifecycle-workspace-kind', message: `unit ${unit} lifecycle.workspace.kind must be worktree or clone` })
373
+ if (!WORKSPACE_STATES.has(ws.state)) errors.push({ code: 'lifecycle-workspace-state', message: `unit ${unit} lifecycle.workspace.state must be preparing or ready` })
374
+ if (ws.baseSha !== undefined && !isHexSha(ws.baseSha)) errors.push({ code: 'lifecycle-base-sha', message: `unit ${unit} lifecycle.workspace.baseSha must be an exact 40 or 64 character hex SHA` })
375
+ if (ws.identity !== undefined && !isObject(ws.identity)) errors.push({ code: 'lifecycle-identity', message: `unit ${unit} lifecycle.workspace.identity must be an object` })
376
+ }
377
+ if (lifecycle.resources !== undefined && !Array.isArray(lifecycle.resources)) {
378
+ errors.push({ code: 'lifecycle-resources', message: `unit ${unit} lifecycle.resources must be an array` })
379
+ }
380
+ for (const [index, resource] of (Array.isArray(lifecycle.resources) ? lifecycle.resources : []).entries()) {
381
+ if (!isObject(resource)) { errors.push({ code: 'lifecycle-resource', message: `unit ${unit} lifecycle.resources[${index}] is not an object` }); continue }
382
+ if (!nonEmpty(resource.path) || !isAbsolute(resource.path)) errors.push({ code: 'lifecycle-resource-path', message: `unit ${unit} lifecycle.resources[${index}].path must be an absolute path` })
383
+ if (!RESOURCE_KINDS.has(resource.kind)) errors.push({ code: 'lifecycle-resource-kind', message: `unit ${unit} lifecycle.resources[${index}].kind must be directory, clone, or worktree` })
384
+ if (!RESOURCE_ROLES.has(resource.role)) errors.push({ code: 'lifecycle-resource-role', message: `unit ${unit} lifecycle.resources[${index}].role must be scratch, tools, or review` })
385
+ if (resource.disposable !== true) errors.push({ code: 'lifecycle-resource-disposable', message: `unit ${unit} lifecycle.resources[${index}].disposable must be true` })
386
+ if (!WORKSPACE_STATES.has(resource.state)) errors.push({ code: 'lifecycle-resource-state', message: `unit ${unit} lifecycle.resources[${index}].state must be preparing or ready` })
387
+ if (resource.identity !== undefined && !isObject(resource.identity)) errors.push({ code: 'lifecycle-resource-identity', message: `unit ${unit} lifecycle.resources[${index}].identity must be an object` })
388
+ }
389
+ const cleanup = lifecycle.cleanup
390
+ if (cleanup !== undefined) {
391
+ if (!isObject(cleanup)) errors.push({ code: 'lifecycle-cleanup', message: `unit ${unit} lifecycle.cleanup must be an object` })
392
+ else {
393
+ if (!CLEANUP_STATES.has(cleanup.state)) errors.push({ code: 'lifecycle-cleanup-state', message: `unit ${unit} lifecycle.cleanup.state must be pending, applying, complete, or retained` })
394
+ if (cleanup.headSha !== undefined && !isHexSha(cleanup.headSha)) errors.push({ code: 'lifecycle-cleanup-head', message: `unit ${unit} lifecycle.cleanup.headSha must be an exact 40 or 64 character hex SHA` })
395
+ }
396
+ }
397
+ return errors
398
+ }
399
+
400
+ // Validate ownership before any mutation. This runs for prepare and cleanup, and it only
401
+ // reads the plan and the filesystem.
402
+ export function validateUnit({ repo, run, plan, unit }) {
403
+ const errors = []
404
+ const unitId = String(unit?.id ?? '')
405
+ if (!isObject(unit)) throw new LifecycleError('unit-missing', 'unit is not an object')
406
+ if (!nonEmpty(unitId) || unitId.trim() !== unitId || /[\x00-\x1f\x7f]/.test(unitId) || unitId === '.' || unitId === '..' || unitId.includes('/') || unitId.includes('\\')) {
407
+ errors.push({ code: 'unit-id', message: `unit id ${JSON.stringify(unitId)} cannot name a workspace directory` })
408
+ }
409
+ if (!nonEmpty(unit.workspace) || !isAbsolute(unit.workspace)) {
410
+ errors.push({ code: 'workspace-missing', message: `unit ${unitId} has no absolute unit.workspace; plan the workspace before prepare` })
411
+ }
412
+ if (!nonEmpty(unit.branch)) errors.push({ code: 'branch-missing', message: `unit ${unitId} has no unit.branch` })
413
+ if (!nonEmpty(unit.base)) errors.push({ code: 'base-missing', message: `unit ${unitId} has no unit.base` })
414
+ errors.push(...validateLifecycleShape(unit.lifecycle, unitId))
415
+
416
+ const runDir = canonicalRunDir(repo, run)
417
+ const runIdent = pathIdentity(runDir)
418
+ if (!runIdent.resolved) {
419
+ errors.push({ code: 'run-unresolved', message: `run directory ${runDir} cannot be resolved: ${runIdent.reason}` })
420
+ return { errors, runDir }
421
+ }
422
+ const repoIdent = pathIdentity(repo)
423
+ if (!repoIdent.resolved) {
424
+ errors.push({ code: 'repo-unresolved', message: `repository ${repo} cannot be resolved: ${repoIdent.reason}` })
425
+ return { errors, runDir }
426
+ }
427
+
428
+ const allUnits = Array.isArray(plan?.units) ? plan.units : (Array.isArray(plan?.issues) ? plan.issues : [])
429
+ const seen = new Map()
430
+ const claim = (path, owner, role) => {
431
+ if (!nonEmpty(path) || !isAbsolute(path)) return
432
+ if (normPath(path) !== resolve(path)) {
433
+ errors.push({ code: 'path-spelling', message: `${role} ${path} must use a canonical spelling without dot-segment traversal` })
434
+ return
435
+ }
436
+ const ident = pathIdentity(path)
437
+ if (!ident.resolved) {
438
+ errors.push({ code: 'path-unresolved', message: `${role} ${path} cannot be resolved: ${ident.reason}` })
439
+ return
440
+ }
441
+ if (ident.real === repoIdent.real) errors.push({ code: 'project-root', message: `${role} ${path} is the project root; an owned workspace is not the repository checkout` })
442
+ if (containsPath(ident.real, repoIdent.real) && ident.real !== repoIdent.real) errors.push({ code: 'project-root', message: `${role} ${path} contains the project root` })
443
+ if (containsPath(repoIdent.real, ident.real) && ident.real !== repoIdent.real && !containsPath(runIdent.real, ident.real)) {
444
+ errors.push({ code: 'project-root', message: `${role} ${path} is inside the project checkout but outside the run directory` })
445
+ }
446
+ if (!containsPath(runIdent.real, ident.real)) errors.push({ code: 'containment', message: `${role} ${path} is outside the canonical run directory ${runDir}` })
447
+ if (ident.real === runIdent.real) errors.push({ code: 'broad-parent', message: `${role} ${path} is the run directory; only a unit's own workspace or resource may be removed` })
448
+ if (ident.real === '/' || containsPath(ident.real, runIdent.real)) errors.push({ code: 'broad-parent', message: `${role} ${path} contains the run directory or the filesystem root` })
449
+ if (symlinkBelow(runDir, path)) errors.push({ code: 'symlink', message: `${role} ${path} is a symbolic link, or passes through one below ${runDir}` })
450
+ const key = ident.real
451
+ for (const [claimed, claimant] of seen) {
452
+ if (claimant.owner !== owner && (containsPath(claimed, key) || containsPath(key, claimed))) {
453
+ errors.push({ code: 'overlapping-path', message: `${role} ${path} overlaps ${claimant.owner}'s ${claimed}` })
454
+ }
455
+ }
456
+ if (seen.has(key)) {
457
+ errors.push({ code: 'duplicate-path', message: `${role} ${path} is claimed by both ${seen.get(key).owner} and ${owner}` })
458
+ } else {
459
+ seen.set(key, { owner, role })
460
+ }
461
+ }
462
+
463
+ // Cross-unit references: every unit's workspace and resources are collected, so two units
464
+ // cannot claim one tree and a resource cannot alias another unit's workspace.
465
+ for (const other of allUnits) {
466
+ if (!isObject(other)) continue
467
+ if (nonEmpty(other.workspace) && isAbsolute(other.workspace)) claim(other.workspace, `unit ${other.id}`, 'workspace')
468
+ for (const resource of lifecycleResources(other)) {
469
+ if (isObject(resource) && nonEmpty(resource.path) && isAbsolute(resource.path)) claim(resource.path, `unit ${other.id}`, `resource:${resource.role ?? 'unknown'}`)
470
+ }
471
+ }
472
+
473
+ if (nonEmpty(unit.workspace) && isAbsolute(unit.workspace)) {
474
+ const ws = pathIdentity(unit.workspace)
475
+ if (ws.resolved) {
476
+ const expectedRoot = pathIdentity(join(runDir, 'units', String(unit.id)))
477
+ if (!expectedRoot.resolved || expectedRoot.real !== ws.real) {
478
+ errors.push({ code: 'workspace-layout', message: `unit ${unitId} workspace ${unit.workspace} is not under ${join(runDir, 'units', String(unit.id))}` })
479
+ }
480
+ const runroot = pathIdentity(runDir)
481
+ if (runroot.resolved && ws.real === runroot.real) errors.push({ code: 'workspace-layout', message: `unit ${unitId} workspace is the run directory` })
482
+ }
483
+ if (existsSync(unit.workspace) && lstatSync(unit.workspace).isSymbolicLink()) {
484
+ errors.push({ code: 'symlink', message: `unit ${unitId} workspace ${unit.workspace} is a symbolic link` })
485
+ }
486
+ }
487
+
488
+ const allowedResourcePrefixes = [
489
+ pathIdentity(join(runDir, 'resources', String(unit.id))),
490
+ pathIdentity(join(runDir, 'reviews', String(unit.id))),
491
+ pathIdentity(unit.workspace),
492
+ ].filter((ident) => ident.resolved).map((ident) => ident.real)
493
+ for (const resource of lifecycleResources(unit)) {
494
+ if (!isObject(resource) || !nonEmpty(resource.path) || !isAbsolute(resource.path)) continue
495
+ const ident = pathIdentity(resource.path)
496
+ if (!ident.resolved) continue
497
+ const underWorkspace = nonEmpty(unit.workspace) && containsPath(pathIdentity(unit.workspace).real, ident.real) && ident.real !== pathIdentity(unit.workspace).real
498
+ const underOwnedRoot = allowedResourcePrefixes.some((prefix) => containsPath(prefix, ident.real) && ident.real !== prefix)
499
+ const reviewRoot = pathIdentity(join(runDir, 'reviews', String(unit.id))).real
500
+ const reviewClone = pathIdentity(join(runDir, 'reviews', String(unit.id), 'clone')).real
501
+ if ((!underWorkspace && !underOwnedRoot) || (containsPath(reviewRoot, ident.real) && ident.real !== reviewClone)) {
502
+ errors.push({ code: 'resource-layout', message: `unit ${unitId} resource ${resource.path} is not a workspace child, not under ${join(runDir, 'resources', String(unit.id))}, and not under ${join(runDir, 'reviews', String(unit.id))}` })
503
+ }
504
+ }
505
+
506
+ return { errors, runDir }
507
+ }
508
+
509
+ function assertValid(result) {
510
+ if (result.errors.length) {
511
+ const error = new LifecycleError(result.errors[0].code, result.errors.map((e) => e.message).join('; '))
512
+ error.details = result.errors
513
+ throw error
514
+ }
515
+ }
516
+
517
+ // ---- locking ----
518
+
519
+ function safeName(value) {
520
+ return encodeURIComponent(String(value))
521
+ }
522
+
523
+ function acquireLock(runDir, unitId) {
524
+ const dir = join(runDir, '.workspace-locks')
525
+ if (symlinkBelow(runDir, dir)) throw new LifecycleError('symlink', 'workspace lock directory passes through a symbolic link')
526
+ mkdirSync(dir, { recursive: true })
527
+ const path = join(dir, `${safeName(unitId)}.lock`)
528
+ try {
529
+ mkdirSync(path)
530
+ } catch (error) {
531
+ if (error?.code === 'EEXIST') {
532
+ throw new LifecycleError('locked', `workspace ${unitId} is locked at ${path}; another prepare or cleanup owns it. The lock is never reclaimed by age`)
533
+ }
534
+ throw error
535
+ }
536
+ const owner = join(path, 'owner.json')
537
+ try {
538
+ writeFileSync(owner, `${JSON.stringify({ pid: process.pid, unit: String(unitId), at: new Date().toISOString() }, null, 2)}\n`)
539
+ } catch { /* the directory itself is the lock */ }
540
+ let released = false
541
+ return {
542
+ path,
543
+ release() {
544
+ if (released) return
545
+ released = true
546
+ try { unlinkSync(owner) } catch { /* may be absent */ }
547
+ try { rmSync(path, { recursive: true, force: true }) } catch { /* a lock left behind is visible in recover */ }
548
+ // Remove the empty lock directory too, so a read-only dry run after a completed
549
+ // prepare leaves no lock residue behind.
550
+ try { rmdirSync(dir) } catch { /* another unit's lock is still there */ }
551
+ },
552
+ }
553
+ }
554
+
555
+ // ---- workspace creation ----
556
+
557
+ function defaultKind(unit, repo) {
558
+ const planned = unit?.lifecycle?.workspace?.kind ?? unit?.workspaceKind
559
+ if (planned !== undefined) return planned
560
+ return gitToplevel(repo) ? 'worktree' : 'clone'
561
+ }
562
+
563
+ function baseShaFor(repo, unit) {
564
+ const planned = unit?.lifecycle?.workspace?.baseSha
565
+ if (planned !== undefined) {
566
+ if (!isHexSha(planned)) throw new LifecycleError('lifecycle-base-sha', `unit ${unit.id} lifecycle.workspace.baseSha is not an exact 40 or 64 character hex SHA`)
567
+ const exists = git(repo, ['cat-file', '-e', `${planned}^{commit}`]).ok
568
+ if (!exists) throw new LifecycleError('base-missing', `unit ${unit.id} baseSha ${planned} is not a commit in ${repo}`)
569
+ return planned
570
+ }
571
+ const sha = resolveCommit(repo, unit.base)
572
+ if (!sha) throw new LifecycleError('base-missing', `unit ${unit.id} base ${JSON.stringify(unit.base)} does not resolve to a commit in ${repo}`)
573
+ return sha
574
+ }
575
+
576
+ function createWorktree({ repo, workspace, branch, baseSha }) {
577
+ const branchExists = git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]).ok
578
+ if (branchExists && resolveCommit(repo, `refs/heads/${branch}`) !== baseSha) {
579
+ throw new LifecycleError('base-mismatch', `existing branch ${branch} is not at the planned base ${baseSha}`)
580
+ }
581
+ const args = ['worktree', 'add']
582
+ if (branchExists) args.push(workspace, branch)
583
+ else args.push('-b', branch, workspace, baseSha)
584
+ const result = git(repo, args)
585
+ if (!result.ok) throw new LifecycleError('worktree-create', `git worktree add failed for ${workspace}: ${result.err || result.out}`)
586
+ }
587
+
588
+ function createClone({ repo, workspace, branch, baseSha, source = repo }) {
589
+ // Fetch only the prepared commit from the local source. Cloning that source wholesale
590
+ // would relabel its private branches as origin/* when the origin URL is changed.
591
+ const origin = remoteUrl(repo, 'origin')
592
+ if (!origin) throw new LifecycleError('clone-origin', 'the repository has no recorded origin')
593
+ const existing = resolveCommit(repo, `refs/heads/${branch}`)
594
+ if (existing && existing !== baseSha) throw new LifecycleError('base-mismatch', `existing branch ${branch} is not at the planned base ${baseSha}`)
595
+ const init = git(dirname(workspace), ['init', '--quiet', workspace])
596
+ if (!init.ok) throw new LifecycleError('clone-create', init.err)
597
+ for (const args of [
598
+ ['remote', 'add', 'origin', origin],
599
+ ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', source, baseSha],
600
+ ['checkout', '--quiet', '-b', branch, baseSha],
601
+ ]) {
602
+ const result = git(workspace, args)
603
+ if (!result.ok) throw new LifecycleError('clone-create', `${workspace}: ${result.err || result.out}`)
604
+ }
605
+ }
606
+
607
+ function workspaceKindOf(path) {
608
+ const dotGit = join(path, '.git')
609
+ if (!existsSync(dotGit)) return null
610
+ try {
611
+ return lstatSync(dotGit).isFile() ? 'worktree' : 'clone'
612
+ } catch {
613
+ return null
614
+ }
615
+ }
616
+
617
+ function workspaceIdentity(workspace) {
618
+ const ident = fileIdentity(workspace)
619
+ return {
620
+ ...ident,
621
+ gitCommonDir: gitCommonDir(workspace),
622
+ headSha: currentHead(workspace),
623
+ }
624
+ }
625
+
626
+ function resourceIdentity(path) {
627
+ const ident = fileIdentity(path)
628
+ const kind = existsSync(join(path, '.git')) ? workspaceKindOf(path) : 'directory'
629
+ return kind && kind !== 'directory'
630
+ ? { ...ident, gitCommonDir: gitCommonDir(path) }
631
+ : ident
632
+ }
633
+
634
+ function ensureDirectory(path) {
635
+ if (existsSync(path)) {
636
+ if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory()) {
637
+ throw new LifecycleError('resource-exists', `${path} exists but is not a real directory`)
638
+ }
639
+ return
640
+ }
641
+ mkdirSync(path, { recursive: true })
642
+ }
643
+
644
+ function createResources({ unit, repo, workspace }) {
645
+ const resources = []
646
+ for (const planned of lifecycleResources(unit)) {
647
+ if (!isObject(planned)) continue
648
+ const path = planned.path
649
+ if (planned.state === 'ready' && !existsSync(path)) {
650
+ throw new LifecycleError('resource-missing', `unit ${unit.id} recorded ready resource ${path} is missing; not recreating over a lost tree`)
651
+ }
652
+ if (existsSync(path)) {
653
+ if (lstatSync(path).isSymbolicLink()) throw new LifecycleError('symlink', `resource ${path} is a symbolic link`)
654
+ if (planned.state === 'ready') {
655
+ const current = resourceIdentity(path)
656
+ if (!sameIdentity(planned.identity, current) || (planned.identity.gitCommonDir && planned.identity.gitCommonDir !== current.gitCommonDir)) {
657
+ throw new LifecycleError('resource-identity', `resource ${path} does not match its recorded identity; refusing to adopt a replacement tree`)
658
+ }
659
+ }
660
+ } else {
661
+ mkdirSync(dirname(path), { recursive: true })
662
+ if (planned.kind === 'clone') {
663
+ createClone({ repo, source: workspace, workspace: path, branch: `review-${safeName(unit.id)}`, baseSha: currentHead(workspace) })
664
+ } else if (planned.kind === 'worktree') {
665
+ const created = git(repo, ['worktree', 'add', '--quiet', '--detach', path, currentHead(workspace)])
666
+ if (!created.ok) throw new LifecycleError('resource-create', created.err)
667
+ } else ensureDirectory(path)
668
+ }
669
+ const actualKind = workspaceKindOf(path) ?? 'directory'
670
+ if (actualKind !== planned.kind) throw new LifecycleError('resource-kind', `resource ${path} is ${actualKind}, not ${planned.kind}; prepare cannot mark an empty directory as a clone`)
671
+ const identity = resourceIdentity(path)
672
+ resources.push({
673
+ ...planned,
674
+ path: normPath(path),
675
+ kind: RESOURCE_KINDS.has(planned.kind) ? planned.kind : 'directory',
676
+ role: RESOURCE_ROLES.has(planned.role) ? planned.role : 'scratch',
677
+ disposable: true,
678
+ state: 'ready',
679
+ identity,
680
+ })
681
+ }
682
+ // A review clone is conventionally `reviews/<unit>` even when it is not listed in the
683
+ // planned resources: older plans recorded it only by path. It is not adopted or created
684
+ // here; it is simply never treated as a workspace.
685
+ return resources
686
+ }
687
+
688
+ function cleanupFor(unit, headSha) {
689
+ const previous = isObject(unit?.lifecycle?.cleanup) ? unit.lifecycle.cleanup : {}
690
+ return {
691
+ ...previous,
692
+ state: previous.state && CLEANUP_STATES.has(previous.state) ? previous.state : 'pending',
693
+ headSha: isHexSha(previous.headSha) ? previous.headSha : headSha,
694
+ ...(previous.reason ? { reason: previous.reason } : {}),
695
+ }
696
+ }
697
+
698
+ export function prepare({
699
+ run, unit: unitId, revision, apply = false, kind = null,
700
+ }) {
701
+ run = resolve(run)
702
+ const first = readPlan(run)
703
+ const repo = repositoryOf(first.plan, run)
704
+ requireRevision(first.plan, revision)
705
+ const unit = unitOf(first.plan, unitId)
706
+ assertValid(validateUnit({ repo, run, plan: first.plan, unit }))
707
+ const resolvedKind = kind ?? defaultKind(unit, repo)
708
+ if (!WORKSPACE_KINDS.has(resolvedKind)) throw new LifecycleError('kind-invalid', `--kind must be worktree or clone, not ${JSON.stringify(kind)}`)
709
+ if (unit.lifecycle?.workspace?.kind && resolvedKind !== unit.lifecycle.workspace.kind) {
710
+ throw new LifecycleError('kind-mismatch', `the plan records ${unit.lifecycle.workspace.kind}, not ${resolvedKind}`)
711
+ }
712
+ const baseSha = baseShaFor(repo, unit)
713
+ const workspace = normPath(unit.workspace)
714
+
715
+ if (!apply) {
716
+ return {
717
+ version: 1,
718
+ command: 'prepare',
719
+ ok: true,
720
+ applied: false,
721
+ action: existsSync(workspace) ? 'verify' : 'create',
722
+ repo,
723
+ run: canonicalRunDir(repo, run),
724
+ unit: String(unitId),
725
+ revision: planRevisionOf(first.plan),
726
+ workspace: {
727
+ path: workspace,
728
+ kind: resolvedKind,
729
+ state: existsSync(workspace) ? 'ready' : 'preparing',
730
+ baseSha,
731
+ identity: existsSync(workspace) ? workspaceIdentity(workspace) : null,
732
+ exists: existsSync(workspace),
733
+ },
734
+ resources: lifecycleResources(unit).map((r) => ({
735
+ path: r.path, kind: r.kind ?? 'directory', role: r.role ?? 'scratch', disposable: true,
736
+ state: existsSync(r.path) ? 'ready' : 'preparing', identity: existsSync(r.path) ? resourceIdentity(r.path) : null,
737
+ exists: existsSync(r.path),
738
+ })),
739
+ nextLifecycle: {
740
+ version: 1,
741
+ workspace: { path: workspace, kind: resolvedKind, state: 'preparing', baseSha, identity: null },
742
+ resources: lifecycleResources(unit).map((r) => ({ ...r, state: 'preparing', identity: null })),
743
+ cleanup: cleanupFor(unit, existsSync(workspace) ? currentHead(workspace) : null),
744
+ },
745
+ checks: [{ name: 'plan', ok: true, detail: `revision ${planRevisionOf(first.plan)}` }],
746
+ errors: [],
747
+ }
748
+ }
749
+
750
+ const runDir = canonicalRunDir(repo, run)
751
+ const lock = acquireLock(runDir, unitId)
752
+ try {
753
+ // Re-read under the lock: a concurrent plan transition or another prepare must not be
754
+ // overwritten by a decision made against a stale revision.
755
+ const current = readPlan(run)
756
+ requireRevision(current.plan, revision)
757
+ const freshUnit = unitOf(current.plan, unitId)
758
+ assertValid(validateUnit({ repo, run, plan: current.plan, unit: freshUnit }))
759
+ if (JSON.stringify(current.plan) !== JSON.stringify(first.plan)) throw new LifecycleError('revision-mismatch', 'plan changed before prepare acquired its lock')
760
+ const freshBase = baseShaFor(repo, freshUnit)
761
+ if (freshBase !== baseSha) throw new LifecycleError('revision-mismatch', `unit ${unitId} base changed from ${baseSha} to ${freshBase}; reread the plan`)
762
+ if (freshUnit.lifecycle?.cleanup?.state === 'complete' && existsSync(workspace)) {
763
+ throw new LifecycleError('cleanup-final', `unit ${unitId} is recorded complete, but its workspace still exists; the plan and the disk disagree`)
764
+ }
765
+
766
+ let existing = existsSync(workspace)
767
+ // An empty directory at the planned path is not a used tree: the coordinator planned the
768
+ // path, and prepare is the operation that fills it. Only an empty directory is replaced;
769
+ // anything else must already be the recorded workspace.
770
+ if (existing && !lstatSync(workspace).isDirectory()) {
771
+ throw new LifecycleError('workspace-invalid', `planned workspace ${workspace} exists but is not a directory`)
772
+ }
773
+ if (existing && readdirSync(workspace).length === 0 && !(freshUnit.lifecycle?.workspace?.state === 'ready')) {
774
+ rmdirSync(workspace)
775
+ existing = false
776
+ }
777
+ let action = 'verify'
778
+ if (existing) {
779
+ if (lstatSync(workspace).isSymbolicLink()) throw new LifecycleError('symlink', `workspace ${workspace} is a symbolic link`)
780
+ if (!freshUnit.lifecycle) throw new LifecycleError('unowned-tree', `workspace ${workspace} exists but the plan records no lifecycle for unit ${unitId}; refusing to adopt it`)
781
+ if (freshUnit.lifecycle.workspace?.state === 'ready' && !existsSync(join(workspace, '.git'))) {
782
+ throw new LifecycleError('workspace-missing-git', `workspace ${workspace} is recorded ready but is not a Git workspace`)
783
+ }
784
+ const actualKind = workspaceKindOf(workspace)
785
+ if (!actualKind) throw new LifecycleError('workspace-invalid', `workspace ${workspace} exists but is not a Git worktree or clone`)
786
+ if (actualKind !== resolvedKind) throw new LifecycleError('kind-mismatch', `workspace ${workspace} is a ${actualKind}, not the recorded ${resolvedKind}`)
787
+ const branch = currentBranch(workspace)
788
+ if (branch !== freshUnit.branch) throw new LifecycleError('branch-mismatch', `workspace ${workspace} is on ${branch ?? 'an unknown branch'}, not ${freshUnit.branch}`)
789
+ if (!git(workspace, ['merge-base', '--is-ancestor', freshBase, 'HEAD']).ok && currentHead(workspace) !== freshBase) {
790
+ throw new LifecycleError('base-mismatch', `workspace ${workspace} does not contain its recorded base ${freshBase}; refusing to reset a used tree`)
791
+ }
792
+ const currentIdentity = workspaceIdentity(workspace)
793
+ if (freshUnit.lifecycle.workspace?.state === 'ready' && (!sameIdentity(freshUnit.lifecycle.workspace.identity, currentIdentity)
794
+ || freshUnit.lifecycle.workspace.identity.gitCommonDir !== currentIdentity.gitCommonDir)) {
795
+ throw new LifecycleError('workspace-identity', `workspace ${workspace} does not match its recorded identity; refusing to adopt a replacement tree`)
796
+ }
797
+ } else {
798
+ if (freshUnit.lifecycle?.workspace?.state === 'ready') {
799
+ throw new LifecycleError('workspace-lost', `unit ${unitId} records a ready workspace at ${workspace}, but it is missing; not recreating it`)
800
+ }
801
+ if (freshUnit.lifecycle?.cleanup?.state === 'complete' || freshUnit.lifecycle?.cleanup?.state === 'retained') {
802
+ throw new LifecycleError('cleanup-final', `unit ${unitId} is already ${freshUnit.lifecycle.cleanup.state}; prepare will not recreate its workspace`)
803
+ }
804
+ action = 'create'
805
+ mkdirSync(dirname(workspace), { recursive: true })
806
+ try {
807
+ if (resolvedKind === 'worktree') createWorktree({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
808
+ else createClone({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
809
+ } catch (error) {
810
+ // A Pi sandbox can deny linked `.git` files. If the plan did not choose the kind and
811
+ // `git worktree add` failed before leaving a tree we can identify, fall back to an
812
+ // independent clone. Never fall back over an existing tree.
813
+ const branchExistsInRepo = git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${freshUnit.branch}`]).ok
814
+ const absentOrEmpty = !existsSync(workspace) || (lstatSync(workspace).isDirectory() && readdirSync(workspace).length === 0)
815
+ if (resolvedKind === 'worktree' && !kind && !freshUnit.lifecycle?.workspace?.kind && !branchExistsInRepo && absentOrEmpty) {
816
+ if (existsSync(workspace)) rmdirSync(workspace)
817
+ createClone({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
818
+ } else {
819
+ throw error
820
+ }
821
+ }
822
+ }
823
+
824
+ const actualKind = workspaceKindOf(workspace)
825
+ const identity = workspaceIdentity(workspace)
826
+ if (!identity.headSha) throw new LifecycleError('workspace-invalid', `workspace ${workspace} has no readable HEAD`)
827
+ const resources = createResources({ unit: freshUnit, repo, workspace })
828
+ const lifecycle = {
829
+ ...freshUnit.lifecycle,
830
+ version: LIFECYCLE_VERSION,
831
+ workspace: {
832
+ path: workspace,
833
+ kind: actualKind,
834
+ state: 'ready',
835
+ baseSha: freshBase,
836
+ identity,
837
+ },
838
+ resources,
839
+ cleanup: cleanupFor(freshUnit, identity.headSha),
840
+ }
841
+ return {
842
+ version: 1,
843
+ command: 'prepare',
844
+ ok: true,
845
+ applied: true,
846
+ action,
847
+ repo,
848
+ run: runDir,
849
+ unit: String(unitId),
850
+ revision: planRevisionOf(current.plan),
851
+ workspace: { ...lifecycle.workspace, exists: true },
852
+ resources: resources.map((r) => ({ ...r, exists: true })),
853
+ lifecycle,
854
+ nextLifecycle: lifecycle,
855
+ checks: [
856
+ { name: 'plan', ok: true, detail: `revision ${planRevisionOf(current.plan)}` },
857
+ { name: 'workspace', ok: true, detail: `${actualKind} ${workspace}` },
858
+ { name: 'base', ok: true, detail: freshBase },
859
+ ],
860
+ errors: [],
861
+ }
862
+ } finally {
863
+ lock.release()
864
+ }
865
+ }
866
+
867
+ // ---- liveness and process use ----
868
+
869
+ // Linux can return EACCES when a task exits during a /proc read. Only signal-0 ESRCH
870
+ // proves that exact PID is gone and permits one fresh scan. All other uncertainty
871
+ // retains the environment (ADR 0023).
872
+ function readProcLink(path, pid) {
873
+ try { return { link: readlinkSync(path) } } catch (error) {
874
+ const errno = error?.code ?? 'unknown'
875
+ if (errno === 'ENOENT' || errno === 'ESRCH') return { link: null }
876
+ return { failure: { path, operation: 'readlink', errno, pid } }
877
+ }
878
+ }
879
+
880
+ function probePid(pid) {
881
+ const operation = `process.kill(${pid}, 0)`
882
+ const numeric = Number(pid)
883
+ if (!Number.isSafeInteger(numeric) || numeric <= 0) return { exited: false, operation, errno: 'EINVAL' }
884
+ try {
885
+ process.kill(numeric, 0)
886
+ return { exited: false, operation, errno: null }
887
+ } catch (error) {
888
+ const errno = error?.code ?? 'unknown'
889
+ return { exited: errno === 'ESRCH', operation, errno }
890
+ }
891
+ }
892
+
893
+ // Only an EACCES read attributed to one exact pid may be probed. A failure without a pid
894
+ // (the /proc listing itself) or with another errno is a refusal with no escape hatch.
895
+ function procRefusal(failure) {
896
+ return {
897
+ refusal: failure,
898
+ ...(failure.errno === 'EACCES' && failure.pid ? { probe: String(failure.pid) } : {}),
899
+ }
900
+ }
901
+
902
+ // One complete, bounded pass over /proc. Every call builds its own pid set, enumeration and
903
+ // counters, so a restart can never inherit the abandoned pass's budget or findings.
904
+ function scanProcOnce(targets) {
905
+ const pids = new Set()
906
+ let fdsSeen = 0
907
+ let procCount = 0
908
+ let names = []
909
+ try { names = readdirSync('/proc') } catch (error) {
910
+ return { refusal: { path: '/proc', operation: 'readdir', errno: error?.code ?? 'unknown' } }
911
+ }
912
+ const overPids = () => ({ refusal: { path: '/proc', operation: 'enumerate-pids', errno: 'PROC_SCAN_MAX_PIDS', limit: PROC_SCAN_MAX_PIDS } })
913
+ const overFds = (name) => ({ refusal: { path: `/proc/${name}/fd`, operation: 'enumerate-fds', errno: 'PROC_SCAN_MAX_FDS', limit: PROC_SCAN_MAX_FDS } })
914
+ for (const name of names) {
915
+ if (!/^\d+$/.test(name)) continue
916
+ if (procCount++ >= PROC_SCAN_MAX_PIDS) return overPids()
917
+ const cwd = readProcLink(`/proc/${name}/cwd`, name)
918
+ if (cwd.failure) return procRefusal(cwd.failure)
919
+ if (cwd.link && targets.some((target) => containsPath(target, cwd.link))) { pids.add(name); continue }
920
+ let fds = []
921
+ try { fds = readdirSync(`/proc/${name}/fd`) } catch (error) {
922
+ const errno = error?.code ?? 'unknown'
923
+ if (errno === 'ENOENT' || errno === 'ESRCH') continue
924
+ return procRefusal({ path: `/proc/${name}/fd`, operation: 'readdir', errno, pid: name })
925
+ }
926
+ for (const fd of fds) {
927
+ if (fdsSeen++ >= PROC_SCAN_MAX_FDS) return overFds(name)
928
+ const link = readProcLink(`/proc/${name}/fd/${fd}`, name)
929
+ if (link.failure) return procRefusal(link.failure)
930
+ if (link.link && targets.some((target) => containsPath(target, link.link))) { pids.add(name); break }
931
+ }
932
+ if (fdsSeen > PROC_SCAN_MAX_FDS) return overFds(name)
933
+ }
934
+ return { pids: [...pids] }
935
+ }
936
+
937
+ function linuxProcessesUsing(paths, onUncertain) {
938
+ if (!existsSync('/proc')) return null
939
+ const targets = paths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real)
940
+ if (!targets.length) return []
941
+ let restarts = 0
942
+ for (;;) {
943
+ const scan = scanProcOnce(targets)
944
+ if (scan.pids) return scan.pids
945
+ if (!scan.probe || restarts > 0) {
946
+ onUncertain?.(scan.refusal)
947
+ return 'unreadable'
948
+ }
949
+ const probe = probePid(scan.probe)
950
+ if (!probe.exited) {
951
+ onUncertain?.({ ...scan.refusal, probe })
952
+ return 'unreadable'
953
+ }
954
+ restarts += 1
955
+ }
956
+ }
957
+
958
+ function lsofProcessesUsing(paths) {
959
+ try {
960
+ // Enumerate open files once, bounded by time and output. No recursive lsof +D walk.
961
+ const out = execFileSync('lsof', ['-nP', '-Fn'], {
962
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: LSOF_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024,
963
+ })
964
+ const targets = paths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real)
965
+ const pids = new Set()
966
+ let pid = null
967
+ for (const line of String(out).split('\n')) {
968
+ if (line.startsWith('p')) pid = line.slice(1).trim()
969
+ else if (line.startsWith('n') && pid) {
970
+ const cwd = line.slice(1)
971
+ if (targets.some((target) => containsPath(target, cwd))) pids.add(pid)
972
+ }
973
+ }
974
+ return [...pids]
975
+ } catch (error) {
976
+ if (error?.code === 'ENOENT' || error?.status === 127) return 'unavailable'
977
+ if (error?.status === 1 && !String(error?.stdout ?? '').trim()) return []
978
+ return 'unreadable'
979
+ }
980
+ }
981
+
982
+ export function liveProcessesUsing(paths, options = {}) {
983
+ const onUncertain = typeof options?.onUncertain === 'function' ? options.onUncertain : null
984
+ const linux = linuxProcessesUsing(paths, onUncertain)
985
+ if (linux !== null) return linux
986
+ return lsofProcessesUsing(paths)
987
+ }
988
+
989
+ // ---- unpublished work, evidence, nested repos ----
990
+
991
+ function hasRecordedWorker(plan, unit) {
992
+ const rosters = [...(plan?.units ?? []), ...(plan?.issues ?? [])]
993
+ const rows = [...rosters, ...(Array.isArray(plan?.fleet) ? plan.fleet : [])]
994
+ if (Array.isArray(plan?.workers)) rows.push(...plan.workers)
995
+ if (isObject(plan?.workers)) rows.push(...Object.values(plan.workers))
996
+ const paths = [unit.workspace, ...lifecycleResources(unit).map(r => r.path)].map(p => pathIdentity(p).real).filter(Boolean)
997
+ return rows.filter(row => {
998
+ if (!isObject(row) || row.worker == null || row.worker === '') return false
999
+ const owner = row.unit ?? row.unitId ?? (rosters.includes(row) ? row.id : null)
1000
+ const claims = [row.workspace, ...lifecycleResources(row).map(r => r.path)].filter(nonEmpty).map(p => pathIdentity(p).real).filter(Boolean)
1001
+ if (claims.some(p => paths.some(q => containsPath(p, q) || containsPath(q, p)))) return true
1002
+ // A handle without an owner or path is ambiguous, not an unrelated worker.
1003
+ return owner == null || String(owner) === String(unit.id)
1004
+ }).map(row => String(row.worker))
1005
+ }
1006
+
1007
+ function statusLines(workspace) {
1008
+ const r = git(workspace, ['status', '--porcelain=v1', '--ignored=matching'])
1009
+ if (!r.ok) return null
1010
+ return r.out.split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean)
1011
+ }
1012
+
1013
+ function interruptedGitState(workspace) {
1014
+ const checks = ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'sequencer']
1015
+ const found = []
1016
+ const dir = git(workspace, ['rev-parse', '--path-format=absolute', '--git-dir'])
1017
+ if (dir.ok && existsSync(join(dir.out, 'locked'))) found.push('locked')
1018
+ for (const name of checks) {
1019
+ const r = git(workspace, ['rev-parse', '--path-format=absolute', '--git-path', name])
1020
+ if (r.ok && existsSync(r.out)) found.push(name)
1021
+ }
1022
+ return found
1023
+ }
1024
+
1025
+ function nestedRepositories(workspace, skipPaths) {
1026
+ const root = pathIdentity(workspace)
1027
+ if (!root.resolved) return { found: [], truncated: false }
1028
+ const skip = new Set(skipPaths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real))
1029
+ const found = []
1030
+ let seen = 0
1031
+ let truncated = false
1032
+ const walk = (dir, depth) => {
1033
+ if (truncated) return
1034
+ if (depth > NESTED_SCAN_MAX_DEPTH) { truncated = true; return }
1035
+ let entries = []
1036
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { truncated = true; return }
1037
+ for (const entry of entries) {
1038
+ if (seen++ >= NESTED_SCAN_MAX_ENTRIES) { truncated = true; return }
1039
+ const path = join(dir, entry.name)
1040
+ const ident = pathIdentity(path)
1041
+ if (ident.resolved && skip.has(ident.real)) continue
1042
+ if (entry.name === '.git' && depth === 0) continue
1043
+ if (entry.name === '.git' && depth > 0) { found.push(path); continue }
1044
+ if (entry.isDirectory() && existsSync(join(path, 'HEAD')) && existsSync(join(path, 'objects')) && existsSync(join(path, 'refs'))) { found.push(path); continue }
1045
+ if (!entry.isDirectory()) continue
1046
+ walk(path, depth + 1)
1047
+ }
1048
+ }
1049
+ walk(root.real, 0)
1050
+ return { found, truncated }
1051
+ }
1052
+
1053
+ function evidenceEntries(unit) {
1054
+ const declarations = [unit?.lifecycle?.evidence, unit?.evidence, unit?.lifecycle?.cleanup?.evidence, unit?.requiredEvidence, unit?.lifecycle?.requiredEvidence].filter(v => v !== undefined)
1055
+ const entries = []
1056
+ for (const raw of declarations) {
1057
+ if (!Array.isArray(raw)) return null
1058
+ for (const entry of raw) {
1059
+ if (typeof entry === 'string' && nonEmpty(entry)) entries.push({ path: entry, required: true })
1060
+ else if (isObject(entry) && nonEmpty(entry.path) && (entry.required === undefined || typeof entry.required === 'boolean')) entries.push({ path: entry.path, required: entry.required !== false })
1061
+ else return null
1062
+ }
1063
+ }
1064
+ return entries
1065
+ }
1066
+
1067
+ function evidenceReport(unit, workspace) {
1068
+ const roots = [workspace, ...lifecycleResources(unit).map(r => r.path)].map(pathIdentity)
1069
+ const entries = evidenceEntries(unit)
1070
+ if (entries === null) return [{ path: '(invalid evidence declaration)', required: true, location: 'unresolved', preserved: false, exists: false }]
1071
+ return entries.map((entry) => {
1072
+ const ident = pathIdentity(entry.path)
1073
+ const location = !ident.resolved ? 'unresolved'
1074
+ : roots.some(root => root.resolved && containsPath(root.real, ident.real)) ? 'inside-removal-root' : 'outside'
1075
+ return { ...entry, location, preserved: location === 'outside' && ident.exists, exists: ident.exists }
1076
+ })
1077
+ }
1078
+
1079
+ function targetBranchOf(plan, unit) {
1080
+ const candidates = [plan?.targetBranch, plan?.target]
1081
+ for (const value of candidates) {
1082
+ if (!nonEmpty(value) || isHexSha(value)) continue
1083
+ let name = String(value).trim()
1084
+ name = name.replace(/^refs\/remotes\/origin\//, '').replace(/^refs\/heads\//, '').replace(/^origin\//, '').replace(/^refs\/remotes\//, '')
1085
+ if (name && name !== 'HEAD') return name
1086
+ }
1087
+ return null
1088
+ }
1089
+
1090
+ // Read the actual origin's current heads/tags. Neither local refs/remotes nor the
1091
+ // coordinator's branches are publication authority. Fetch objects only on apply;
1092
+ // do not overwrite refs or their reflogs while deciding what work must survive.
1093
+ function freshTarget({ workspace, target, apply, origin }) {
1094
+ const remote = git(workspace, ['ls-remote', '--heads', '--tags', origin], { timeout: 60000 })
1095
+ if (!remote.ok) return { ok: false, reason: `cannot read fresh origin refs: ${remote.err || remote.out}` }
1096
+ const refs = new Map()
1097
+ for (const line of remote.out.split('\n').filter(Boolean)) {
1098
+ const [sha, ref] = line.split(/\s+/)
1099
+ if (!isHexSha(sha) || !ref?.startsWith('refs/')) return { ok: false, reason: 'unreadable fresh origin refs' }
1100
+ refs.set(ref, sha)
1101
+ }
1102
+ const commit = refs.get(`refs/heads/${target}`)
1103
+ if (!commit) return { ok: false, reason: `remote has no branch ${target}` }
1104
+ const tips = []
1105
+ for (const sha of new Set(refs.values())) {
1106
+ if (!git(workspace, ['cat-file', '-e', sha]).ok && apply) {
1107
+ const fetched = git(workspace, ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', '--refmap=', origin, sha], { timeout: 120000 })
1108
+ if (!fetched.ok) return { ok: false, reason: `fresh origin object fetch failed: ${fetched.err || fetched.out}` }
1109
+ }
1110
+ const tip = resolveCommit(workspace, sha)
1111
+ if (tip) tips.push(tip)
1112
+ }
1113
+ if (!tips.includes(commit)) return { ok: false, reason: `fresh target ${commit} is unavailable locally; a dry run does not fetch` }
1114
+ return { ok: true, commit, tips: [...new Set(tips)], fresh: true, method: apply ? 'ls-remote-and-object-fetch' : 'ls-remote' }
1115
+ }
1116
+
1117
+ function prNumberOf(unit) {
1118
+ const raw = unit?.pr
1119
+ if (Number.isSafeInteger(raw)) return raw
1120
+ if (isObject(raw)) return Number.isSafeInteger(raw.number) ? raw.number : null
1121
+ if (typeof raw === 'string') {
1122
+ const match = raw.match(/\/pull\/(\d+)(?:\b|$)/) ?? raw.match(/^#?(\d+)$/)
1123
+ if (match) return Number(match[1])
1124
+ }
1125
+ return null
1126
+ }
1127
+
1128
+ function normalizeProof(raw) {
1129
+ if (!isObject(raw)) return null
1130
+ return {
1131
+ repo: raw.repo ?? raw.repository ?? raw.nameWithOwner ?? null,
1132
+ pr: raw.pr ?? raw.number ?? raw.pullRequest ?? null,
1133
+ headRepo: raw.headRepo ?? raw.headRepository?.nameWithOwner ?? raw.headRepository ?? raw.headRepositoryNameWithOwner ?? null,
1134
+ headBranch: raw.headBranch ?? raw.headRefName ?? raw.branch ?? null,
1135
+ headSha: raw.headSha ?? raw.headSHA ?? raw.headRefOid ?? raw.headOid ?? null,
1136
+ dest: raw.dest ?? raw.destination ?? raw.baseRefName ?? raw.base ?? raw.baseBranch ?? null,
1137
+ mergeSha: raw.mergeSha ?? raw.mergeCommit?.oid ?? raw.mergeCommit ?? raw.mergeCommitOid ?? raw.merge_commit_sha ?? null,
1138
+ state: raw.state ?? null,
1139
+ source: raw.__file ?? 'inline',
1140
+ }
1141
+ }
1142
+
1143
+ function ghProof({ unit, repoSlug, runGh }) {
1144
+ const pr = prNumberOf(unit)
1145
+ if (!pr || !repoSlug || typeof runGh !== 'function') return null
1146
+ try {
1147
+ const out = runGh(['pr', 'view', String(pr), '--repo', repoSlug, '--json', 'number,state,headRefOid,headRefName,headRepository,headRepositoryOwner,baseRefName,mergeCommit'])
1148
+ const raw = JSON.parse(out)
1149
+ return normalizeProof({
1150
+ repo: repoSlug,
1151
+ pr: raw.number,
1152
+ headRepo: raw.headRepository?.nameWithOwner ?? (raw.headRepositoryOwner?.login && raw.headRepository?.name ? `${raw.headRepositoryOwner.login}/${raw.headRepository.name}` : null),
1153
+ headBranch: raw.headRefName,
1154
+ headSha: raw.headRefOid,
1155
+ dest: raw.baseRefName,
1156
+ mergeSha: raw.mergeCommit?.oid ?? null,
1157
+ state: raw.state,
1158
+ })
1159
+ } catch {
1160
+ return null
1161
+ }
1162
+ }
1163
+
1164
+ function squashProof({ plan, unit, run, repo, workspace, target, fresh, headSha, apply, runGh }) {
1165
+ const repoSlug = slugFromUrl(remoteUrl(repo, 'origin'))
1166
+ // Saved JSON is evidence, not authority to destroy the PR head. An unavailable or
1167
+ // contradictory provider must retain the tree, even if an inline snapshot says merged.
1168
+ const proof = apply && repoSlug && prNumberOf(unit) && typeof runGh === 'function'
1169
+ ? ghProof({ unit, repoSlug, runGh }) : null
1170
+ const snapshots = proof ? [proof] : []
1171
+ const reasons = proof ? [] : ['live authoritative PR proof is unavailable']
1172
+ for (const proof of snapshots) {
1173
+ if (proof.state !== 'MERGED') { reasons.push(`PR state is ${proof.state}`); continue }
1174
+ if (proof.repo !== repoSlug) { reasons.push(`repo ${proof.repo} is not ${repoSlug}`); continue }
1175
+ const pr = prNumberOf(unit)
1176
+ if (proof.pr !== pr) { reasons.push(`PR ${proof.pr} is not ${pr}`); continue }
1177
+ const expectedHeadRepo = unit.headRepo ?? unit.headRepository ?? (isObject(unit.pr) ? (unit.pr.headRepo ?? unit.pr.headRepository) : null) ?? repoSlug
1178
+ if (!/^[\w.-]+\/[\w.-]+$/.test(String(expectedHeadRepo)) || proof.headRepo !== expectedHeadRepo) {
1179
+ reasons.push(`headRepo ${proof.headRepo} is not the recorded ${expectedHeadRepo}`)
1180
+ continue
1181
+ }
1182
+ if (proof.headBranch !== unit.branch) { reasons.push(`headBranch ${proof.headBranch} is not ${unit.branch}`); continue }
1183
+ if (proof.headSha !== headSha) { reasons.push(`headSha ${proof.headSha} is not the recorded ${headSha}`); continue }
1184
+ const dest = String(proof.dest ?? '').replace(/^refs\/heads\//, '').replace(/^origin\//, '')
1185
+ if (dest !== target) { reasons.push(`destination ${proof.dest} is not ${target}`); continue }
1186
+ if (!isHexSha(proof.mergeSha)) { reasons.push('mergeSha is missing or not an exact hex SHA'); continue }
1187
+ if (!git(workspace, ['cat-file', '-e', `${proof.mergeSha}^{commit}`]).ok) { reasons.push(`mergeSha ${proof.mergeSha} is not in the local object store after the fresh target read`); continue }
1188
+ if (!fresh.ok) { reasons.push(fresh.reason); continue }
1189
+ if (!git(workspace, ['merge-base', '--is-ancestor', proof.mergeSha, fresh.commit]).ok) {
1190
+ reasons.push(`mergeSha ${proof.mergeSha} is not reachable from the fresh ${target} ${fresh.commit}`)
1191
+ continue
1192
+ }
1193
+ return { ok: true, proof: { ...proof, source: 'live-github', target: fresh.commit }, reasons }
1194
+ }
1195
+ return { ok: false, reasons }
1196
+ }
1197
+
1198
+ // ---- cleanup planning ----
1199
+
1200
+ function deliveryMerged(unit) {
1201
+ const delivery = unit?.delivery
1202
+ if (delivery === 'merged') return true
1203
+ if (isObject(delivery)) return delivery.outcome === 'merged' || delivery.state === 'merged' || delivery.status === 'merged'
1204
+ return false
1205
+ }
1206
+
1207
+ function unitCompleted(unit) {
1208
+ return unit?.status === 'succeeded' && deliveryMerged(unit)
1209
+ }
1210
+
1211
+ function checkWorkspace({ repo, plan, unit, run, apply, requireApplying, allowGh, runGh }) {
1212
+ const checks = []
1213
+ const add = (name, ok, detail, extra = {}) => { checks.push({ name, ok, detail, ...extra }); return ok }
1214
+ const workspace = normPath(unit?.workspace)
1215
+ const lifecycle = unit?.lifecycle
1216
+ const unitId = String(unit?.id ?? '')
1217
+
1218
+ if (!unitCompleted(unit)) {
1219
+ const delivery = isObject(unit?.delivery) ? (unit.delivery.outcome ?? unit.delivery.state ?? 'unknown') : (unit.delivery ?? 'unknown')
1220
+ return { verdict: 'retain', reason: `unit ${unitId} is not a completed unit (status ${unit.status ?? 'missing'}, delivery ${delivery}); its environment is kept`, checks, workspace }
1221
+ }
1222
+ add('completed', true, `status succeeded, delivery merged`)
1223
+
1224
+ const workers = hasRecordedWorker(plan, unit)
1225
+ if (workers.length) return { verdict: 'retain', reason: `workers are recorded for this unit or overlapping paths (${workers.join(', ')})`, checks, workspace }
1226
+ add('workers', true, 'no worker recorded for this owner or its paths')
1227
+
1228
+ if (!isObject(lifecycle)) return { verdict: 'retain', reason: `unit ${unitId} has no lifecycle record, so the plan does not say what it owns; run inventory/recover`, checks, workspace }
1229
+ add('lifecycle', true, `version ${lifecycle.version}`)
1230
+ if (lifecycle.version !== LIFECYCLE_VERSION) return { verdict: 'retain', reason: `unit ${unitId} lifecycle version ${lifecycle.version} is not ${LIFECYCLE_VERSION}`, checks, workspace }
1231
+ const cleanup = isObject(lifecycle.cleanup) ? lifecycle.cleanup : {}
1232
+ if (requireApplying && cleanup.state !== 'applying' && cleanup.state !== 'complete') {
1233
+ return { verdict: 'refuse', reason: `unit ${unitId} cleanup.state is ${cleanup.state ?? 'missing'}, not applying; the coordinator must bind cleanup.state=applying through update_plan before --apply`, checks, workspace }
1234
+ }
1235
+ add('cleanup-state', true, requireApplying ? String(cleanup.state) : String(cleanup.state ?? 'missing'))
1236
+
1237
+ const wsIdent = pathIdentity(workspace)
1238
+ if (!wsIdent.resolved) return { verdict: 'retain', reason: `workspace ${workspace} cannot be identified: ${wsIdent.reason}`, checks, workspace }
1239
+ if (wsIdent.isSymlink) return { verdict: 'retain', reason: `workspace ${workspace} is a symbolic link`, checks, workspace }
1240
+ if (!wsIdent.exists) {
1241
+ const completion = verifiedReceipt({ repo, run, unit })
1242
+ if (completion.ok && ['applying', 'complete'].includes(cleanup.state)) {
1243
+ return { verdict: 'already-complete', reason: 'the bound cleanup receipt and every removal postcondition agree', checks, workspace, receipt: completion.receipt }
1244
+ }
1245
+ return { verdict: 'retain', reason: `workspace is missing; recovery requires inspection: ${completion.reason}`, checks, workspace }
1246
+ }
1247
+ if (cleanup.state === 'complete') {
1248
+ return { verdict: 'retain', reason: `cleanup.state is complete, but workspace ${workspace} still exists; the plan and the disk disagree, so nothing is removed`, checks, workspace }
1249
+ }
1250
+
1251
+ if (wsIdent.real === pathIdentity(repo).real) return { verdict: 'refuse', reason: `unit ${unitId} workspace is the repository checkout`, checks, workspace }
1252
+ const runIdent = pathIdentity(run)
1253
+ if (!runIdent.resolved || !containsPath(runIdent.real, wsIdent.real)) {
1254
+ return { verdict: 'refuse', reason: `workspace ${workspace} is not inside the canonical run directory ${run}`, checks, workspace }
1255
+ }
1256
+ add('containment', true, workspace)
1257
+
1258
+ const recordedIdentity = lifecycle.workspace?.identity
1259
+ if (!isObject(recordedIdentity)) return { verdict: 'retain', reason: `unit ${unitId} records no workspace identity, so the tree cannot be shown to be the one that was prepared`, checks, workspace }
1260
+ const actualIdentity = workspaceIdentity(workspace)
1261
+ if (!sameIdentity(recordedIdentity, actualIdentity)) {
1262
+ return { verdict: 'retain', reason: `workspace ${workspace} does not match its recorded dev/ino/realPath identity; refusing a replacement tree`, checks, workspace }
1263
+ }
1264
+ add('identity', true, recordedIdentity.realPath)
1265
+
1266
+ const kind = workspaceKindOf(workspace)
1267
+ if (!kind) return { verdict: 'retain', reason: `workspace ${workspace} is not a readable Git worktree or clone`, checks, workspace }
1268
+ if (kind !== lifecycle.workspace.kind) return { verdict: 'retain', reason: `workspace ${workspace} is a ${kind}, not the recorded ${lifecycle.workspace.kind}`, checks, workspace }
1269
+ add('kind', true, kind)
1270
+
1271
+ const head = currentHead(workspace)
1272
+ const recordedHead = cleanup.headSha ?? unit.headSha
1273
+ if (!isHexSha(recordedHead)) return { verdict: 'retain', reason: `unit ${unitId} records no exact headSha for cleanup`, checks, workspace }
1274
+ if (!head || head !== recordedHead) return { verdict: 'retain', reason: `workspace HEAD ${head ?? 'unreadable'} is not the recorded headSha ${recordedHead}`, checks, workspace }
1275
+ if (unit.headSha !== undefined && unit.headSha !== head) return { verdict: 'retain', reason: `unit.headSha ${unit.headSha} is not the workspace HEAD ${head}`, checks, workspace }
1276
+ add('head', true, head)
1277
+ if (currentBranch(workspace) !== unit.branch) return { verdict: 'retain', reason: `workspace branch ${currentBranch(workspace) ?? 'unknown'} is not ${unit.branch}`, checks, workspace }
1278
+ add('branch', true, unit.branch)
1279
+
1280
+ if (kind === 'worktree') {
1281
+ if (!isRegisteredWorktree(repo, workspace)) return { verdict: 'retain', reason: `workspace ${workspace} is a linked worktree but is not registered in ${repo}; only git worktree remove may remove it`, checks, workspace }
1282
+ const common = gitCommonDir(workspace)
1283
+ const repoCommon = gitCommonDir(repo)
1284
+ if (!common || !repoCommon || common !== repoCommon) return { verdict: 'retain', reason: `workspace ${workspace} does not share ${repo}'s Git object store; it is not this repository's linked worktree`, checks, workspace }
1285
+ add('git-ownership', true, common)
1286
+ } else {
1287
+ const list = worktreeList(workspace)
1288
+ if (!list) return { verdict: 'retain', reason: `cannot read the clone's worktree registration`, checks, workspace }
1289
+ if (list.length > 1) return { verdict: 'retain', reason: `clone ${workspace} has ${list.length - 1} linked borrower(s); only independent clones may be removed`, checks, workspace }
1290
+ const common = gitCommonDir(workspace)
1291
+ if (!common || common === gitCommonDir(repo)) return { verdict: 'retain', reason: `clone ${workspace} shares this repository's object store`, checks, workspace }
1292
+ add('git-ownership', true, 'independent clone')
1293
+ }
1294
+
1295
+ const origin = remoteUrl(workspace, 'origin')
1296
+ const repoOrigin = remoteUrl(repo, 'origin')
1297
+ if (!origin) return { verdict: 'retain', reason: `workspace ${workspace} has no origin remote, so a fresh target cannot be read`, checks, workspace }
1298
+ if (!repoOrigin || !sameRepoUrl(origin, repoOrigin)) {
1299
+ return { verdict: 'retain', reason: `workspace origin ${origin} is not the repository origin ${repoOrigin ?? '(missing)'}`, checks, workspace }
1300
+ }
1301
+ add('origin', true, origin)
1302
+
1303
+ const lines = statusLines(workspace)
1304
+ if (lines === null) return { verdict: 'retain', reason: `git status cannot be read in ${workspace}`, checks, workspace }
1305
+ const ignored = lines.filter((l) => l.startsWith('!!')).map((l) => l.slice(3).trim())
1306
+ const declared = lifecycleResources(unit).filter((r) => isObject(r) && nonEmpty(r.path)).map((r) => pathIdentity(r.path)).filter((i) => i.resolved).map((i) => i.real)
1307
+ const underDeclared = (rel) => {
1308
+ const ident = pathIdentity(join(workspace, rel.replace(/^"|"$/g, '')))
1309
+ return ident.resolved && declared.some((prefix) => containsPath(prefix, ident.real))
1310
+ }
1311
+ const statusPath = (line) => line.slice(3).split(' -> ').at(-1).trim().replace(/^"|"$/g, '')
1312
+ const work = lines.filter((l) => !l.startsWith('!!')).filter((l) => !underDeclared(statusPath(l)))
1313
+ if (work.length) return { verdict: 'retain', reason: `workspace ${workspace} holds ${work.length} uncommitted or untracked path(s) outside declared disposable resources; a clean tree is required`, checks, workspace }
1314
+ const unknownIgnored = ignored.filter((rel) => {
1315
+ const abs = join(workspace, rel)
1316
+ const ident = pathIdentity(abs)
1317
+ if (!ident.resolved) return true
1318
+ return !declared.some((prefix) => containsPath(prefix, ident.real))
1319
+ })
1320
+ if (unknownIgnored.length) {
1321
+ return { verdict: 'retain', reason: `workspace ${workspace} holds ${unknownIgnored.length} git-ignored path(s) outside declared disposable resources (${unknownIgnored.slice(0, 3).join(', ')}${unknownIgnored.length > 3 ? ', ...' : ''}); unknown ignored files are never stripped`, checks, workspace }
1322
+ }
1323
+ add('clean', true, 'no uncommitted, untracked, or undeclared ignored paths')
1324
+
1325
+ const interrupted = interruptedGitState(workspace)
1326
+ if (interrupted.length) return { verdict: 'retain', reason: `workspace ${workspace} has an interrupted Git operation (${interrupted.join(', ')})`, checks, workspace }
1327
+ add('git-operation', true, 'none in progress')
1328
+ const stashes = git(workspace, ['stash', 'list'])
1329
+ if (!stashes.ok || stashes.out.split('\n').filter(Boolean).length) {
1330
+ return { verdict: 'retain', reason: `workspace ${workspace} has ${stashes.ok ? stashes.out.split('\n').filter(Boolean).length : 'unreadable'} stash(es)`, checks, workspace }
1331
+ }
1332
+ add('stash', true, 'none')
1333
+
1334
+ const branchTip = git(workspace, ['rev-parse', '--verify', `refs/heads/${unit.branch}`])
1335
+ if (!branchTip.ok || branchTip.out !== head) {
1336
+ return { verdict: 'retain', reason: `branch ${unit.branch} is not at the recorded head ${head}; the workspace has local commits beyond the prepared head`, checks, workspace }
1337
+ }
1338
+ add('branch-tip', true, head)
1339
+
1340
+ // Refs and reflogs are checked below against the fresh origin ref set, after
1341
+ // delivery proof identifies the only head eligible for a squash exemption.
1342
+
1343
+ for (const resource of lifecycleResources(unit)) {
1344
+ if (!existsSync(resource.path)) return { verdict: 'retain', reason: `declared resource ${resource.path} is missing; inspect partial cleanup`, checks, workspace }
1345
+ const actual = resourceIdentity(resource.path)
1346
+ if (!sameIdentity(resource.identity, actual)) return { verdict: 'retain', reason: `declared resource ${resource.path} does not match its recorded identity`, checks, workspace }
1347
+ const actualKind = workspaceKindOf(resource.path) ?? 'directory'
1348
+ if (actualKind !== resource.kind) return { verdict: 'retain', reason: `declared resource ${resource.path} is ${actualKind}, not recorded ${resource.kind}`, checks, workspace }
1349
+ const failure = removalTreeSafety({ repo, path: resource.path, kind: actualKind, identity: resource.identity, declared })
1350
+ if (failure) return { verdict: 'retain', reason: failure, checks, workspace }
1351
+ }
1352
+ const workspaceFailure = removalTreeSafety({ repo, path: workspace, kind, identity: recordedIdentity, declared })
1353
+ if (workspaceFailure) return { verdict: 'retain', reason: workspaceFailure, checks, workspace }
1354
+ add('resources', true, 'every removal path passed Git, identity, nested-repository and lock checks')
1355
+
1356
+ const nested = nestedRepositories(workspace, declared)
1357
+ if (nested.truncated) return { verdict: 'retain', reason: `nested-repository scan of ${workspace} hit its bound before finishing; cannot prove there is no nested repository`, checks, workspace }
1358
+ if (nested.found.length) return { verdict: 'retain', reason: `workspace ${workspace} contains nested Git repository ${nested.found.slice(0, 3).join(', ')}, which this cleanup does not own`, checks, workspace }
1359
+ add('nested-repos', true, 'none')
1360
+
1361
+ const evidence = evidenceReport(unit, workspace)
1362
+ if (symlinkBelow(run, join(run, 'results'))) return { verdict: 'retain', reason: 'receipt directory passes through a symbolic link', checks, workspace, evidence }
1363
+ const receiptPath = join(run, 'results', `${safeName(unit.id)}-cleanup.json`)
1364
+ if (existsSync(receiptPath) && !lstatSync(receiptPath).isFile()) return { verdict: 'retain', reason: 'cleanup receipt destination is not a regular file', checks, workspace, evidence }
1365
+ const blockingEvidence = evidence.filter((e) => e.required && !(e.location === 'outside' && e.exists))
1366
+ if (blockingEvidence.length) {
1367
+ return { verdict: 'retain', reason: `required evidence is not preserved outside the workspace (${blockingEvidence.slice(0, 3).map((e) => e.path).join(', ')}); no transcripts are copied automatically`, checks, workspace, evidence }
1368
+ }
1369
+ add('evidence', true, evidence.length ? `${evidence.length} required path(s) outside the workspace` : 'none recorded')
1370
+
1371
+ // The refusal detail is collected per proof, not in module state: a second cleanup must
1372
+ // not report the first one's read.
1373
+ const uncertain = []
1374
+ const live = liveProcessesUsing([workspace, ...declared], { onUncertain: (detail) => uncertain.push(detail) })
1375
+ if (live === 'unreadable') return { verdict: 'retain', reason: `could not determine whether a process is using ${workspace}${uncertaintyDetail(uncertain)}; uncertainty never authorizes cleanup`, checks, workspace }
1376
+ if (live === 'unavailable') return { verdict: 'retain', reason: `no portable process-use check is available here, so ${workspace} cannot be shown to be idle`, checks, workspace }
1377
+ if (Array.isArray(live) && live.length) return { verdict: 'retain', reason: `${live.length} process(es) are using ${workspace} (${live.slice(0, 3).join(', ')}); a clean tree can still have live work in it`, checks, workspace }
1378
+ add('live-use', true, 'no use found by the bounded OS cwd/open-file scan')
1379
+
1380
+ const target = targetBranchOf(plan, unit)
1381
+ if (!target) return { verdict: 'retain', reason: `unit ${unitId} records no target branch, so merge into the target cannot be proved`, checks, workspace }
1382
+ const fresh = freshTarget({ workspace, target, apply, origin: repoOrigin })
1383
+ if (!fresh.ok) return { verdict: 'retain', reason: fresh.reason, checks, workspace }
1384
+ add('fresh-target', true, `${target} ${fresh.commit}`)
1385
+
1386
+ const direct = git(workspace, ['merge-base', '--is-ancestor', head, fresh.commit]).ok
1387
+ const proof = direct ? null : squashProof({ plan, unit, run, repo, workspace, target, fresh, headSha: head, apply, runGh: allowGh ? runGh : null })
1388
+ if (!direct && !proof.ok) return {
1389
+ verdict: 'retain', reason: `cannot prove ${head} merged into fresh origin/${target}: ${proof.reasons.join('; ')}`,
1390
+ checks, workspace, target, targetCommit: fresh.commit, evidence,
1391
+ }
1392
+ for (const tree of [{ path: workspace, kind }, ...lifecycleResources(unit)]) {
1393
+ if (tree.kind === 'directory') continue
1394
+ const published = tree.path === workspace ? fresh : freshTarget({ workspace: tree.path, target, apply, origin: repoOrigin })
1395
+ if (!published.ok) return { verdict: 'retain', reason: published.reason, checks, workspace, evidence }
1396
+ const failure = unpublishedWork(tree.path, tree.kind, published.tips, head)
1397
+ if (failure) return { verdict: 'retain', reason: failure, checks, workspace, evidence }
1398
+ }
1399
+ add('local-refs', true, 'all removable refs and reflogs are published or reachable from the verified delivered head')
1400
+ add('merged', true, direct ? `${head} is on fresh origin/${target}` : `live PR proof for ${proof.proof.mergeSha}`)
1401
+ return {
1402
+ verdict: 'remove', reason: direct ? `clean and merged: ${head} is on fresh origin/${target}` : `clean and squash-merged: ${head} has live PR proof`,
1403
+ checks, workspace, target, targetCommit: fresh.commit, evidence,
1404
+ ...(proof?.ok ? { mergeProof: proof.proof } : {}),
1405
+ }
1406
+ }
1407
+
1408
+ // The liveness refusal names what it could not read, so an operator sees the path, the
1409
+ // operation and the errno instead of only "unreadable". `details` is the caller's own list.
1410
+ function uncertaintyDetail(details) {
1411
+ const detail = details[0]
1412
+ if (!detail) return ''
1413
+ const where = [detail.path, detail.operation].filter(Boolean).join(' ')
1414
+ const limit = detail.limit ? ` (limit ${detail.limit})` : ''
1415
+ const probe = detail.probe
1416
+ ? `; ${detail.probe.operation} ${detail.probe.errno ? `failed with ${detail.probe.errno}` : 'showed the pid still alive'}`
1417
+ : ''
1418
+ return ` (${where || 'a /proc read'} failed with ${detail.errno ?? 'an unknown error'}${limit}${probe})`
1419
+ }
1420
+
1421
+ function removalTreeSafety({ repo, path, kind, identity, declared }) {
1422
+ if (!lstatSync(path).isDirectory()) return `${path} is not a real directory`
1423
+ const children = declared.filter(p => p !== pathIdentity(path).real && containsPath(pathIdentity(path).real, p))
1424
+ const nested = nestedRepositories(path, children)
1425
+ if (nested.truncated || nested.found.length) return `${path}: nested repository scan ${nested.truncated ? 'is incomplete' : `found ${nested.found.join(', ')}`}`
1426
+ if (kind === 'directory') {
1427
+ if (existsSync(join(path, '.git')) || (existsSync(join(path, 'HEAD')) && existsSync(join(path, 'objects')) && existsSync(join(path, 'refs')))) return `${path}: directory contains Git metadata`
1428
+ return null
1429
+ }
1430
+ const common = gitCommonDir(path)
1431
+ if (!common || (identity?.gitCommonDir && common !== identity.gitCommonDir)) return `${path}: Git identity changed or is unreadable`
1432
+ if (kind === 'worktree') {
1433
+ if (common !== gitCommonDir(repo) || !isRegisteredWorktree(repo, path)) return `${path}: worktree is not registered in its recorded repository`
1434
+ } else {
1435
+ const list = worktreeList(path)
1436
+ if (!list || list.length !== 1 || !containsPath(pathIdentity(path).real, pathIdentity(common).real)) return `${path}: clone has linked borrowers or an external Git object store`
1437
+ if (existsSync(join(common, 'objects', 'info', 'alternates'))) return `${path}: clone borrows an external Git object store`
1438
+ }
1439
+ const interrupted = interruptedGitState(path)
1440
+ if (interrupted.length) return `${path}: locked or interrupted Git operation (${interrupted.join(', ')})`
1441
+ const lines = statusLines(path)
1442
+ if (!lines) return `${path}: cannot read Git status`
1443
+ // Only untracked/ignored paths may be covered by a declared disposable child.
1444
+ // Tracked changes are always work, including a changed submodule entry.
1445
+ const work = lines.filter(line => {
1446
+ if (!line.startsWith('?? ') && !line.startsWith('!! ')) return true
1447
+ const rel = line.slice(3).replace(/^"|"$/g, '')
1448
+ const ident = pathIdentity(join(path, rel))
1449
+ return !ident.resolved || !children.some(child => containsPath(child, ident.real))
1450
+ })
1451
+ if (work.length) return `${path}: uncommitted, untracked or ignored work must be preserved`
1452
+ const stash = git(path, ['stash', 'list'])
1453
+ if (!stash.ok || stash.out) return `${path}: stash is present or unreadable`
1454
+ const origin = remoteUrl(path)
1455
+ if (!sameRepoUrl(origin, remoteUrl(repo))) return `${path}: origin is not the recorded repository origin`
1456
+ return null
1457
+ }
1458
+
1459
+ function unpublishedWork(path, kind, published, deliveredHead) {
1460
+ const exclusions = [...published]
1461
+ if (resolveCommit(path, deliveredHead)) exclusions.push(deliveredHead)
1462
+ const refs = git(path, ['for-each-ref', '--format=%(refname)'])
1463
+ if (!refs.ok) return `${path}: cannot read local refs`
1464
+ const names = refs.out.split('\n').filter(Boolean)
1465
+ const candidates = [currentHead(path)].filter(Boolean)
1466
+ if (kind === 'clone') {
1467
+ for (const ref of names) {
1468
+ // Replacement refs can change the meaning of every ancestry query. Do not
1469
+ // discard them based on commit reachability, which does not preserve that mapping.
1470
+ if (ref.startsWith('refs/replace/')) return `${path}: local replace ref ${ref} must be preserved`
1471
+ const commit = resolveCommit(path, ref)
1472
+ if (!commit) return `${path}: local ref ${ref} is not a verifiable commit`
1473
+ candidates.push(commit)
1474
+ }
1475
+ } else {
1476
+ // Common refs survive git worktree remove. They can preserve this worktree's
1477
+ // former HEADs without making another unit's private branch a global blocker.
1478
+ for (const ref of names) {
1479
+ const commit = resolveCommit(path, ref)
1480
+ if (commit) exclusions.push(commit)
1481
+ }
1482
+ // Worktree-private refs do NOT survive removal.
1483
+ const privateRefs = names.filter(ref => /^(refs\/(worktree|bisect|rewritten)\/)/.test(ref))
1484
+ if (privateRefs.length) return `${path}: private worktree refs must be preserved`
1485
+ }
1486
+ const outside = git(path, ['rev-list', '--count', ...candidates, '--not', ...exclusions])
1487
+ if (!outside.ok || outside.out !== '0') return `${path}: unpublished local ref or HEAD commit(s) must be preserved`
1488
+ const reflogs = git(path, ['reflog', 'show', ...(kind === 'clone' ? ['--all'] : ['HEAD']), '--format=%H'])
1489
+ if (!reflogs.ok) return `${path}: cannot read reflog`
1490
+ const tips = [...new Set(reflogs.out.split('\n').filter(isHexSha))]
1491
+ if (tips.length) {
1492
+ const result = git(path, ['rev-list', '--count', ...tips, '--not', ...exclusions])
1493
+ if (!result.ok || result.out !== '0') return `${path}: unpublished reflog commit(s) must be preserved`
1494
+ }
1495
+ return null
1496
+ }
1497
+
1498
+ // ---- removal ----
1499
+
1500
+ function removePath(path) {
1501
+ try {
1502
+ rmSync(path, { recursive: true, force: true })
1503
+ return null
1504
+ } catch (error) {
1505
+ try {
1506
+ execFileSync('chmod', ['-R', 'u+w', path], { stdio: 'ignore' })
1507
+ rmSync(path, { recursive: true, force: true })
1508
+ return null
1509
+ } catch {
1510
+ return `${path}: ${error?.code ?? error?.message ?? 'removal failed'}`
1511
+ }
1512
+ }
1513
+ }
1514
+
1515
+ function writeReceipt(run, unitId, payload) {
1516
+ const dir = join(run, 'results')
1517
+ if (symlinkBelow(run, dir)) throw new LifecycleError('symlink', 'receipt directory passes through a symbolic link')
1518
+ mkdirSync(dir, { recursive: true })
1519
+ const path = join(dir, `${safeName(unitId)}-cleanup.json`)
1520
+ const temporary = `${path}.${process.pid}.tmp`
1521
+ try {
1522
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx' })
1523
+ renameSync(temporary, path)
1524
+ } finally {
1525
+ try { unlinkSync(temporary) } catch { /* rename consumed it */ }
1526
+ }
1527
+ return path
1528
+ }
1529
+
1530
+ function removalRecords(unit) {
1531
+ return [{ path: unit.workspace, kind: unit.lifecycle.workspace.kind, identity: unit.lifecycle.workspace.identity }, ...lifecycleResources(unit)]
1532
+ }
1533
+
1534
+ function verifiedReceipt({ repo, run, unit }) {
1535
+ const fail = reason => ({ ok: false, reason })
1536
+ const receipt = join(run, 'results', `${safeName(unit.id)}-cleanup.json`)
1537
+ let saved
1538
+ try {
1539
+ if (symlinkBelow(run, receipt)) return fail('receipt path passes through a symbolic link')
1540
+ saved = JSON.parse(readFileSync(receipt, 'utf8'))
1541
+ } catch { return fail('no readable cleanup receipt') }
1542
+ const ws = unit.lifecycle?.workspace
1543
+ if (saved.version !== RECEIPT_VERSION || saved.kind !== 'workspace-cleanup-postcondition'
1544
+ || saved.unit !== String(unit.id) || saved.run !== normPath(run) || saved.repo !== normPath(repo)
1545
+ || !isHexSha(saved.headSha) || saved.headSha !== unit.lifecycle?.cleanup?.headSha
1546
+ || (unit.headSha !== undefined && saved.headSha !== unit.headSha)
1547
+ || saved.workspace?.path !== normPath(unit.workspace) || saved.workspace?.kind !== ws?.kind
1548
+ || saved.workspace?.removed !== true || !sameIdentity(ws?.identity, saved.workspace?.identity)
1549
+ || JSON.stringify(ws?.identity) !== JSON.stringify(saved.workspace?.identity)) return fail('receipt does not bind this run, unit, head and workspace identity')
1550
+ const records = removalRecords(unit)
1551
+ if (!Array.isArray(saved.resources) || saved.resources.length !== records.length - 1
1552
+ || !Array.isArray(saved.removedPaths) || saved.removedPaths.length !== records.length
1553
+ || new Set(saved.removedPaths).size !== records.length) return fail('receipt does not cover every removal path')
1554
+ const list = worktreeList(repo)
1555
+ if (!list) return fail('worktree registrations cannot be read')
1556
+ for (const record of records) {
1557
+ if (!saved.removedPaths.includes(normPath(record.path))) return fail(`receipt omits ${record.path}`)
1558
+ if (record !== records[0]) {
1559
+ const item = saved.resources.find(r => r.path === record.path)
1560
+ if (!item || item.removed !== true || item.kind !== record.kind || JSON.stringify(item.identity) !== JSON.stringify(record.identity)) return fail(`receipt does not bind resource ${record.path}`)
1561
+ }
1562
+ const ident = pathIdentity(record.path)
1563
+ if (!ident.resolved || ident.exists || symlinkBelow(run, record.path)) return fail(`removal path still exists or cannot be identified: ${record.path}`)
1564
+ if (list.some(p => pathIdentity(p).real === ident.real)) return fail(`worktree remains registered: ${record.path}`)
1565
+ }
1566
+ if (evidenceReport(unit, unit.workspace).some(e => e.required && !e.preserved)) return fail('required evidence is not preserved')
1567
+ return { ok: true, receipt }
1568
+ }
1569
+
1570
+ function removeOwnedTree({ repo, path, kind }) {
1571
+ if (kind === 'worktree') {
1572
+ if (!isRegisteredWorktree(repo, path)) return `${path}: not a registered worktree; refusing a raw removal`
1573
+ const result = git(repo, ['worktree', 'remove', path], { timeout: 120000 })
1574
+ if (!result.ok) return `${path}: git worktree remove failed: ${result.err || result.out}`
1575
+ if (existsSync(path)) return `${path}: still exists after git worktree remove`
1576
+ return null
1577
+ }
1578
+ return removePath(path)
1579
+ }
1580
+
1581
+ function applyRemoval({ repo, run, plan, unit, planResult }) {
1582
+ const unitId = String(unit.id)
1583
+ const workspace = normPath(unit.workspace)
1584
+ const lifecycle = unit.lifecycle
1585
+ const failures = []
1586
+ const removed = []
1587
+ const resources = lifecycleResources(unit)
1588
+ // Recheck the plan after remote/provider calls and before the first deletion.
1589
+ const latest = readPlan(run)
1590
+ requireRevision(latest.plan, plan.planRevision)
1591
+ if (JSON.stringify(latest.plan) !== JSON.stringify(plan)) throw new LifecycleError('revision-mismatch', 'plan changed during cleanup preflight')
1592
+ const records = removalRecords(unit).sort((a, b) => b.path.split(sep).length - a.path.split(sep).length)
1593
+ const declared = resources.map(r => pathIdentity(r.path).real)
1594
+ for (const record of records) {
1595
+ if (symlinkBelow(run, record.path) || !sameIdentity(record.identity, resourceIdentity(record.path))
1596
+ || (workspaceKindOf(record.path) ?? 'directory') !== record.kind) return { failures: [`${record.path}: identity or kind changed before deletion`], removed }
1597
+ const failure = removalTreeSafety({ repo, ...record, declared })
1598
+ if (failure) return { failures: [failure], removed }
1599
+ }
1600
+ if (currentHead(workspace) !== planResult.cleanup.headSha) return { failures: ['workspace HEAD changed during preflight'], removed }
1601
+ // Children precede their containers. Git worktrees always go through Git, never rm.
1602
+ for (const record of records) {
1603
+ try {
1604
+ if (symlinkBelow(run, record.path) || !sameIdentity(record.identity, resourceIdentity(record.path))) {
1605
+ failures.push(`${record.path}: identity changed during removal`)
1606
+ break
1607
+ }
1608
+ const failure = removeOwnedTree({ repo, path: record.path, kind: record.kind })
1609
+ if (failure) { failures.push(failure); break }
1610
+ removed.push(normPath(record.path))
1611
+ } catch (error) {
1612
+ failures.push(`${record.path}: ${error.message}`)
1613
+ break
1614
+ }
1615
+ }
1616
+ const registrations = worktreeList(repo)
1617
+ if (!registrations) failures.push('cannot verify final worktree registrations')
1618
+ for (const record of records) {
1619
+ const postcondition = pathIdentity(record.path)
1620
+ if (!postcondition.resolved || postcondition.exists || symlinkBelow(run, record.path)) failures.push(`${record.path}: removal postcondition is not established`)
1621
+ if (registrations?.some(p => pathIdentity(p).real === pathIdentity(record.path).real)) failures.push(`${record.path}: still registered after removal`)
1622
+ }
1623
+ if (failures.length) return { failures, removed }
1624
+
1625
+ const receiptPayload = {
1626
+ version: RECEIPT_VERSION,
1627
+ kind: 'workspace-cleanup-postcondition',
1628
+ unit: unitId,
1629
+ run: normPath(run),
1630
+ repo: normPath(repo),
1631
+ at: new Date().toISOString(),
1632
+ planRevision: plan.planRevision,
1633
+ headSha: planResult.cleanup.headSha,
1634
+ workspace: {
1635
+ path: workspace,
1636
+ kind: lifecycle.workspace.kind,
1637
+ identity: lifecycle.workspace.identity,
1638
+ removed: true,
1639
+ },
1640
+ resources: resources.map((r) => ({ path: r.path, role: r.role, kind: r.kind, identity: r.identity, removed: !existsSync(r.path) })),
1641
+ removedPaths: removed,
1642
+ target: planResult.target ?? null,
1643
+ targetCommit: planResult.targetCommit ?? null,
1644
+ mergeProof: planResult.mergeProof ?? null,
1645
+ evidence: planResult.evidence ?? [],
1646
+ }
1647
+ let receipt = null
1648
+ try { receipt = writeReceipt(run, unitId, receiptPayload) } catch (error) { failures.push(`could not write cleanup receipt: ${error.message}`) }
1649
+ return { failures, removed, receipt }
1650
+ }
1651
+
1652
+ export function cleanup({
1653
+ run, unit: unitId, revision, apply = false, requireApplying = true, allowGh = true, runGh = null,
1654
+ }) {
1655
+ run = resolve(run)
1656
+ const first = readPlan(run)
1657
+ const repo = repositoryOf(first.plan, run)
1658
+ requireRevision(first.plan, revision)
1659
+ const unit = unitOf(first.plan, unitId)
1660
+ assertValid(validateUnit({ repo, run, plan: first.plan, unit }))
1661
+ const runDir = canonicalRunDir(repo, run)
1662
+
1663
+ if (!apply) {
1664
+ // A dry run previews the same checks, but it does not demand the coordinator's applying
1665
+ // binding yet: that binding is what --apply checks, and the plan must be able to see what
1666
+ // it would remove before making the transition.
1667
+ const check = checkWorkspace({ repo, plan: first.plan, unit, run: runDir, apply: false, requireApplying: false, allowGh, runGh })
1668
+ const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
1669
+ const needsBinding = requireApplying && lifecycle?.cleanup?.state !== 'applying' && lifecycle?.cleanup?.state !== 'complete'
1670
+ const wouldRemove = check.verdict === 'remove'
1671
+ return {
1672
+ version: 1,
1673
+ command: 'cleanup',
1674
+ ok: check.verdict === 'remove' || check.verdict === 'already-complete',
1675
+ applied: false,
1676
+ action: check.verdict,
1677
+ wouldRemove,
1678
+ authorizationRequired: needsBinding && check.verdict === 'remove' ? 'applying' : null,
1679
+ repo,
1680
+ run: runDir,
1681
+ unit: String(unitId),
1682
+ revision: planRevisionOf(first.plan),
1683
+ workspace: { path: normPath(unit.workspace), exists: existsSync(unit.workspace), kind: lifecycle?.workspace?.kind ?? null },
1684
+ lifecycle: null,
1685
+ nextLifecycle: lifecycle,
1686
+ requiredTransition: needsBinding && check.verdict === 'remove'
1687
+ ? { cleanup: { state: 'applying', headSha: lifecycle?.cleanup?.headSha ?? unit.headSha ?? check.checks.find((c) => c.name === 'head')?.detail } }
1688
+ : null,
1689
+ checks: check.checks,
1690
+ paths: check.verdict === 'remove' ? [unit.workspace, ...lifecycleResources(unit).map((r) => r.path)] : [],
1691
+ reason: needsBinding && check.verdict === 'remove'
1692
+ ? `${check.reason}; bind cleanup.state=applying through update_plan before --apply`
1693
+ : check.reason,
1694
+ receipt: check.receipt ?? null,
1695
+ evidence: check.evidence ?? [],
1696
+ errors: [],
1697
+ }
1698
+ }
1699
+
1700
+ const lock = acquireLock(runDir, unitId)
1701
+ try {
1702
+ // Revision and identity are rechecked under the lock, immediately before mutation.
1703
+ const current = readPlan(run)
1704
+ requireRevision(current.plan, revision)
1705
+ const freshUnit = unitOf(current.plan, unitId)
1706
+ assertValid(validateUnit({ repo, run, plan: current.plan, unit: freshUnit }))
1707
+ const check = checkWorkspace({ repo, plan: current.plan, unit: freshUnit, run: runDir, apply: true, requireApplying, allowGh, runGh })
1708
+
1709
+ if (check.verdict === 'remove') {
1710
+ const removal = applyRemoval({
1711
+ repo, run: runDir, plan: current.plan, unit: freshUnit,
1712
+ planResult: { cleanup: { headSha: check.checks.find((c) => c.name === 'head')?.detail ?? freshUnit.lifecycle?.cleanup?.headSha }, target: check.target, targetCommit: check.targetCommit, mergeProof: check.mergeProof, evidence: check.evidence },
1713
+ })
1714
+ if (removal.failures.length) {
1715
+ const lifecycle = {
1716
+ ...freshUnit.lifecycle,
1717
+ cleanup: { ...(freshUnit.lifecycle.cleanup ?? {}), state: 'applying', headSha: freshUnit.lifecycle.cleanup?.headSha ?? currentHead(freshUnit.workspace), reason: removal.failures.join('; '), removedPaths: removal.removed, failureCount: removal.failures.length },
1718
+ }
1719
+ return {
1720
+ version: 1, command: 'cleanup', ok: false, applied: true, action: 'retain', code: 'retained',
1721
+ repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
1722
+ workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: freshUnit.lifecycle.workspace.kind },
1723
+ lifecycle, nextLifecycle: lifecycle, checks: check.checks,
1724
+ paths: removal.removed, reason: removal.failures.join('; '), receipt: null,
1725
+ evidence: check.evidence ?? [], errors: removal.failures,
1726
+ }
1727
+ }
1728
+ const lifecycle = {
1729
+ ...freshUnit.lifecycle,
1730
+ cleanup: {
1731
+ ...(freshUnit.lifecycle.cleanup ?? {}),
1732
+ state: 'complete',
1733
+ headSha: freshUnit.lifecycle.cleanup?.headSha ?? check.checks.find((c) => c.name === 'head')?.detail,
1734
+ completedAt: new Date().toISOString(),
1735
+ ...(removal.receipt ? { receipt: removal.receipt } : {}),
1736
+ },
1737
+ }
1738
+ return {
1739
+ version: 1, command: 'cleanup', ok: true, applied: true, action: 'remove', code: 'removed',
1740
+ repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
1741
+ workspace: { path: normPath(freshUnit.workspace), exists: false, kind: freshUnit.lifecycle.workspace.kind },
1742
+ lifecycle, nextLifecycle: lifecycle, checks: check.checks,
1743
+ paths: removal.removed, reason: check.reason, receipt: removal.receipt,
1744
+ evidence: check.evidence ?? [], errors: [],
1745
+ }
1746
+ }
1747
+
1748
+ if (check.verdict === 'already-complete') {
1749
+ const receipt = check.receipt
1750
+ const lifecycle = {
1751
+ ...freshUnit.lifecycle,
1752
+ cleanup: { ...(freshUnit.lifecycle.cleanup ?? {}), state: 'complete', ...(receipt ? { receipt } : {}) },
1753
+ }
1754
+ return {
1755
+ version: 1, command: 'cleanup', ok: true, applied: false, action: 'already-complete', code: 'complete',
1756
+ repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
1757
+ workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: freshUnit.lifecycle.workspace.kind },
1758
+ lifecycle, nextLifecycle: lifecycle, checks: check.checks, paths: [],
1759
+ reason: check.reason, receipt, evidence: check.evidence ?? [], errors: [],
1760
+ }
1761
+ }
1762
+
1763
+ const baseLifecycle = isObject(freshUnit.lifecycle) ? freshUnit.lifecycle : null
1764
+ const lifecycle = baseLifecycle
1765
+ ? {
1766
+ ...baseLifecycle,
1767
+ cleanup: { ...(baseLifecycle.cleanup ?? {}), state: !existsSync(freshUnit.workspace) && baseLifecycle.cleanup?.state === 'applying' ? 'applying' : 'retained', headSha: currentHead(freshUnit.workspace) ?? baseLifecycle.cleanup?.headSha ?? null, reason: check.reason },
1768
+ }
1769
+ : null
1770
+ return {
1771
+ version: 1, command: 'cleanup', ok: false, applied: false, action: check.verdict, code: check.verdict === 'refuse' ? 'refused' : 'retained',
1772
+ repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
1773
+ workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: baseLifecycle?.workspace?.kind ?? null },
1774
+ lifecycle, nextLifecycle: lifecycle, checks: check.checks, paths: [],
1775
+ reason: check.reason, receipt: null, evidence: check.evidence ?? [], errors: [],
1776
+ }
1777
+ } finally {
1778
+ lock.release()
1779
+ }
1780
+ }
1781
+
1782
+ // ---- legacy --merged compatibility ----
1783
+
1784
+ // The old flow (`cleanup-runs.mjs --merged <workspace> --apply`) had no --run or --unit.
1785
+ // Resolve the saved run and unit that own the path, then use the same checks. An unowned
1786
+ // path is retained: a path that no plan claims is not cleaned by a glob.
1787
+ export function resolveMerged(repo, targetPath, { discoveryRoot = null } = {}) {
1788
+ const path = pathIdentity(resolve(targetPath))
1789
+ if (!path.resolved) return { found: false, reason: `cannot resolve ${targetPath}: ${path.reason}` }
1790
+ const roots = []
1791
+ if (discoveryRoot) roots.push(discoveryRoot)
1792
+ else {
1793
+ const marker = `${sep}.resolve-issues${sep}rush${sep}`
1794
+ const text = `${path.real}${sep}`
1795
+ const at = text.indexOf(marker)
1796
+ if (at >= 0) roots.push(text.slice(0, at + marker.length - 1))
1797
+ }
1798
+ if (!roots.length && repo) roots.push(join(repo, '.resolve-issues', 'rush'))
1799
+ const runsRoot = roots[0]
1800
+ if (!runsRoot) return { found: false, reason: `${targetPath} is not under a .resolve-issues/rush directory` }
1801
+ let names = []
1802
+ try { names = readdirSync(runsRoot) } catch { return { found: false, reason: `cannot read ${runsRoot}` } }
1803
+ for (const name of names) {
1804
+ const run = join(runsRoot, name)
1805
+ let plan
1806
+ try { plan = readPlan(run).plan } catch { continue }
1807
+ const units = Array.isArray(plan.units) ? plan.units : (Array.isArray(plan.issues) ? plan.issues : [])
1808
+ for (const unit of units) {
1809
+ if (!isObject(unit)) continue
1810
+ if (nonEmpty(unit.workspace)) {
1811
+ const ws = pathIdentity(unit.workspace)
1812
+ if (ws.resolved && ws.real === path.real) return { found: true, run, repo: repositoryOf(plan, run), plan, unit, role: 'workspace' }
1813
+ }
1814
+ for (const resource of lifecycleResources(unit)) {
1815
+ if (!isObject(resource) || !nonEmpty(resource.path)) continue
1816
+ const ident = pathIdentity(resource.path)
1817
+ if (ident.resolved && ident.real === path.real) return { found: true, run, repo: repositoryOf(plan, run), plan, unit, role: 'resource', resource }
1818
+ }
1819
+ }
1820
+ }
1821
+ return { found: false, reason: `no saved run or unit claims ${targetPath}; an unowned path is retained` }
1822
+ }
1823
+
1824
+ export function cleanupMerged({ repo, path, apply = false, requireApplying = false, runGh = null }) {
1825
+ const resolved = resolveMerged(repo, path)
1826
+ if (!resolved.found) {
1827
+ return {
1828
+ version: 1, command: 'merged', ok: false, applied: false, action: 'refuse',
1829
+ repo, run: null, unit: null, revision: null, workspace: { path: normPath(path), exists: existsSync(path) },
1830
+ lifecycle: null, nextLifecycle: null, checks: [], paths: [], reason: resolved.reason, receipt: null, evidence: [], errors: [],
1831
+ }
1832
+ }
1833
+ // Compatibility resolves ownership only; both workspace and resource requests enter
1834
+ // the same whole-environment checks, lock, revision binding and receipt path.
1835
+ const result = cleanup({ run: resolved.run, unit: resolved.unit.id, revision: resolved.plan.planRevision, apply, requireApplying, runGh })
1836
+ return { ...result, command: 'merged' }
1837
+ }
1838
+
1839
+ // ---- safe --repo sweep ----
1840
+
1841
+ export function sweep({ repo, apply = false, runGh = null }) {
1842
+ repo = resolve(repo)
1843
+ const runsRoot = join(repo, '.resolve-issues', 'rush')
1844
+ const result = {
1845
+ version: 1,
1846
+ command: 'sweep',
1847
+ ok: true,
1848
+ applied: Boolean(apply),
1849
+ repo: normPath(repo),
1850
+ runsRoot,
1851
+ runs: [],
1852
+ unknownEntries: [],
1853
+ summary: { runs: 0, completed: 0, removed: 0, retained: 0, refused: 0, unknown: 0 },
1854
+ errors: [],
1855
+ }
1856
+ if (!existsSync(runsRoot)) return result
1857
+ let names = []
1858
+ try { names = readdirSync(runsRoot, { withFileTypes: true }) } catch (error) {
1859
+ return { ...result, ok: false, errors: [`cannot read ${runsRoot}: ${error.message}`] }
1860
+ }
1861
+ for (const entry of names) {
1862
+ if (!entry.isDirectory()) { result.unknownEntries.push(entry.name); continue }
1863
+ const run = join(runsRoot, entry.name)
1864
+ if (!existsSync(join(run, 'task-plan.json'))) { result.unknownEntries.push(entry.name); continue }
1865
+ result.summary.runs++
1866
+ let planRead
1867
+ try { planRead = readPlan(run) } catch (error) {
1868
+ result.runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}` })
1869
+ result.summary.retained++
1870
+ continue
1871
+ }
1872
+ const { plan, units } = planRead
1873
+ const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
1874
+ for (const unit of units) {
1875
+ if (!isObject(unit)) continue
1876
+ const unitId = String(unit.id ?? '')
1877
+ if (!unitCompleted(unit)) {
1878
+ runRow.units.push({ unit: unitId, action: 'retain', reason: 'not a completed unit' })
1879
+ result.summary.retained++
1880
+ continue
1881
+ }
1882
+ result.summary.completed++
1883
+ const lifecycle = unit.lifecycle
1884
+ if (!isObject(lifecycle)) {
1885
+ runRow.units.push({ unit: unitId, action: 'retain', reason: 'no lifecycle record; the sweep does not adopt an unrecorded tree' })
1886
+ result.summary.retained++
1887
+ continue
1888
+ }
1889
+ try {
1890
+ const observed = cleanup({ run, unit: unitId, revision: plan.planRevision, apply, requireApplying: false, runGh })
1891
+ runRow.units.push(observed)
1892
+ if (observed.action === 'remove' && observed.applied) result.summary.removed++
1893
+ else if (observed.action === 'refuse') result.summary.refused++
1894
+ else if (!observed.ok) result.summary.retained++
1895
+ } catch (error) {
1896
+ runRow.units.push({ unit: unitId, action: 'inspect', reason: `${error.code ?? 'error'}: ${error.message}` })
1897
+ result.summary.retained++
1898
+ }
1899
+ }
1900
+ result.runs.push(runRow)
1901
+ }
1902
+ result.summary.unknown = result.unknownEntries.length
1903
+ return result
1904
+ }
1905
+
1906
+ // ---- recover (read-only) ----
1907
+
1908
+ export function recover({ repo, runGh = null }) {
1909
+ repo = resolve(repo)
1910
+ const runsRoot = join(repo, '.resolve-issues', 'rush')
1911
+ const out = {
1912
+ version: 1,
1913
+ command: 'recover',
1914
+ ok: true,
1915
+ readonly: true,
1916
+ repo: normPath(repo),
1917
+ runsRoot,
1918
+ runs: [],
1919
+ worktrees: [],
1920
+ locks: [],
1921
+ due: [],
1922
+ unknownEntries: [],
1923
+ }
1924
+ const owned = new Map()
1925
+ let names = []
1926
+ try { names = readdirSync(runsRoot, { withFileTypes: true }) } catch (error) {
1927
+ return { ...out, ok: false, errors: [`cannot read ${runsRoot}: ${error.message}`] }
1928
+ }
1929
+ for (const entry of names) {
1930
+ if (!entry.isDirectory()) { out.unknownEntries.push(entry.name); continue }
1931
+ const run = join(runsRoot, entry.name)
1932
+ if (!existsSync(join(run, 'task-plan.json'))) { out.unknownEntries.push(entry.name); continue }
1933
+ let planRead
1934
+ try { planRead = readPlan(run) } catch (error) {
1935
+ out.runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}`, units: [] })
1936
+ continue
1937
+ }
1938
+ const { plan, units } = planRead
1939
+ const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
1940
+ for (const unit of units) {
1941
+ if (!isObject(unit)) continue
1942
+ const unitId = String(unit.id ?? '')
1943
+ if (nonEmpty(unit.workspace)) {
1944
+ const ident = pathIdentity(unit.workspace)
1945
+ if (ident.resolved) owned.set(ident.real, `${unitId}:workspace`)
1946
+ }
1947
+ for (const resource of lifecycleResources(unit)) {
1948
+ if (!isObject(resource) || !nonEmpty(resource.path)) continue
1949
+ const ident = pathIdentity(resource.path)
1950
+ if (ident.resolved) owned.set(ident.real, `${unitId}:resource:${resource.role ?? 'unknown'}`)
1951
+ }
1952
+ const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
1953
+ const row = {
1954
+ unit: unitId,
1955
+ status: unit.status ?? null,
1956
+ delivery: isObject(unit.delivery) ? (unit.delivery.outcome ?? unit.delivery.state ?? null) : (unit.delivery ?? null),
1957
+ workspace: { path: unit.workspace ?? null, exists: nonEmpty(unit.workspace) ? existsSync(unit.workspace) : false, kind: lifecycle?.workspace?.kind ?? null },
1958
+ cleanup: lifecycle?.cleanup ?? null,
1959
+ resources: lifecycleResources(unit).map((r) => ({ path: r.path, role: r.role, exists: existsSync(r.path), state: r.state })),
1960
+ action: 'none',
1961
+ reason: null,
1962
+ }
1963
+ const cleanup = lifecycle?.cleanup ?? null
1964
+ if (lifecycle?.workspace?.state === 'preparing' && cleanup?.state !== 'complete' && cleanup?.state !== 'retained') {
1965
+ row.action = 'prepare'
1966
+ row.reason = `lifecycle.workspace.state is preparing; prepare the recorded ${lifecycle.workspace.kind} and persist its ready identity`
1967
+ out.due.push({ run: normPath(run), unit: unitId, operation: 'prepare', workspace: unit.workspace ?? null, reason: row.reason })
1968
+ } else if (isObject(cleanup) && cleanup.state === 'complete') {
1969
+ const completion = verifiedReceipt({ repo: repositoryOf(plan, run), run: canonicalRunDir(repo, run), unit })
1970
+ if (!completion.ok) {
1971
+ row.action = 'inspect'
1972
+ row.reason = `completion cannot be verified: ${completion.reason}`
1973
+ out.due.push({ run: normPath(run), unit: unitId, operation: 'inspect', workspace: unit.workspace, reason: row.reason })
1974
+ }
1975
+ } else if (isObject(cleanup) && cleanup.state === 'applying') {
1976
+ if (!row.workspace.exists) {
1977
+ const completion = verifiedReceipt({ repo: repositoryOf(plan, run), run: canonicalRunDir(repo, run), unit })
1978
+ row.action = completion.ok ? 'postcondition' : 'inspect'
1979
+ row.reason = completion.ok ? `verified cleanup receipt ${completion.receipt}; persist complete` : `ambiguous partial cleanup: ${completion.reason}`
1980
+ out.due.push({ run: normPath(run), unit: unitId, operation: completion.ok ? 'recover-postcondition' : 'inspect', workspace: unit.workspace ?? null, receipt: completion.receipt ?? null, reason: row.reason })
1981
+ } else {
1982
+ row.action = 'cleanup-due'
1983
+ row.reason = 'cleanup.state is applying and the workspace still exists; rerun cleanup --apply'
1984
+ out.due.push({ run: normPath(run), unit: unitId, operation: 'cleanup', workspace: unit.workspace ?? null, reason: row.reason })
1985
+ }
1986
+ } else if (unitCompleted(unit) && lifecycle && cleanup?.state !== 'complete' && row.workspace.exists) {
1987
+ row.action = 'cleanup-due'
1988
+ row.reason = `unit is completed and cleanup.state is ${cleanup?.state ?? 'missing'}; verify and bind applying before apply`
1989
+ out.due.push({ run: normPath(run), unit: unitId, operation: 'cleanup', workspace: unit.workspace ?? null, reason: row.reason })
1990
+ } else if (!row.workspace.exists && lifecycle?.workspace?.state === 'ready') {
1991
+ row.action = 'inspect'
1992
+ row.reason = 'lifecycle records a ready workspace that is missing'
1993
+ out.due.push({ run: normPath(run), unit: unitId, operation: 'inspect', workspace: unit.workspace ?? null, reason: row.reason })
1994
+ }
1995
+ runRow.units.push(row)
1996
+ }
1997
+ out.runs.push(runRow)
1998
+ try {
1999
+ const locks = readdirSync(join(run, '.workspace-locks'), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name.replace(/\.lock$/, ''))
2000
+ for (const lock of locks) {
2001
+ const path = join(run, '.workspace-locks', `${lock}.lock`)
2002
+ out.locks.push({ run: normPath(run), unit: lock, path })
2003
+ out.due.push({ run: normPath(run), unit: lock, operation: 'lock', path, reason: 'a workspace lock is held; the lock is never reclaimed by age, so inspect the owner and remove it only after confirming no prepare or cleanup is running' })
2004
+ }
2005
+ } catch { /* no locks directory */ }
2006
+ }
2007
+ const listed = worktreeList(repo)
2008
+ if (listed) {
2009
+ const runsReal = pathIdentity(runsRoot).real
2010
+ for (const path of listed) {
2011
+ const ident = pathIdentity(path)
2012
+ if (!ident.resolved) {
2013
+ out.worktrees.push({ path, resolved: false, ownedBy: owned.get(ident.recorded) ?? null, insideRoot: false })
2014
+ continue
2015
+ }
2016
+ const isRepository = ident.real === pathIdentity(repo).real
2017
+ out.worktrees.push({
2018
+ path: ident.real,
2019
+ ownedBy: owned.get(ident.real) ?? null,
2020
+ insideRoot: runsReal ? containsPath(runsReal, ident.real) : false,
2021
+ isRepository,
2022
+ })
2023
+ if (!isRepository && !owned.get(ident.real)) {
2024
+ out.due.push({ operation: 'orphan-worktree', path: ident.real, reason: 'a registered worktree is not owned by any unit in a saved plan; inventory only, never removed by pattern or age' })
2025
+ }
2026
+ }
2027
+ }
2028
+ out.ownedPaths = [...owned.entries()].map(([path, owner]) => ({ path, owner }))
2029
+ return out
2030
+ }
2031
+
2032
+ // ---- inventory (read-only, bounded, no symlink traversal) ----
2033
+
2034
+ export function inventory({ repo, root = null, maxEntries = INVENTORY_MAX_ENTRIES, maxDepth = INVENTORY_MAX_DEPTH }) {
2035
+ repo = resolve(repo)
2036
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) throw new LifecycleError('inventory-bound', '--max-entries must be a positive integer')
2037
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 0) throw new LifecycleError('inventory-bound', '--max-depth must be a nonnegative integer')
2038
+ const defaultRoot = join(repo, '.resolve-issues', 'rush')
2039
+ const inventoryRoot = root ? resolve(root) : defaultRoot
2040
+ const out = {
2041
+ version: 1,
2042
+ command: 'inventory',
2043
+ ok: true,
2044
+ readonly: true,
2045
+ repo: normPath(repo),
2046
+ root: normPath(inventoryRoot),
2047
+ maxEntries,
2048
+ maxDepth,
2049
+ entries: [],
2050
+ runs: [],
2051
+ registeredWorktrees: [],
2052
+ truncated: false,
2053
+ unknownEntries: [],
2054
+ }
2055
+ const owned = new Map()
2056
+ const registered = new Set()
2057
+ const runs = []
2058
+ if (existsSync(defaultRoot)) {
2059
+ let names = []
2060
+ try { names = readdirSync(defaultRoot, { withFileTypes: true }) } catch { names = [] }
2061
+ for (const entry of names) {
2062
+ if (!entry.isDirectory()) { out.unknownEntries.push(entry.name); continue }
2063
+ const run = join(defaultRoot, entry.name)
2064
+ if (!existsSync(join(run, 'task-plan.json'))) { out.unknownEntries.push(entry.name); continue }
2065
+ try {
2066
+ const { plan, units } = readPlan(run)
2067
+ const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
2068
+ for (const unit of units) {
2069
+ if (!isObject(unit)) continue
2070
+ const unitId = String(unit.id ?? '')
2071
+ const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
2072
+ runRow.units.push({
2073
+ unit: unitId,
2074
+ workspace: unit.workspace ?? null,
2075
+ branch: unit.branch ?? null,
2076
+ lifecycle: lifecycle ? { state: lifecycle.workspace?.state, kind: lifecycle.workspace?.kind, cleanup: lifecycle.cleanup ?? null } : null,
2077
+ resources: lifecycleResources(unit).map((r) => ({ path: r.path, role: r.role, state: r.state })),
2078
+ })
2079
+ if (nonEmpty(unit.workspace)) {
2080
+ const ident = pathIdentity(unit.workspace)
2081
+ if (ident.resolved) owned.set(ident.real, `${unitId}:workspace`)
2082
+ }
2083
+ for (const resource of lifecycleResources(unit)) {
2084
+ if (!isObject(resource) || !nonEmpty(resource.path)) continue
2085
+ const ident = pathIdentity(resource.path)
2086
+ if (ident.resolved) owned.set(ident.real, `${unitId}:resource:${resource.role ?? 'unknown'}`)
2087
+ }
2088
+ }
2089
+ runs.push(runRow)
2090
+ } catch (error) {
2091
+ out.unknownEntries.push(entry.name)
2092
+ runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}` })
2093
+ }
2094
+ }
2095
+ }
2096
+ out.runs = runs
2097
+
2098
+ // Walk the requested root without following symlinks. A symlink is reported as an entry
2099
+ // and never traversed; the bound is on entries, not on the size of the filesystem.
2100
+ const rootIdent = pathIdentity(inventoryRoot)
2101
+ let rootIsSymlink = false
2102
+ try { rootIsSymlink = lstatSync(inventoryRoot).isSymbolicLink() } catch { /* missing is handled below */ }
2103
+ if (!rootIdent.resolved || !rootIdent.exists) {
2104
+ out.reason = rootIdent.resolved ? `${inventoryRoot} does not exist` : rootIdent.reason
2105
+ } else if (rootIsSymlink) {
2106
+ out.reason = `${inventoryRoot} is a symbolic link; inventory does not follow it`
2107
+ } else {
2108
+ const queue = [{ path: rootIdent.real, depth: 0 }]
2109
+ while (queue.length) {
2110
+ if (out.entries.length >= maxEntries) { out.truncated = true; break }
2111
+ const { path, depth } = queue.shift()
2112
+ let entries = []
2113
+ try { entries = readdirSync(path, { withFileTypes: true }) } catch { continue }
2114
+ for (const entry of entries) {
2115
+ if (out.entries.length >= maxEntries) { out.truncated = true; break }
2116
+ const child = join(path, entry.name)
2117
+ let kind = 'other'
2118
+ if (entry.isSymbolicLink()) kind = 'symlink'
2119
+ else if (entry.isDirectory()) kind = 'directory'
2120
+ else if (entry.isFile()) kind = 'file'
2121
+ const ident = pathIdentity(child)
2122
+ const ownedBy = ident.resolved ? owned.get(ident.real) ?? null : null
2123
+ const row = {
2124
+ path: child,
2125
+ depth,
2126
+ kind,
2127
+ ownedBy,
2128
+ registeredWorktree: false,
2129
+ }
2130
+ try { row.size = entry.isFile() ? statSync(child).size : null } catch { row.size = null }
2131
+ out.entries.push(row)
2132
+ if (kind === 'directory' && depth + 1 <= maxDepth) queue.push({ path: child, depth: depth + 1 })
2133
+ else if (kind === 'directory') out.truncated = true
2134
+ }
2135
+ }
2136
+ }
2137
+
2138
+ const listed = worktreeList(repo)
2139
+ if (listed) {
2140
+ const rootReal = pathIdentity(inventoryRoot).real
2141
+ out.registeredWorktrees = listed.map((path) => {
2142
+ const ident = pathIdentity(path)
2143
+ if (ident.resolved) registered.add(ident.real)
2144
+ return {
2145
+ path: ident.resolved ? ident.real : path,
2146
+ ownedBy: ident.resolved ? owned.get(ident.real) ?? null : null,
2147
+ insideRoot: ident.resolved && rootReal ? containsPath(rootReal, ident.real) : false,
2148
+ exists: ident.resolved ? ident.exists : false,
2149
+ }
2150
+ })
2151
+ for (const row of out.entries) row.registeredWorktree = registered.has(row.path)
2152
+ }
2153
+ return out
2154
+ }