@edspencer/paddock 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/adoptable.d.ts +18 -0
  3. package/packages/server/dist/adoptable.js +1 -1
  4. package/packages/server/dist/app.js +3 -1
  5. package/packages/server/dist/claude-home.js +9 -1
  6. package/packages/server/dist/cli/import-chats.js +1 -1
  7. package/packages/server/dist/git.d.ts +44 -1
  8. package/packages/server/dist/git.js +79 -11
  9. package/packages/server/dist/management-ops.js +42 -2
  10. package/packages/server/dist/management-policy.d.ts +4 -3
  11. package/packages/server/dist/management-policy.js +5 -2
  12. package/packages/server/dist/project-paths.d.ts +72 -4
  13. package/packages/server/dist/project-paths.js +76 -4
  14. package/packages/server/dist/project-types.d.ts +109 -11
  15. package/packages/server/dist/projects.d.ts +170 -3
  16. package/packages/server/dist/projects.js +477 -66
  17. package/packages/server/dist/routes/git.js +6 -3
  18. package/packages/server/dist/routes/projects.js +16 -2
  19. package/packages/server/dist/self-mcp-descriptions.d.ts +1 -0
  20. package/packages/server/dist/self-mcp-descriptions.js +14 -1
  21. package/packages/server/dist/self-mcp-types.d.ts +32 -8
  22. package/packages/server/dist/self-mcp-write.d.ts +21 -0
  23. package/packages/server/dist/self-mcp-write.js +44 -0
  24. package/packages/server/dist/self-mcp.d.ts +7 -6
  25. package/packages/server/dist/self-mcp.js +36 -10
  26. package/packages/server/dist/sweep.js +10 -5
  27. package/packages/server/package.json +1 -1
  28. package/packages/web/dist/assets/{InstanceConfigPage-CCk2hVT9.js → InstanceConfigPage-DcprTH2W.js} +1 -1
  29. package/packages/web/dist/assets/{MarkdownRenderer-B6gST_0U.js → MarkdownRenderer-Ri1LwAUN.js} +1 -1
  30. package/packages/web/dist/assets/{ProjectRedirect-BCmKT4mf.js → ProjectRedirect-Dze5H8Xy.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectView-CCN98MWu.js → ProjectView-PrA8purE.js} +9 -9
  32. package/packages/web/dist/assets/{ProjectsGrid-CYzuN_yD.js → ProjectsGrid-BidsM7ah.js} +1 -1
  33. package/packages/web/dist/assets/{arc-BxvtMCZQ.js → arc-eQ3ryE9c.js} +1 -1
  34. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Bp6ffJ_u.js → architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js} +1 -1
  35. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BIY2X8nW.js → blockDiagram-GPEHLZMM-DBX5pma_.js} +1 -1
  36. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Br9stEJS.js → c4Diagram-AAUBKEIU-CnWrtTpa.js} +1 -1
  37. package/packages/web/dist/assets/channel-B4sED4OP.js +2 -0
  38. package/packages/web/dist/assets/{chunk-2J33WTMH-Djc7uqMV.js → chunk-2J33WTMH-2qmLvtsr.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-4BX2VUAB-yEVMLksS.js → chunk-4BX2VUAB-Dn-LUDMV.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-55IACEB6-tJaEdPZg.js → chunk-55IACEB6-DB0augUY.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-727SXJPM-bWEqh672.js → chunk-727SXJPM-BPQQM0i8.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B8U9fB-T.js → chunk-AQP2D5EJ-DOhfwA-x.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-FMBD7UC4-BeEITYig.js → chunk-FMBD7UC4-CatHHdFD.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-ND2GUHAM-L2rpLaoK.js → chunk-ND2GUHAM-B645aAlG.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-QZHKN3VN-qqzPMZL_.js → chunk-QZHKN3VN-D1fAaPSZ.js} +1 -1
  46. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +2 -0
  47. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +2 -0
  48. package/packages/web/dist/assets/{core-CSaNbmLO.js → core-B4t7D8Om.js} +1 -1
  49. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BRF7Nech.js → cose-bilkent-S5V4N54A-BLdKDAwa.js} +1 -1
  50. package/packages/web/dist/assets/{dagre-BM42HDAG-D10rJWOR.js → dagre-BM42HDAG-CbTbTVKv.js} +1 -1
  51. package/packages/web/dist/assets/{diagram-2AECGRRQ-DGjT5mAO.js → diagram-2AECGRRQ-CorSS29_.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-5GNKFQAL-DaH3bxHh.js → diagram-5GNKFQAL-PLKivts0.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-KO2AKTUF-QrLW9Wn6.js → diagram-KO2AKTUF-CUSIBacZ.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-LMA3HP47-BX15wgYN.js → diagram-LMA3HP47-CZ69mAZs.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-OG6HWLK6-DXQ_p63s.js → diagram-OG6HWLK6-Cmf042Rh.js} +1 -1
  56. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C_ioRruE.js → erDiagram-TEJ5UH35-DyIKGj6e.js} +1 -1
  57. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-7d-xLXLM.js → flowDiagram-I6XJVG4X-B79pLdLD.js} +1 -1
  58. package/packages/web/dist/assets/{format-BSDZezwD.js → format-C5nhFL8q.js} +1 -1
  59. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DZYx5xcj.js → ganttDiagram-6RSMTGT7-BFTR_oNA.js} +1 -1
  60. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-dimo-osm.js → gitGraphDiagram-PVQCEYII-DdGUlJxS.js} +1 -1
  61. package/packages/web/dist/assets/{index-CUnFKMga.js → index-DVSsta3B.js} +14 -14
  62. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BzApY25x.js → infoDiagram-5YYISTIA-B80FyUOy.js} +1 -1
  63. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DtX47_9p.js → ishikawaDiagram-YF4QCWOH-_wRlphn9.js} +1 -1
  64. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C7fITKg1.js → journeyDiagram-JHISSGLW-qu4yvjW8.js} +1 -1
  65. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BOrWLf_W.js → kanban-definition-UN3LZRKU-D2DEUp2-.js} +1 -1
  66. package/packages/web/dist/assets/{linear-DuyAaBu6.js → linear-D-CLRUEW.js} +1 -1
  67. package/packages/web/dist/assets/{mermaid.core-BQlzuVvO.js → mermaid.core-DdihBJl4.js} +4 -4
  68. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CmP748B5.js → mindmap-definition-RKZ34NQL-BEh_TDBf.js} +1 -1
  69. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CRd5Pdfq.js → pieDiagram-4H26LBE5-BbrmLVaZ.js} +1 -1
  70. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DpJm0Yw_.js → quadrantDiagram-W4KKPZXB-DfVuygAv.js} +1 -1
  71. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-B0o2PRMC.js → requirementDiagram-4Y6WPE33-BHp2QpZt.js} +1 -1
  72. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CBi3Ek3v.js → sankeyDiagram-5OEKKPKP-D2G6lRMY.js} +1 -1
  73. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C1stxfQg.js → sequenceDiagram-3UESZ5HK-Dj-jzR-c.js} +1 -1
  74. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-lCkiETWK.js → stateDiagram-AJRCARHV-C5D6bT13.js} +1 -1
  75. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +2 -0
  76. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D3DkWJMC.js → timeline-definition-PNZ67QCA-DE4gGjhP.js} +1 -1
  77. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CO2-yC7K.js → vennDiagram-CIIHVFJN-C9QNrl92.js} +1 -1
  78. package/packages/web/dist/assets/{wardley-L42UT6IY-Dfjp0kqX.js → wardley-L42UT6IY-U2fSGy-d.js} +1 -1
  79. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CIl0OPwS.js → wardleyDiagram-YWT4CUSO-B9RhObdH.js} +1 -1
  80. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-CuvlQuup.js → xychartDiagram-2RQKCTM6-BnlPrtQ9.js} +1 -1
  81. package/packages/web/dist/index.html +1 -1
  82. package/packages/web/dist/sw.js +1 -1
  83. package/packages/web/dist/assets/channel-CMdgNeFH.js +0 -2
  84. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +0 -2
  85. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +0 -2
  86. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +0 -2
