@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/integrations/skills/pappra/SKILL.md +68 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +30 -0
  6. package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
  7. package/assets/integrations/skills/pappra/references/operations.md +14 -0
  8. package/assets/manifest.json +4 -0
  9. package/dist/app/collaboration.d.ts +170 -0
  10. package/dist/app/collaboration.js +1650 -0
  11. package/dist/app/doctor.d.ts +9 -0
  12. package/dist/app/doctor.js +1030 -0
  13. package/dist/app/init.d.ts +143 -0
  14. package/dist/app/init.js +1111 -0
  15. package/dist/cli/commands/code.d.ts +16 -0
  16. package/dist/cli/commands/code.js +127 -0
  17. package/dist/cli/commands/collaboration.d.ts +13 -0
  18. package/dist/cli/commands/collaboration.js +186 -0
  19. package/dist/cli/commands/continuity.d.ts +29 -0
  20. package/dist/cli/commands/continuity.js +133 -0
  21. package/dist/cli/commands/docs.d.ts +11 -0
  22. package/dist/cli/commands/docs.js +133 -0
  23. package/dist/cli/commands/doctor.d.ts +14 -0
  24. package/dist/cli/commands/doctor.js +57 -0
  25. package/dist/cli/commands/host.d.ts +13 -0
  26. package/dist/cli/commands/host.js +125 -0
  27. package/dist/cli/commands/init.d.ts +51 -0
  28. package/dist/cli/commands/init.js +668 -0
  29. package/dist/cli/main.d.ts +2 -0
  30. package/dist/cli/main.js +10 -0
  31. package/dist/cli/output.d.ts +4 -0
  32. package/dist/cli/output.js +1 -0
  33. package/dist/cli/run.d.ts +2 -0
  34. package/dist/cli/run.js +597 -0
  35. package/dist/code/budget.d.ts +14 -0
  36. package/dist/code/budget.js +39 -0
  37. package/dist/code/exec.d.ts +23 -0
  38. package/dist/code/exec.js +133 -0
  39. package/dist/code/identity.d.ts +13 -0
  40. package/dist/code/identity.js +214 -0
  41. package/dist/code/index.d.ts +6 -0
  42. package/dist/code/index.js +3 -0
  43. package/dist/code/providers/codegraph.d.ts +59 -0
  44. package/dist/code/providers/codegraph.js +567 -0
  45. package/dist/code/registration.d.ts +9 -0
  46. package/dist/code/registration.js +293 -0
  47. package/dist/code/service.d.ts +53 -0
  48. package/dist/code/service.js +633 -0
  49. package/dist/code/text.d.ts +3 -0
  50. package/dist/code/text.js +202 -0
  51. package/dist/collab/admission.d.ts +450 -0
  52. package/dist/collab/admission.js +797 -0
  53. package/dist/collab/canonical.d.ts +26 -0
  54. package/dist/collab/canonical.js +136 -0
  55. package/dist/collab/contracts.d.ts +73 -0
  56. package/dist/collab/contracts.js +76 -0
  57. package/dist/collab/envelope.d.ts +52 -0
  58. package/dist/collab/envelope.js +241 -0
  59. package/dist/collab/fingerprint.d.ts +7 -0
  60. package/dist/collab/fingerprint.js +58 -0
  61. package/dist/collab/handoff.d.ts +350 -0
  62. package/dist/collab/handoff.js +513 -0
  63. package/dist/collab/index.d.ts +9 -0
  64. package/dist/collab/index.js +8 -0
  65. package/dist/collab/lifecycle.d.ts +449 -0
  66. package/dist/collab/lifecycle.js +534 -0
  67. package/dist/collab/messages.d.ts +148 -0
  68. package/dist/collab/messages.js +583 -0
  69. package/dist/collab/notify.d.ts +47 -0
  70. package/dist/collab/notify.js +140 -0
  71. package/dist/collab/preflight.d.ts +31 -0
  72. package/dist/collab/preflight.js +271 -0
  73. package/dist/collab/resources.d.ts +119 -0
  74. package/dist/collab/resources.js +451 -0
  75. package/dist/collab/store.d.ts +143 -0
  76. package/dist/collab/store.js +606 -0
  77. package/dist/collab/types.d.ts +354 -0
  78. package/dist/collab/types.js +1 -0
  79. package/dist/collab/validation.d.ts +10 -0
  80. package/dist/collab/validation.js +303 -0
  81. package/dist/continuity/context.d.ts +24 -0
  82. package/dist/continuity/context.js +179 -0
  83. package/dist/continuity/index.d.ts +3 -0
  84. package/dist/continuity/index.js +1 -0
  85. package/dist/continuity/paths.d.ts +37 -0
  86. package/dist/continuity/paths.js +258 -0
  87. package/dist/continuity/service.d.ts +59 -0
  88. package/dist/continuity/service.js +1258 -0
  89. package/dist/hosts/claude/native.d.ts +79 -0
  90. package/dist/hosts/claude/native.js +853 -0
  91. package/dist/hosts/codex/native.d.ts +345 -0
  92. package/dist/hosts/codex/native.js +1528 -0
  93. package/dist/hosts/codex/protocol.d.ts +51 -0
  94. package/dist/hosts/codex/protocol.js +42 -0
  95. package/dist/hosts/detect.d.ts +28 -0
  96. package/dist/hosts/detect.js +73 -0
  97. package/dist/hosts/index.d.ts +10 -0
  98. package/dist/hosts/index.js +10 -0
  99. package/dist/hosts/integration/bundle.d.ts +16 -0
  100. package/dist/hosts/integration/bundle.js +81 -0
  101. package/dist/hosts/integration/hook-entry.d.ts +1 -0
  102. package/dist/hosts/integration/hook-entry.js +68 -0
  103. package/dist/hosts/integration/hook.d.ts +32 -0
  104. package/dist/hosts/integration/hook.js +781 -0
  105. package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
  106. package/dist/hosts/integration/pi-extension-runtime.js +89 -0
  107. package/dist/hosts/integration/pi-template.d.ts +3 -0
  108. package/dist/hosts/integration/pi-template.js +5 -0
  109. package/dist/hosts/integration/service.d.ts +41 -0
  110. package/dist/hosts/integration/service.js +1381 -0
  111. package/dist/hosts/integration/support.d.ts +36 -0
  112. package/dist/hosts/integration/support.js +41 -0
  113. package/dist/hosts/integration/templates.d.ts +22 -0
  114. package/dist/hosts/integration/templates.js +70 -0
  115. package/dist/hosts/pi/protocol.d.ts +22 -0
  116. package/dist/hosts/pi/protocol.js +28 -0
  117. package/dist/hosts/pi/query-result.d.ts +22 -0
  118. package/dist/hosts/pi/query-result.js +710 -0
  119. package/dist/hosts/pi/rpc.d.ts +937 -0
  120. package/dist/hosts/pi/rpc.js +1621 -0
  121. package/dist/hosts/pilot.d.ts +485 -0
  122. package/dist/hosts/pilot.js +593 -0
  123. package/dist/index.d.ts +5 -0
  124. package/dist/index.js +4 -0
  125. package/dist/infra/fs.d.ts +27 -0
  126. package/dist/infra/fs.js +130 -0
  127. package/dist/infra/git.d.ts +60 -0
  128. package/dist/infra/git.js +358 -0
  129. package/dist/infra/owned-process-group.d.ts +173 -0
  130. package/dist/infra/owned-process-group.js +571 -0
  131. package/dist/install/apply.d.ts +65 -0
  132. package/dist/install/apply.js +663 -0
  133. package/dist/install/index.d.ts +5 -0
  134. package/dist/install/index.js +5 -0
  135. package/dist/install/ownership.d.ts +85 -0
  136. package/dist/install/ownership.js +430 -0
  137. package/dist/install/plan.d.ts +39 -0
  138. package/dist/install/plan.js +97 -0
  139. package/dist/install/rollback.d.ts +18 -0
  140. package/dist/install/rollback.js +544 -0
  141. package/dist/install/runtime.d.ts +76 -0
  142. package/dist/install/runtime.js +1145 -0
  143. package/dist/knowledge/catalog.d.ts +47 -0
  144. package/dist/knowledge/catalog.js +308 -0
  145. package/dist/knowledge/context.d.ts +28 -0
  146. package/dist/knowledge/context.js +61 -0
  147. package/dist/knowledge/index.d.ts +3 -0
  148. package/dist/knowledge/index.js +1 -0
  149. package/dist/knowledge/journal.d.ts +16 -0
  150. package/dist/knowledge/journal.js +109 -0
  151. package/dist/knowledge/lifecycle.d.ts +8 -0
  152. package/dist/knowledge/lifecycle.js +1001 -0
  153. package/dist/knowledge/paths.d.ts +37 -0
  154. package/dist/knowledge/paths.js +236 -0
  155. package/dist/knowledge/refs.d.ts +36 -0
  156. package/dist/knowledge/refs.js +267 -0
  157. package/dist/knowledge/service.d.ts +21 -0
  158. package/dist/knowledge/service.js +393 -0
  159. package/dist/knowledge/types.d.ts +171 -0
  160. package/dist/knowledge/types.js +1 -0
  161. package/dist/knowledge/write.d.ts +8 -0
  162. package/dist/knowledge/write.js +1223 -0
  163. package/dist/package-info.d.ts +6 -0
  164. package/dist/package-info.js +19 -0
  165. package/dist/schemas/code.d.ts +377 -0
  166. package/dist/schemas/code.js +145 -0
  167. package/dist/schemas/collaboration.d.ts +115 -0
  168. package/dist/schemas/collaboration.js +106 -0
  169. package/dist/schemas/continuity.d.ts +315 -0
  170. package/dist/schemas/continuity.js +199 -0
  171. package/dist/schemas/diagnostics.d.ts +28 -0
  172. package/dist/schemas/diagnostics.js +27 -0
  173. package/dist/schemas/doctor.d.ts +157 -0
  174. package/dist/schemas/doctor.js +75 -0
  175. package/dist/schemas/document.d.ts +644 -0
  176. package/dist/schemas/document.js +296 -0
  177. package/dist/schemas/errors.d.ts +177 -0
  178. package/dist/schemas/errors.js +176 -0
  179. package/dist/schemas/evidence.d.ts +24 -0
  180. package/dist/schemas/evidence.js +16 -0
  181. package/dist/schemas/host.d.ts +436 -0
  182. package/dist/schemas/host.js +201 -0
  183. package/dist/schemas/index.d.ts +15 -0
  184. package/dist/schemas/index.js +15 -0
  185. package/dist/schemas/init.d.ts +121 -0
  186. package/dist/schemas/init.js +94 -0
  187. package/dist/schemas/install.d.ts +213 -0
  188. package/dist/schemas/install.js +139 -0
  189. package/dist/schemas/machine.d.ts +43 -0
  190. package/dist/schemas/machine.js +96 -0
  191. package/dist/schemas/outcome.d.ts +23 -0
  192. package/dist/schemas/outcome.js +17 -0
  193. package/dist/schemas/paths.d.ts +19 -0
  194. package/dist/schemas/paths.js +63 -0
  195. package/dist/schemas/workspace.d.ts +120 -0
  196. package/dist/schemas/workspace.js +201 -0
  197. package/dist/workspace/config-file.d.ts +36 -0
  198. package/dist/workspace/config-file.js +237 -0
  199. package/dist/workspace/config.d.ts +28 -0
  200. package/dist/workspace/config.js +79 -0
  201. package/dist/workspace/discover.d.ts +34 -0
  202. package/dist/workspace/discover.js +195 -0
  203. package/dist/workspace/documents.d.ts +21 -0
  204. package/dist/workspace/documents.js +395 -0
  205. package/dist/workspace/ignore.d.ts +10 -0
  206. package/dist/workspace/ignore.js +48 -0
  207. package/dist/workspace/index.d.ts +7 -0
  208. package/dist/workspace/index.js +3 -0
  209. package/dist/workspace/repositories.d.ts +24 -0
  210. package/dist/workspace/repositories.js +411 -0
  211. package/dist/workspace/resolve.d.ts +24 -0
  212. package/dist/workspace/resolve.js +268 -0
  213. package/dist/workspace/types.d.ts +71 -0
  214. package/dist/workspace/types.js +1 -0
  215. package/dist/workspace/validation.d.ts +18 -0
  216. package/dist/workspace/validation.js +197 -0
  217. package/package.json +105 -4
  218. package/skills/pappra/SKILL.md +68 -0
  219. package/skills/pappra/references/hosts.md +30 -0
  220. package/skills/pappra/references/lifecycle.md +37 -0
  221. package/skills/pappra/references/operations.md +14 -0
@@ -0,0 +1,411 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { createDiagnostic, pointer } from '../schemas/diagnostics.js';
4
+ import { diagnosticCodes } from '../schemas/errors.js';
5
+ import { externalRepositoryId, isExternalRepositoryPath, isPathInside, resolvePortableRelativePath, samePath, toWorkspaceRelativePath, } from '../schemas/paths.js';
6
+ import { normalizedExcludes, } from '../schemas/workspace.js';
7
+ import { inspectGitRepository, } from '../infra/git.js';
8
+ import { observePath, resolveRealpath } from '../infra/fs.js';
9
+ import { isExcludedPath, isHiddenSegment } from './ignore.js';
10
+ function failureDiagnostic(failure, source, location) {
11
+ return createDiagnostic({
12
+ code: failure.code,
13
+ severity: 'error',
14
+ message: failure.message,
15
+ source,
16
+ pointer: location,
17
+ hint: failure.nextAction,
18
+ });
19
+ }
20
+ async function findNestedWorkspace(root, target) {
21
+ if (!isPathInside(root, target))
22
+ return null;
23
+ let directory = target;
24
+ for (;;) {
25
+ if (!samePath(directory, root)) {
26
+ const marker = await observePath(join(directory, '.pappra'));
27
+ if (marker.kind !== 'missing')
28
+ return directory;
29
+ }
30
+ if (samePath(directory, root))
31
+ return null;
32
+ const parent = dirname(directory);
33
+ if (!isPathInside(root, parent) || samePath(parent, directory))
34
+ return null;
35
+ directory = parent;
36
+ }
37
+ }
38
+ async function resolveConfiguredRepository(input, repositoryIndex) {
39
+ const { config, machine, root, configPath, options } = input;
40
+ if (config === null)
41
+ return { repository: null, unresolved: null, diagnostics: [] };
42
+ const entry = config.repositories[repositoryIndex];
43
+ if (entry === undefined) {
44
+ return { repository: null, unresolved: null, diagnostics: [] };
45
+ }
46
+ const location = pointer('repositories', repositoryIndex, 'path');
47
+ const diagnostics = [];
48
+ const unresolved = () => ({
49
+ id: entry.id,
50
+ configuredPath: entry.path,
51
+ diagnostics,
52
+ });
53
+ let candidateRoot;
54
+ let external = false;
55
+ if (isExternalRepositoryPath(entry.path)) {
56
+ external = true;
57
+ const logicalId = externalRepositoryId(entry.path);
58
+ if (logicalId === null) {
59
+ diagnostics.push(createDiagnostic({
60
+ code: diagnosticCodes.configPathInvalid,
61
+ severity: 'error',
62
+ message: `Repository "${entry.id}" has a malformed external reference: ${entry.path}`,
63
+ source: configPath,
64
+ pointer: location,
65
+ hint: 'Use external:<logical-id> exactly.',
66
+ }));
67
+ return { repository: null, unresolved: unresolved(), diagnostics };
68
+ }
69
+ const mapping = machine?.externalRepositories.find((item) => item.id === logicalId);
70
+ if (mapping === undefined) {
71
+ diagnostics.push(createDiagnostic({
72
+ code: diagnosticCodes.externalRepositoryUnmapped,
73
+ severity: 'error',
74
+ message: `External repository "${logicalId}" has no machine-local mapping.`,
75
+ source: configPath,
76
+ pointer: location,
77
+ hint: machine === null
78
+ ? 'Create or repair .pappra/local/machine.json on this checkout; Pappra will not guess an absolute path.'
79
+ : 'Add exactly one externalRepositories mapping in .pappra/local/machine.json.',
80
+ }));
81
+ return { repository: null, unresolved: unresolved(), diagnostics };
82
+ }
83
+ const observed = await observePath(mapping.path);
84
+ if (observed.kind === 'missing') {
85
+ diagnostics.push(createDiagnostic({
86
+ code: diagnosticCodes.externalRepositoryInvalid,
87
+ severity: 'error',
88
+ message: `Mapped external repository does not exist: ${mapping.path}`,
89
+ source: configPath,
90
+ pointer: location,
91
+ hint: 'Correct the local mapping; the shared workspace.json stores only the logical id.',
92
+ }));
93
+ return { repository: null, unresolved: unresolved(), diagnostics };
94
+ }
95
+ const real = await resolveRealpath(mapping.path);
96
+ if (real === null) {
97
+ diagnostics.push(createDiagnostic({
98
+ code: diagnosticCodes.externalRepositoryInvalid,
99
+ severity: 'error',
100
+ message: `Cannot resolve mapped external repository: ${mapping.path}`,
101
+ source: configPath,
102
+ pointer: location,
103
+ hint: 'Correct the local mapping path and permissions.',
104
+ }));
105
+ return { repository: null, unresolved: unresolved(), diagnostics };
106
+ }
107
+ candidateRoot = real;
108
+ }
109
+ else {
110
+ if (!entry.path.startsWith('/') && !/^[A-Za-z]:/.test(entry.path)) {
111
+ candidateRoot = resolvePortableRelativePath(root, entry.path);
112
+ }
113
+ else {
114
+ candidateRoot = entry.path;
115
+ }
116
+ if (!isPathInside(root, candidateRoot)) {
117
+ diagnostics.push(createDiagnostic({
118
+ code: diagnosticCodes.configPathEscapesRoot,
119
+ severity: 'error',
120
+ message: `Repository "${entry.id}" resolves outside the workspace root: ${candidateRoot}`,
121
+ source: configPath,
122
+ pointer: location,
123
+ hint: 'Use a relative path, or declare the checkout explicitly as external:<id> in machine.json.',
124
+ }));
125
+ return { repository: null, unresolved: unresolved(), diagnostics };
126
+ }
127
+ const observed = await observePath(candidateRoot);
128
+ if (observed.kind === 'missing') {
129
+ diagnostics.push(createDiagnostic({
130
+ code: diagnosticCodes.repositoryPathMissing,
131
+ severity: 'error',
132
+ message: `Repository path does not exist: ${candidateRoot}`,
133
+ source: configPath,
134
+ pointer: location,
135
+ hint: 'Correct the path or remove the declaration; Pappra does not create or clone repositories.',
136
+ }));
137
+ return { repository: null, unresolved: unresolved(), diagnostics };
138
+ }
139
+ const real = await resolveRealpath(candidateRoot);
140
+ if (real === null) {
141
+ diagnostics.push(createDiagnostic({
142
+ code: diagnosticCodes.repositoryPathMissing,
143
+ severity: 'error',
144
+ message: `Cannot resolve repository path: ${candidateRoot}`,
145
+ source: configPath,
146
+ pointer: location,
147
+ hint: 'Check permissions and the path.',
148
+ }));
149
+ return { repository: null, unresolved: unresolved(), diagnostics };
150
+ }
151
+ if (!isPathInside(root, real)) {
152
+ diagnostics.push(createDiagnostic({
153
+ code: diagnosticCodes.repositorySymlinkEscape,
154
+ severity: 'error',
155
+ message: `Repository path resolves outside the workspace through a symlink: ${real}`,
156
+ source: configPath,
157
+ pointer: location,
158
+ hint: 'Keep workspace repositories inside the root, or use an explicit external:<id> mapping.',
159
+ }));
160
+ return { repository: null, unresolved: unresolved(), diagnostics };
161
+ }
162
+ const nested = await findNestedWorkspace(root, real);
163
+ if (nested !== null) {
164
+ diagnostics.push(createDiagnostic({
165
+ code: diagnosticCodes.repositoryNestedWorkspace,
166
+ severity: 'error',
167
+ message: `Repository "${entry.id}" is inside nested workspace ${nested}.`,
168
+ source: configPath,
169
+ pointer: location,
170
+ hint: 'Do not swallow a nested workspace. Configure the inner workspace separately or map a read-only external checkout.',
171
+ }));
172
+ return { repository: null, unresolved: unresolved(), diagnostics };
173
+ }
174
+ candidateRoot = real;
175
+ }
176
+ const candidateObservation = await observePath(candidateRoot);
177
+ if (candidateObservation.kind !== 'directory') {
178
+ diagnostics.push(createDiagnostic({
179
+ code: diagnosticCodes.repositoryNotGit,
180
+ severity: 'error',
181
+ message: `Repository path is not a directory: ${candidateRoot}`,
182
+ source: configPath,
183
+ pointer: location,
184
+ hint: 'Declare a checkout directory; Pappra does not create, clone, or guess repositories.',
185
+ }));
186
+ return { repository: null, unresolved: unresolved(), diagnostics };
187
+ }
188
+ const inspected = await inspectGitRepository(candidateRoot, {
189
+ timeoutMs: options.gitTimeoutMs,
190
+ });
191
+ if (!inspected.ok) {
192
+ diagnostics.push(failureDiagnostic(inspected, configPath, location));
193
+ return { repository: null, unresolved: unresolved(), diagnostics };
194
+ }
195
+ const git = inspected.value;
196
+ return {
197
+ repository: {
198
+ id: entry.id,
199
+ origin: 'workspace-config',
200
+ external,
201
+ access: entry.access,
202
+ root: git.root,
203
+ relativePath: toWorkspaceRelativePath(root, git.root),
204
+ configuredPath: entry.path,
205
+ git,
206
+ kind: git.kind,
207
+ diagnostics,
208
+ },
209
+ unresolved: null,
210
+ diagnostics,
211
+ };
212
+ }
213
+ function scanRepository(input, git) {
214
+ return {
215
+ id: null,
216
+ origin: 'scan',
217
+ external: false,
218
+ access: 'read',
219
+ root: git.root,
220
+ relativePath: toWorkspaceRelativePath(input.root, git.root),
221
+ configuredPath: null,
222
+ git,
223
+ kind: git.kind,
224
+ diagnostics: [],
225
+ };
226
+ }
227
+ /**
228
+ * Resolves explicit repository declarations first, then performs a bounded
229
+ * scan when the workspace root is not itself a repository. A scan never
230
+ * descends into a repository, so source files are not enumerated.
231
+ */
232
+ export async function discoverRepositories(input) {
233
+ const { config, configPath, options, root } = input;
234
+ const diagnostics = [];
235
+ const repositories = [];
236
+ const unresolved = [];
237
+ const boundaries = new Set();
238
+ const byRoot = new Map();
239
+ let truncated = false;
240
+ let stopsAtRepositories = false;
241
+ const excluded = config === null ? [] : normalizedExcludes(config);
242
+ const addRepository = (repository) => {
243
+ const existing = byRoot.get(repository.root);
244
+ if (existing !== undefined) {
245
+ if (existing.origin === 'workspace-config' &&
246
+ repository.origin === 'workspace-config') {
247
+ diagnostics.push(createDiagnostic({
248
+ code: diagnosticCodes.repositoryDuplicatePath,
249
+ severity: 'warning',
250
+ message: `Repositories "${existing.id ?? existing.root}" and "${repository.id ?? repository.root}" resolve to the same checkout: ${repository.root}`,
251
+ source: configPath,
252
+ pointer: null,
253
+ hint: 'Keep one logical repository id per checkout.',
254
+ }));
255
+ }
256
+ return;
257
+ }
258
+ byRoot.set(repository.root, repository);
259
+ repositories.push(repository);
260
+ };
261
+ if (config !== null) {
262
+ for (const index of config.repositories.keys()) {
263
+ const resolved = await resolveConfiguredRepository(input, index);
264
+ diagnostics.push(...resolved.diagnostics);
265
+ if (resolved.repository !== null)
266
+ addRepository(resolved.repository);
267
+ if (resolved.unresolved !== null)
268
+ unresolved.push(resolved.unresolved);
269
+ }
270
+ }
271
+ const rootGitEntry = await observePath(join(root, '.git'));
272
+ if (rootGitEntry.kind === 'directory' || rootGitEntry.kind === 'file') {
273
+ stopsAtRepositories = true;
274
+ const inspected = await inspectGitRepository(root, {
275
+ timeoutMs: options.gitTimeoutMs,
276
+ });
277
+ if (inspected.ok) {
278
+ addRepository(scanRepository(input, inspected.value));
279
+ }
280
+ else {
281
+ diagnostics.push(failureDiagnostic(inspected, root, null));
282
+ }
283
+ }
284
+ else if (rootGitEntry.kind === 'symlink') {
285
+ diagnostics.push(createDiagnostic({
286
+ code: diagnosticCodes.discoverySymlinkSkipped,
287
+ severity: 'warning',
288
+ message: `Workspace root .git is a symlink and was not followed: ${join(root, '.git')}`,
289
+ source: root,
290
+ hint: 'Use a regular .git directory or file; discovery does not follow repository metadata symlinks.',
291
+ }));
292
+ stopsAtRepositories = true;
293
+ }
294
+ if (!stopsAtRepositories && options.scanDepth > 0) {
295
+ let visited = 0;
296
+ const visit = async (directory, depth) => {
297
+ if (visited >= options.maxDirectories) {
298
+ if (!truncated) {
299
+ diagnostics.push(createDiagnostic({
300
+ code: diagnosticCodes.discoveryLimitReached,
301
+ severity: 'warning',
302
+ message: `Repository scan stopped after ${options.maxDirectories} directories.`,
303
+ source: root,
304
+ hint: 'Increase the scan bound explicitly or declare repositories in workspace.json.',
305
+ }));
306
+ truncated = true;
307
+ }
308
+ return;
309
+ }
310
+ visited += 1;
311
+ if (depth > 0) {
312
+ const marker = await observePath(join(directory, '.pappra'));
313
+ if (marker.kind !== 'missing') {
314
+ boundaries.add(directory);
315
+ diagnostics.push(createDiagnostic({
316
+ code: diagnosticCodes.discoveryNestedWorkspace,
317
+ severity: 'info',
318
+ message: `Nested workspace boundary observed: ${directory}`,
319
+ source: directory,
320
+ hint: 'The outer discovery does not enter or absorb a nested workspace.',
321
+ }));
322
+ return;
323
+ }
324
+ }
325
+ const gitEntry = await observePath(join(directory, '.git'));
326
+ if (gitEntry.kind === 'directory' || gitEntry.kind === 'file') {
327
+ const inspected = await inspectGitRepository(directory, {
328
+ timeoutMs: options.gitTimeoutMs,
329
+ });
330
+ if (inspected.ok) {
331
+ addRepository(scanRepository(input, inspected.value));
332
+ }
333
+ else {
334
+ diagnostics.push(failureDiagnostic(inspected, directory, null));
335
+ }
336
+ return;
337
+ }
338
+ if (gitEntry.kind === 'symlink') {
339
+ diagnostics.push(createDiagnostic({
340
+ code: diagnosticCodes.discoverySymlinkSkipped,
341
+ severity: 'warning',
342
+ message: `Repository metadata symlink was not followed: ${join(directory, '.git')}`,
343
+ source: directory,
344
+ hint: 'Use a regular .git directory or file.',
345
+ }));
346
+ return;
347
+ }
348
+ if (depth >= options.scanDepth)
349
+ return;
350
+ let entries;
351
+ try {
352
+ entries = await readdir(directory, { withFileTypes: true });
353
+ }
354
+ catch (error) {
355
+ diagnostics.push(createDiagnostic({
356
+ code: diagnosticCodes.fileUnreadable,
357
+ severity: 'warning',
358
+ message: `Cannot read directory ${directory}: ${error instanceof Error ? error.message : String(error)}`,
359
+ source: directory,
360
+ hint: 'Check permissions; discovery skips unreadable directories.',
361
+ }));
362
+ return;
363
+ }
364
+ entries.sort((left, right) => left.name.localeCompare(right.name));
365
+ const limited = entries.slice(0, options.maxEntriesPerDirectory);
366
+ if (entries.length > limited.length) {
367
+ diagnostics.push(createDiagnostic({
368
+ code: diagnosticCodes.discoveryLimitReached,
369
+ severity: 'warning',
370
+ message: `Directory has ${entries.length} entries; only the first ${limited.length} were inspected.`,
371
+ source: directory,
372
+ hint: 'Narrow the workspace scope or declare repositories explicitly.',
373
+ }));
374
+ truncated = true;
375
+ }
376
+ for (const entry of limited) {
377
+ if (!entry.isDirectory() && !entry.isSymbolicLink())
378
+ continue;
379
+ if (isHiddenSegment(entry.name))
380
+ continue;
381
+ const child = join(directory, entry.name);
382
+ const relative = toWorkspaceRelativePath(root, child);
383
+ if (relative === null)
384
+ continue;
385
+ if (isExcludedPath(relative, excluded))
386
+ continue;
387
+ if (entry.isSymbolicLink()) {
388
+ const real = await resolveRealpath(child);
389
+ const outside = real === null || !isPathInside(root, real);
390
+ diagnostics.push(createDiagnostic({
391
+ code: diagnosticCodes.discoverySymlinkSkipped,
392
+ severity: outside ? 'warning' : 'info',
393
+ message: `Symlinked directory was not followed: ${child}${real === null ? '' : ` -> ${real}`}`,
394
+ source: directory,
395
+ hint: 'Replace it with a real directory or declare an explicit external repository mapping.',
396
+ }));
397
+ continue;
398
+ }
399
+ await visit(child, depth + 1);
400
+ }
401
+ };
402
+ await visit(root, 0);
403
+ }
404
+ return {
405
+ repositories,
406
+ unresolved,
407
+ boundaries: [...boundaries],
408
+ diagnostics,
409
+ truncated,
410
+ };
411
+ }
@@ -0,0 +1,24 @@
1
+ import type { Diagnostic } from '../schemas/diagnostics.js';
2
+ export type WorkspaceRootState = 'registered' | 'missing' | 'incomplete' | 'unavailable';
3
+ export interface ResolveWorkspaceRootOptions {
4
+ readonly startPath?: string;
5
+ }
6
+ export interface WorkspaceRootResolution {
7
+ readonly requestedPath: string;
8
+ readonly startDirectory: string | null;
9
+ readonly state: WorkspaceRootState;
10
+ /** The nearest directory containing an explicit `.pappra/workspace.json`. */
11
+ readonly root: string | null;
12
+ readonly configPath: string | null;
13
+ readonly diagnostics: readonly Diagnostic[];
14
+ }
15
+ /**
16
+ * Walks from an explicit start path (default cwd) to the filesystem root and
17
+ * returns the nearest registered workspace. It only probes `.pappra` markers,
18
+ * never scans home or unrelated project content.
19
+ *
20
+ * A `.pappra` directory without workspace.json stops the walk: a partial
21
+ * registration is reported instead of silently falling back to an outer
22
+ * workspace.
23
+ */
24
+ export declare function resolveWorkspaceRoot(options?: ResolveWorkspaceRootOptions): Promise<WorkspaceRootResolution>;