@@ -35,8 +35,9 @@ import { sanitizeAttachmentsOverride } from "./attachments-config.js";
35
35
  // them at `./projects.js` — the same one-place-to-import discipline as before.
36
36
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
37
37
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
38
- import { SLUG_RE, ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
39
- export { ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
38
+ import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
39
+ export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
40
+ import { gitRemotes, normalizeRemote } from "./adoptable.js";
40
41
  import { normalizeLinks, } from "./project-types.js";
41
42
  import * as projectFiles from "./project-files.js";
42
43
  // Barrel: re-export all type declarations + normalizeLinks + the config-type
@@ -78,8 +79,24 @@ function sanitizeModelsOverride(raw) {
78
79
  }
79
80
  export class ProjectStore {
80
81
  root;
81
- constructor(root) {
82
+ dataDir;
83
+ log;
84
+ /**
85
+ * @param root The projects root (`cfg.projectsRoot`) — every project's metadata dir.
86
+ * @param dataDir The instance data dir (`cfg.dataDir`), used ONLY to reject a
87
+ * `path:` that points inside Paddock's own state (issue #206). Optional so the
88
+ * many tests that construct a bare store keep compiling; when omitted, `root`
89
+ * alone is the containment check (in the default layout `projectsRoot` is
90
+ * `<dataDir>/projects`, so it is the tighter of the two anyway).
91
+ * @param log Where non-fatal advisories go — currently only the declared-repo /
92
+ * actual-remote mismatch on an existing `path` (issue #206), which must be
93
+ * said out loud rather than silently ignored (#659) but must not fail the
94
+ * create. Defaults to a no-op so a bare `new ProjectStore(root)` is unchanged.
95
+ */
96
+ constructor(root, dataDir, log = { warn: () => { } }) {
82
97
  this.root = root;
98
+ this.dataDir = dataDir;
99
+ this.log = log;
83
100
  }
84
101
  /** Ensure the projects root exists. Call once at startup. */
85
102
  async init() {
@@ -174,6 +191,222 @@ export class ProjectStore {
174
191
  return false;
175
192
  }
176
193
  }
194
+ /**
195
+ * Recursively delete `target`, but ONLY if it is strictly inside `projectsRoot`.
196
+ *
197
+ * The single choke point for every recursive delete in this store. Before issue
198
+ * #206 the containment was implicit: everything a project owned — including a
199
+ * repo-backed project's checkout — was nested under `<projectsRoot>/<slug>/`, so
200
+ * `fs.rm(dir, {recursive: true})` could not reach anything that wasn't ours.
201
+ * A LINKED project's working directory is the user's real work repo, outside the
202
+ * root, and that implicit safety is gone.
203
+ *
204
+ * So the check is made explicit and centralised here rather than re-derived at
205
+ * each call site: an `fs.rm` that escapes throws instead of deleting. Every
206
+ * caller already only passes in-root paths — this exists so that stays true
207
+ * when someone later adds another one.
208
+ *
209
+ * `promote()`'s two rollbacks route through here as well. Their target is
210
+ * `<projectsRoot>/<slug>/<checkoutName>`, which is in-root by construction, so
211
+ * the guard is inert there today — that is the point. This is the choke point
212
+ * the comment above promised, and a recursive delete that skips it is exactly
213
+ * the drift it exists to prevent. {@link rollback} is the deliberate exception:
214
+ * it deletes paths that may legitimately sit OUTSIDE the root (a user-nominated
215
+ * clone target), and is safe by a different mechanism — it only ever removes
216
+ * directories this operation created.
217
+ */
218
+ async rmInsideRoot(target) {
219
+ const resolved = path.resolve(target);
220
+ const root = path.resolve(this.root);
221
+ if (resolved === root || !isPathInside(resolved, root)) {
222
+ throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
223
+ }
224
+ await fs.rm(resolved, { recursive: true, force: true });
225
+ }
226
+ /**
227
+ * Undo a failed {@link create} by removing the directories it created — and
228
+ * ONLY those.
229
+ *
230
+ * This is the non-negotiable half of the acquisition rules (issue #206). The
231
+ * previous rollback was an unconditional `fs.rm(dir, {recursive, force})` on the
232
+ * slug dir, safe only because everything a project owned was nested inside it.
233
+ * Once `path:` lets a user nominate a clone TARGET, an unconditional recursive
234
+ * delete is a footgun aimed at whatever they typed — a home directory, say. So
235
+ * the caller records what it brought into being and this removes exactly that
236
+ * list; a pre-existing directory is never in it and therefore can never be
237
+ * deleted, no matter how the create failed.
238
+ *
239
+ * Removed deepest-first so a nested pair (slug dir + its checkout) unwinds
240
+ * cleanly. Failures are swallowed: the original error is the one worth
241
+ * reporting, and a rollback that can't finish must not mask it.
242
+ */
243
+ async rollback(created) {
244
+ for (const target of [...created].reverse()) {
245
+ await fs.rm(target, { recursive: true, force: true }).catch(() => undefined);
246
+ }
247
+ }
248
+ /**
249
+ * Validate a `path:` (issue #206) and return it canonicalised, along with
250
+ * whether it already exists.
251
+ *
252
+ * This is the ONLY gate between a user-supplied string and a directory Paddock
253
+ * will hand an agent as its cwd (and may `git clone` into), so it is
254
+ * deliberately strict and ordered cheapest-first. It rejects:
255
+ *
256
+ * - a relative path (a cwd must be unambiguous — it is baked into every
257
+ * transcript path, and resolving it against the server's cwd is a footgun);
258
+ * - a path that exists but isn't a directory;
259
+ * - a path inside `projectsRoot` or the data dir — that is Paddock's own
260
+ * state, and pointing at it re-creates the nesting this feature exists to
261
+ * avoid (plus `remove()` would then really delete it);
262
+ * - a path that is, contains, or sits inside another project's working
263
+ * directory — two keepers sharing one cwd collide on transcripts (they are
264
+ * keyed by cwd) and on the working tree itself.
265
+ *
266
+ * It deliberately does NOT reject a directory for not being a git repository.
267
+ * That would be the only mandatory git check in the codebase: everywhere else
268
+ * paddock PROBES and degrades (`GitService.isRepo()` → false, the Changes tab
269
+ * removes itself, commit/push return `{error: "not a repo"}`), and the default
270
+ * E2E server runs on a non-git directory on purpose.
271
+ *
272
+ * A MISSING path is not an error here — it is an acquisition instruction, which
273
+ * {@link acquirePath} resolves. Only the existing-path branch can be
274
+ * canonicalised by `realpath`, so for a missing one the containment checks run
275
+ * against the resolved-but-not-real path plus its nearest existing ancestor —
276
+ * otherwise `/tmp/link -> <projectsRoot>` with a missing leaf would slip past.
277
+ */
278
+ async validatePath(raw) {
279
+ const input = raw.trim();
280
+ if (!path.isAbsolute(input)) {
281
+ throw new ProjectError(`Project path must be absolute: ${input}`, "invalid");
282
+ }
283
+ // Resolve symlinks before ANY containment check — otherwise
284
+ // `/tmp/innocent -> <projectsRoot>/victim` would sail through them all. A
285
+ // path that doesn't exist yet can't be realpath'd, so fall back to resolving
286
+ // its nearest existing ancestor and re-appending the missing tail; that keeps
287
+ // a symlinked PARENT from smuggling the target back inside our own state.
288
+ const direct = await fs.realpath(input).catch(() => null);
289
+ const exists = direct !== null;
290
+ const resolved = direct ?? (await this.resolveViaExistingAncestor(input));
291
+ if (exists) {
292
+ const st = await fs.stat(resolved).catch(() => null);
293
+ if (!st?.isDirectory()) {
294
+ throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
295
+ }
296
+ }
297
+ if (isPathInside(resolved, this.root)) {
298
+ throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
299
+ }
300
+ if (this.dataDir && isPathInside(resolved, this.dataDir)) {
301
+ throw new ProjectError(`Project path must be outside the Paddock data dir (${this.dataDir}): ${input}`, "invalid");
302
+ }
303
+ // Reject overlap with an existing project's cwd in EITHER direction: pointing
304
+ // at a parent of another project's working dir is just as broken as pointing
305
+ // at a child of one.
306
+ for (const other of await this.list()) {
307
+ const cwd = other.workingDir;
308
+ if (isPathInside(resolved, cwd) || isPathInside(cwd, resolved)) {
309
+ throw new ProjectError(`Project path overlaps the working directory of project "${other.slug}" (${cwd}): ${input}`, "invalid");
310
+ }
311
+ }
312
+ return { resolved, exists };
313
+ }
314
+ /**
315
+ * Canonicalise a path that does not exist yet, by realpath-ing the deepest
316
+ * ancestor that DOES exist and re-appending the missing segments. Used only by
317
+ * {@link validatePath}, so a clone target under a symlinked parent still gets
318
+ * containment-checked against its real location.
319
+ */
320
+ async resolveViaExistingAncestor(input) {
321
+ let head = path.resolve(input);
322
+ const tail = [];
323
+ for (;;) {
324
+ const real = await fs.realpath(head).catch(() => null);
325
+ if (real)
326
+ return path.join(real, ...tail.reverse());
327
+ const parent = path.dirname(head);
328
+ // Hit the filesystem root without finding anything real: nothing left to
329
+ // resolve against, so the already-normalised path is the best answer.
330
+ if (parent === head)
331
+ return path.resolve(input);
332
+ tail.push(path.basename(head));
333
+ head = parent;
334
+ }
335
+ }
336
+ /**
337
+ * Bring a project's `path` into existence, returning the directories THIS call
338
+ * created so a failure can roll back exactly those and nothing else.
339
+ *
340
+ * The acquisition rules (issue #206):
341
+ *
342
+ * - path exists ⇒ use it. With `repo` also set, verify the remote matches and
343
+ * WARN on a mismatch rather than silently ignoring `repo` — that is the #659
344
+ * bug class (a directory that is a clone of a *different* repo).
345
+ * - path missing + `repo` ⇒ clone the repo to that path.
346
+ * - path missing, managed ⇒ create the directory; the user is nominating where
347
+ * their notes should live, and an empty dir is a valid notebook.
348
+ * - path missing, unmanaged, no `repo` ⇒ error. There is nothing to acquire
349
+ * from and an empty directory is not a codebase.
350
+ *
351
+ * **Cleanup rule:** `created` only ever gains directories this call brought into
352
+ * being. A pre-existing directory is never appended, so no rollback can delete
353
+ * one — the target is user-nominated and may be a home directory.
354
+ *
355
+ * `created` is an OUT-parameter rather than a return value on purpose. Returning
356
+ * the list would mean the caller only learns what to clean up on the success
357
+ * path: an `acquirePath` that threw would never hand back the directory it had
358
+ * already made, so a clone that died partway through would orphan it. Recording
359
+ * before the risky step is what makes the rollback complete.
360
+ */
361
+ async acquirePath(args) {
362
+ const { resolved, exists, repo, managed, warn, created } = args;
363
+ if (exists) {
364
+ // Used as-is. Nothing is appended to `created` — this directory is the
365
+ // user's, and it must survive any failure that follows.
366
+ if (repo)
367
+ await this.warnOnRemoteMismatch(resolved, repo, warn);
368
+ return;
369
+ }
370
+ if (!repo && !managed) {
371
+ throw new ProjectError(`Project path does not exist and there is no repo to clone from: ${resolved}`, "invalid");
372
+ }
373
+ // From here the directory is ours: record it BEFORE creating it, so a failure
374
+ // midway (a clone that dies after writing some objects) still rolls back.
375
+ created.push(resolved);
376
+ if (repo) {
377
+ try {
378
+ await cloneRepo(repo, resolved);
379
+ }
380
+ catch (err) {
381
+ throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo} to ${resolved}`, "invalid");
382
+ }
383
+ return;
384
+ }
385
+ await fs.mkdir(resolved, { recursive: true });
386
+ }
387
+ /**
388
+ * Warn when a directory's git remotes don't include `repo`.
389
+ *
390
+ * Deliberately a warning, not a rejection: the directory the user named is the
391
+ * one they meant, and plenty of legitimate setups (a fork, a mirror, an
392
+ * ssh-vs-https spelling) won't match exactly. But silently ignoring `repo` is
393
+ * how #659 happened, so the mismatch has to be said out loud. A directory with
394
+ * no git at all is not a mismatch — it is just not a repo, which is allowed.
395
+ *
396
+ * Reuses `adoptable.ts`'s reader, which parses `.git/config` directly (no
397
+ * subprocess) and already follows a linked worktree's `gitdir:`/`commondir`
398
+ * pointers, and returns URLs already through {@link normalizeRemote}.
399
+ */
400
+ async warnOnRemoteMismatch(dir, repo, warn) {
401
+ const remotes = await gitRemotes(dir);
402
+ if (remotes.length === 0)
403
+ return;
404
+ if (remotes.includes(normalizeRemote(repo)))
405
+ return;
406
+ warn(`Project path ${dir} is a git repo whose remotes (${remotes.join(", ")}) ` +
407
+ `do not include the declared repo ${repo}. Using the directory as given; ` +
408
+ `the repo URL is recorded as a hint only.`);
409
+ }
177
410
  /**
178
411
  * Create a project: mkdir + write project.yaml (from template) + seed
179
412
  * CHANGELOG.md. Idempotency: throws ProjectError("exists") if the slug
@@ -190,12 +423,32 @@ export class ProjectStore {
190
423
  if (await this.exists(slug)) {
191
424
  throw new ProjectError(`Project already exists: ${slug}`, "exists");
192
425
  }
193
- // Repo-backed project (issue #187): validate the URL up front so a bad value
194
- // never reaches `git clone` and the project isn't half-created.
426
+ // Validate the repo URL up front so a bad value never reaches `git clone`
427
+ // and the project isn't half-created (issue #187).
195
428
  const repo = input.repo?.trim() || undefined;
196
429
  if (repo && !isValidRepoUrl(repo)) {
197
430
  throw new ProjectError(`Invalid repo URL: ${repo}`, "invalid");
198
431
  }
432
+ const rawPath = input.path?.trim() || undefined;
433
+ // The managed axis (issue #206). Absent input derives the same way a legacy
434
+ // file does, so "what you get by default" is one rule everywhere.
435
+ const managed = isManaged({ managed: input.managed, repo, path: rawPath });
436
+ // Paddock curating its own files INTO a repo it also clones has no sensible
437
+ // meaning, so refuse rather than silently dropping one half. Refusing is the
438
+ // reversible choice: accepting it now and changing what it means later would
439
+ // be a migration.
440
+ if (managed && repo) {
441
+ throw new ProjectError("A managed project cannot have a repo — Paddock would be curating files " +
442
+ "into a checkout it clones. Use managed: false to work in the repo, or " +
443
+ "drop `repo` for a notebook.", "invalid");
444
+ }
445
+ if (!managed && !repo && !rawPath) {
446
+ throw new ProjectError("An unmanaged project needs a `path` (a directory to work in) or a `repo` " +
447
+ "(one to clone), or both.", "invalid");
448
+ }
449
+ // Validate BEFORE anything is created, so a bad path leaves nothing behind.
450
+ const checked = rawPath ? await this.validatePath(rawPath) : undefined;
451
+ const linkedPath = checked?.resolved;
199
452
  const now = today();
200
453
  const yaml = {
201
454
  name,
@@ -211,51 +464,121 @@ export class ProjectStore {
211
464
  summary: input.summary ?? "",
212
465
  links: input.links ?? [],
213
466
  pinned: [],
214
- // Carry `repo` only when repo-backed (same round-trip discipline as model).
467
+ // ALWAYS written explicitly, even though it is optional on disk: the
468
+ // derived default exists to give LEGACY files their old meaning, not to be
469
+ // relied on by files we write ourselves.
470
+ managed,
471
+ // Carry `repo` only when set (same round-trip discipline as model).
215
472
  ...(repo ? { repo } : {}),
473
+ // Carry the canonicalised path only when set (issue #206).
474
+ ...(linkedPath ? { path: linkedPath } : {}),
216
475
  };
476
+ // Everything this call brings into being, newest last — the ONLY things a
477
+ // failure below is allowed to remove. A pre-existing directory is never in
478
+ // here, which is what stops a rollback deleting a user-nominated target that
479
+ // might be their home directory.
480
+ const created = [];
217
481
  const dir = this.dirFor(slug);
482
+ if (!(await fs.stat(dir).then(() => true).catch(() => false)))
483
+ created.push(dir);
218
484
  await fs.mkdir(dir, { recursive: true });
219
- // For a repo-backed project, clone the external repo into the nested checkout
220
- // BEFORE writing any metadata, so a clone failure rolls the whole thing back
221
- // (rm the freshly-made dir) rather than leaving a broken project.yaml behind.
222
- if (repo) {
223
- const checkoutName = repoCheckoutName(repo);
224
- const checkoutDir = path.join(dir, checkoutName);
225
- try {
226
- await cloneRepo(repo, checkoutDir);
485
+ try {
486
+ if (linkedPath && checked) {
487
+ // Acquire the nominated path: use it, clone into it, or create it.
488
+ // `created` is threaded in so a mid-acquisition failure still rolls back
489
+ // what was made — see acquirePath.
490
+ await this.acquirePath({
491
+ resolved: linkedPath,
492
+ exists: checked.exists,
493
+ repo,
494
+ managed,
495
+ warn: (message) => this.log.warn(message),
496
+ created,
497
+ });
227
498
  }
228
- catch (err) {
229
- await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
230
- throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
499
+ else if (repo) {
500
+ // No path nominated: clone into the nested checkout, as before (#187).
501
+ const checkoutName = repoCheckoutName(repo);
502
+ const checkoutDir = path.join(dir, checkoutName);
503
+ created.push(checkoutDir);
504
+ try {
505
+ await cloneRepo(repo, checkoutDir);
506
+ }
507
+ catch (err) {
508
+ throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
509
+ }
510
+ // Keep the nested checkout (a full git repo) OUT of the instance data
511
+ // repo: a sidecar `.gitignore` ignores the checkout dir (git-in-git per
512
+ // #187's option A). `.chats/` is likewise data-repo noise.
513
+ await this.ensureSidecarGitignore(dir, checkoutName);
231
514
  }
232
- // Keep the nested checkout (a full git repo) OUT of the instance data repo:
233
- // a sidecar `.gitignore` ignores the checkout dir (git-in-git per #187's
234
- // option A). `.chats/` (our transcript store) is likewise data-repo noise.
235
- await this.ensureSidecarGitignore(dir, checkoutName);
515
+ await this.finishCreate({ slug, yaml, dir, name, now, managed });
236
516
  }
237
- await this.writeYaml(slug, yaml);
238
- await fs.writeFile(path.join(dir, CHANGELOG_FILE), [
239
- `# Changelog — ${name}`,
240
- "",
241
- "<!--",
242
- "Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
243
- "-->",
244
- "",
245
- `## ${now}`,
246
- "- Project opened.",
247
- "",
248
- ].join("\n"), "utf8");
249
- // Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a NOTEBOOK
250
- // project. A repo-backed project defers to the repo's OWN CLAUDE.md (loaded
251
- // natively via the cwd walk-up from the checkout); the sweeper must never
252
- // touch that upstream-owned file, so we don't seed a competing one (#187).
253
- if (!repo) {
254
- await fs.writeFile(path.join(dir, CLAUDE_FILE), claudeTemplate(name, yaml.summary), "utf8");
517
+ catch (err) {
518
+ // ONE rollback covering everything after the slug dir, not just the
519
+ // acquisition: a `writeYaml` or seed-file failure used to leave the
520
+ // directories we had just made (a clone target, a created notes dir) behind
521
+ // with no project to own them.
522
+ await this.rollback(created);
523
+ throw err;
255
524
  }
256
525
  // No OVERVIEW.md at creation — the first sweep writes it.
257
526
  return this.toDto(dir, yaml, false);
258
527
  }
528
+ /**
529
+ * Write a new project's `project.yaml` and seed its content files. Split out of
530
+ * {@link create} so the whole sequence sits inside that method's single
531
+ * try/rollback rather than trailing off the end of it.
532
+ */
533
+ async finishCreate(args) {
534
+ const { slug, yaml, dir, name, now, managed } = args;
535
+ await this.writeYaml(slug, yaml);
536
+ const contentDir = contentDirFor(dir, yaml);
537
+ // The curated trio follows the content (issue #206): for a managed project
538
+ // with an external `path` these land out there rather than in the data dir.
539
+ // `dir` is already made; `contentDir` may be the same directory or the
540
+ // freshly-acquired path, both of which exist by now.
541
+ //
542
+ // Never clobber: a managed `path` may point at a folder of notes that already
543
+ // has a CHANGELOG.md, and seeding "Project opened." over the user's history
544
+ // would be the worst kind of data loss — silent and plausible. For a fresh
545
+ // project dir the file is always absent, so this only bites in the new case.
546
+ const changelogFile = path.join(contentDir, CHANGELOG_FILE);
547
+ const changelogExists = await fs
548
+ .access(changelogFile)
549
+ .then(() => true)
550
+ .catch(() => false);
551
+ if (!changelogExists) {
552
+ await fs.writeFile(changelogFile, [
553
+ `# Changelog — ${name}`,
554
+ "",
555
+ "<!--",
556
+ "Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
557
+ "-->",
558
+ "",
559
+ `## ${now}`,
560
+ "- Project opened.",
561
+ "",
562
+ ].join("\n"), "utf8");
563
+ }
564
+ // Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a MANAGED
565
+ // project, and never over an existing one. An UNMANAGED project's working
566
+ // directory owns its own `CLAUDE.md` (upstream's, in a checkout the user
567
+ // source-controls); the sweeper must never write that file, so we don't seed a
568
+ // competing one. A managed project with an external `path` DOES get one, out
569
+ // there with the rest of its content — but if that directory already has a
570
+ // `CLAUDE.md` (a pre-existing notes folder), it is the user's and stays.
571
+ if (managed) {
572
+ const claudeFile = path.join(contentDir, CLAUDE_FILE);
573
+ const claudeExists = await fs
574
+ .access(claudeFile)
575
+ .then(() => true)
576
+ .catch(() => false);
577
+ if (!claudeExists) {
578
+ await fs.writeFile(claudeFile, claudeTemplate(name, yaml.summary), "utf8");
579
+ }
580
+ }
581
+ }
259
582
  /**
260
583
  * Promote an existing NOTEBOOK project into a REPO-BACKED one IN PLACE (issue
261
584
  * #213), preserving its chats + sidecar metadata. This relaxes #187's create-time
@@ -293,8 +616,18 @@ export class ProjectStore {
293
616
  throw new ProjectError("The root workspace cannot be repo-backed", "invalid");
294
617
  }
295
618
  const current = await this.get(slug); // throws not_found
296
- if (current.repoBacked) {
297
- throw new ProjectError(`Project is already repo-backed: ${slug}`, "invalid");
619
+ // Promotion means "start working in a clone of this repo", so it only makes
620
+ // sense for a project Paddock currently manages. An UNMANAGED project already
621
+ // works in code somebody source-controls (issue #206).
622
+ if (!current.managed) {
623
+ throw new ProjectError(`Project is already backed by a repo or directory: ${slug}`, "invalid");
624
+ }
625
+ // A managed project with its own `path` is a notes folder the user nominated;
626
+ // promoting it would clone into that directory. Say so specifically — the
627
+ // generic message above would be misleading.
628
+ if (current.path) {
629
+ throw new ProjectError(`Project "${slug}" keeps its content in ${current.path} and cannot be promoted; ` +
630
+ `create an unmanaged project pointing at the repo instead`, "invalid");
298
631
  }
299
632
  const repo = repoUrl?.trim();
300
633
  if (!repo || !isValidRepoUrl(repo)) {
@@ -317,7 +650,7 @@ export class ProjectStore {
317
650
  await cloneRepo(repo, checkoutDir);
318
651
  }
319
652
  catch (err) {
320
- await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
653
+ await this.rmInsideRoot(checkoutDir).catch(() => undefined);
321
654
  throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
322
655
  }
323
656
  // `writeYaml` is the atomic COMMIT point: until it succeeds the notebook is
@@ -330,13 +663,19 @@ export class ProjectStore {
330
663
  const next = {
331
664
  ...this.stripDto(current),
332
665
  repo,
666
+ // Promotion crosses the managed axis (issue #206): the keeper's cwd becomes
667
+ // a checkout that owns its own CLAUDE.md, which is exactly what unmanaged
668
+ // means. Set explicitly — `stripDto` carries the notebook's concrete
669
+ // `managed: true` forward, and leaving it would persist the one combination
670
+ // `create()` rejects (managed + repo).
671
+ managed: false,
333
672
  updated: today(),
334
673
  };
335
674
  try {
336
675
  await this.writeYaml(slug, next);
337
676
  }
338
677
  catch (err) {
339
- await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
678
+ await this.rmInsideRoot(checkoutDir).catch(() => undefined);
340
679
  throw err;
341
680
  }
342
681
  // Post-commit finalization — the project is now validly repo-backed; these
@@ -364,6 +703,24 @@ export class ProjectStore {
364
703
  started: current.started, // immutable
365
704
  updated: today(),
366
705
  };
706
+ // `path` and `managed` are immutable (issue #206), enforced here rather than
707
+ // by their absence from UpdateProjectInput — that type is a compile-time shape
708
+ // while `rest` is spread from a request body.
709
+ //
710
+ // For `path` both directions matter: a project must not be RE-POINTED (its cwd
711
+ // is baked into every transcript path, so moving it strands the history), and
712
+ // one must not be given a path by a stray key, which would silently hand its
713
+ // keeper a cwd somewhere else on the box that never went through
714
+ // `validatePath`. `managed` is re-asserted from `current` for the same reason
715
+ // — flipping it would move where the curated trio lives and either start the
716
+ // sweeper writing into a checkout or orphan the existing notes. (Changing it
717
+ // deliberately is a migration, not a PATCH; see #708 for what a silent
718
+ // location flip does to history.)
719
+ if (current.path)
720
+ next.path = current.path;
721
+ else
722
+ delete next.path;
723
+ next.managed = current.managed;
367
724
  if (driveModePatch === null) {
368
725
  // Clear the per-project override -> inherit the global default (issue #122).
369
726
  delete next.driveMode;
@@ -440,7 +797,7 @@ export class ProjectStore {
440
797
  /** Read OVERVIEW.md, or "" if it doesn't exist yet. */
441
798
  async readOverview(slug) {
442
799
  try {
443
- return await fs.readFile(path.join(this.dirFor(slug), OVERVIEW_FILE), "utf8");
800
+ return await fs.readFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), "utf8");
444
801
  }
445
802
  catch (err) {
446
803
  if (err.code === "ENOENT")
@@ -450,12 +807,12 @@ export class ProjectStore {
450
807
  }
451
808
  /** Replace OVERVIEW.md wholesale (the sweep regenerates it each time). */
452
809
  async writeOverview(slug, content) {
453
- await fs.writeFile(path.join(this.dirFor(slug), OVERVIEW_FILE), content, "utf8");
810
+ await fs.writeFile(path.join(await this.contentDirOf(slug), OVERVIEW_FILE), content, "utf8");
454
811
  }
455
812
  /** Read CHANGELOG.md, or "" if it doesn't exist yet (issue #188). */
456
813
  async readChangelog(slug) {
457
814
  try {
458
- return await fs.readFile(path.join(this.dirFor(slug), CHANGELOG_FILE), "utf8");
815
+ return await fs.readFile(path.join(await this.contentDirOf(slug), CHANGELOG_FILE), "utf8");
459
816
  }
460
817
  catch (err) {
461
818
  if (err.code === "ENOENT")
@@ -465,19 +822,39 @@ export class ProjectStore {
465
822
  }
466
823
  /** Whether OVERVIEW.md exists for this project. */
467
824
  async overviewExists(slug) {
825
+ return this.overviewExistsIn(await this.contentDirOf(slug));
826
+ }
827
+ /**
828
+ * {@link overviewExists} for a content dir already in hand.
829
+ *
830
+ * `readSafe` needs this: it has just parsed the yaml, so it can derive the
831
+ * content dir for free, whereas going through the slug would re-read
832
+ * `project.yaml` on every project of every `list()`.
833
+ */
834
+ async overviewExistsIn(contentDir) {
468
835
  try {
469
- await fs.access(path.join(this.dirFor(slug), OVERVIEW_FILE));
836
+ await fs.access(path.join(contentDir, OVERVIEW_FILE));
470
837
  return true;
471
838
  }
472
839
  catch {
473
840
  return false;
474
841
  }
475
842
  }
843
+ /**
844
+ * Where this project's curated trio lives (issue #206) — see
845
+ * {@link contentDirFor}. Falls back to the metadata dir for a slug that names
846
+ * no project, so a caller reading a missing project still gets "" rather than
847
+ * throwing.
848
+ */
849
+ async contentDirOf(slug) {
850
+ const project = await this.readSafe(slug);
851
+ return project?.contentDir ?? this.dirFor(slug);
852
+ }
476
853
  // --- CLAUDE.md (durable identity & conventions, sweep-amended) ----------
477
854
  /** Read CLAUDE.md, or "" if it doesn't exist yet (issue #177). */
478
855
  async readClaudeMd(slug) {
479
856
  try {
480
- return await fs.readFile(path.join(this.dirFor(slug), CLAUDE_FILE), "utf8");
857
+ return await fs.readFile(path.join(await this.contentDirOf(slug), CLAUDE_FILE), "utf8");
481
858
  }
482
859
  catch (err) {
483
860
  if (err.code === "ENOENT")
@@ -502,8 +879,7 @@ export class ProjectStore {
502
879
  const trimmed = curatedBody.trim();
503
880
  if (!trimmed)
504
881
  return;
505
- const dir = this.dirFor(slug);
506
- const file = path.join(dir, CLAUDE_FILE);
882
+ const file = path.join(await this.contentDirOf(slug), CLAUDE_FILE);
507
883
  let body;
508
884
  try {
509
885
  body = await fs.readFile(file, "utf8");
@@ -602,13 +978,12 @@ export class ProjectStore {
602
978
  throw new ProjectError("Refusing to delete the root workspace", "invalid");
603
979
  }
604
980
  const project = await this.get(slug); // throws not_found
605
- const dir = this.dirFor(slug);
606
- // Guard against deleting the projects root itself if a bad slug slipped in.
607
- const resolved = path.resolve(dir);
608
- if (resolved === path.resolve(this.root) || !resolved.startsWith(path.resolve(this.root) + path.sep)) {
609
- throw new ProjectError("Refusing to delete outside the projects root", "invalid");
610
- }
611
- await fs.rm(dir, { recursive: true, force: true });
981
+ // Only ever the METADATA dir — never `project.workingDir`. For a LINKED
982
+ // project (issue #206) those differ and the working dir is the user's real
983
+ // clone: deleting the project unlinks it, it does not delete their work.
984
+ // `rmInsideRoot` enforces that structurally (a linked path is outside the
985
+ // root by validation, so it cannot be reached from here at all).
986
+ await this.rmInsideRoot(this.dirFor(slug));
612
987
  return project;
613
988
  }
614
989
  /**
@@ -626,7 +1001,7 @@ export class ProjectStore {
626
1001
  const trimmed = body.trim();
627
1002
  if (!trimmed)
628
1003
  return;
629
- const file = path.join(this.dirFor(slug), CHANGELOG_FILE);
1004
+ const file = path.join(await this.contentDirOf(slug), CHANGELOG_FILE);
630
1005
  const title = `# Changelog — ${slug}`;
631
1006
  // Drop a leading top-level heading the model may have emitted, so the
632
1007
  // Paddock-owned title isn't duplicated.
@@ -695,8 +1070,11 @@ export class ProjectStore {
695
1070
  return null;
696
1071
  yaml = this.normalize({}, key);
697
1072
  }
698
- // overviewExists is a cheap fs.access; do it after the yaml parse succeeds.
699
- const hasOverview = await this.overviewExists(key);
1073
+ // A cheap fs.access, done after the yaml parse succeeds. Deliberately the
1074
+ // content-dir-in-hand variant: the slug-taking `overviewExists` would re-read
1075
+ // this very `project.yaml` to find the content dir, once per project of every
1076
+ // `list()`.
1077
+ const hasOverview = await this.overviewExistsIn(contentDirFor(dir, yaml));
700
1078
  return this.toDto(dir, yaml, hasOverview);
701
1079
  }
702
1080
  /** Fill defaults / coerce a parsed project.yaml into a complete ProjectYaml. */
@@ -776,6 +1154,25 @@ export class ProjectStore {
776
1154
  // repo (issue #187): carried only when present — its presence is what marks
777
1155
  // the project repo-backed and drives the workingDir resolution in toDto.
778
1156
  ...(typeof p.repo === "string" && p.repo.trim() ? { repo: p.repo.trim() } : {}),
1157
+ // path (issue #206): carried only when present AND absolute — its presence
1158
+ // is what makes workingDirFor return the cwd verbatim. The absolute check is
1159
+ // a read-boundary backstop against a hand-edited relative value, which would
1160
+ // otherwise resolve against the server's cwd; dropping it degrades the
1161
+ // project to an unbacked one rather than pointing an agent somewhere
1162
+ // arbitrary. Existence/containment were checked at create time and are NOT
1163
+ // re-checked here — `normalize` runs on every list() and must stay pure +
1164
+ // cheap, and a path that has since gone away should surface as a broken cwd,
1165
+ // not silently mutate into a different kind of project.
1166
+ ...(typeof p.path === "string" && path.isAbsolute(p.path.trim())
1167
+ ? { path: p.path.trim() }
1168
+ : {}),
1169
+ // managed (issue #206): carried ONLY when explicitly set on disk. An absent
1170
+ // value must stay absent so it keeps deriving from the backing
1171
+ // (`!(repo || path)`) — baking a concrete `true` in here would flip every
1172
+ // legacy repo-backed project to managed the first time its yaml was
1173
+ // re-written, and the sweeper would start writing CLAUDE.md into a checkout.
1174
+ // Same round-trip discipline as `driveMode`, and for a much sharper reason.
1175
+ ...(typeof p.managed === "boolean" ? { managed: p.managed } : {}),
779
1176
  // schedules (issue #265): carried only when at least one well-formed entry
780
1177
  // survives sanitization — an absent/empty map stays absent on disk, so files
781
1178
  // without schedules round-trip unchanged. A malformed entry is dropped (not
@@ -875,18 +1272,32 @@ export class ProjectStore {
875
1272
  // sanitised so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#384).
876
1273
  curation: sanitizeCurationOverride(yaml.curation),
877
1274
  dir,
878
- // Repo-backed (#187): the keeper's cwd is the nested checkout; a notebook's
879
- // cwd is the metadata dir itself. `repoBacked` is the presence of `repo`.
880
- workingDir: workingDirFor(dir, yaml.repo),
881
- repoBacked: Boolean(yaml.repo),
1275
+ // The keeper's cwd: `path` verbatim (#206), else the nested checkout for a
1276
+ // repo-backed project (#187), else the metadata dir.
1277
+ workingDir: workingDirFor(dir, yaml.repo, yaml.path),
1278
+ // Where the curated trio lives, and whether Paddock curates at all — the
1279
+ // two facts that replaced `repoBacked` (#206). Both ALWAYS concrete here,
1280
+ // with `managed` resolved through the derived default so a legacy file
1281
+ // without the key keeps the meaning it has always had.
1282
+ contentDir: contentDirFor(dir, yaml),
1283
+ managed: isManaged(yaml),
882
1284
  hasOverview,
883
1285
  };
884
1286
  }
1287
+ /**
1288
+ * Drop the derived DTO-only fields, leaving the on-disk shape.
1289
+ *
1290
+ * `managed` deliberately SURVIVES: it is a real `project.yaml` key, and because
1291
+ * the DTO's value is always concrete, the first `update()` of a legacy project
1292
+ * pins its derived value explicitly on disk. That is the desired direction —
1293
+ * the file stops depending on a default — and it is safe precisely because the
1294
+ * value written is the one the derivation already produced.
1295
+ */
885
1296
  stripDto(p) {
886
- const { dir: _dir, workingDir: _workingDir, repoBacked: _repoBacked, hasOverview: _hasOverview, group, ...rest } = p;
1297
+ const { dir: _dir, workingDir: _workingDir, contentDir: _contentDir, hasOverview: _hasOverview, group, ...rest } = p;
887
1298
  void _dir;
888
1299
  void _workingDir;
889
- void _repoBacked;
1300
+ void _contentDir;
890
1301
  void _hasOverview;
891
1302
  // Keep an empty area off the yaml so it isn't persisted as `group: ""`.
892
1303
  return group?.trim() ? { ...rest, group } : rest;