@edspencer/paddock 0.63.0 → 0.64.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 (78) 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 +2 -1
  10. package/packages/server/dist/project-paths.d.ts +72 -4
  11. package/packages/server/dist/project-paths.js +76 -4
  12. package/packages/server/dist/project-types.d.ts +109 -11
  13. package/packages/server/dist/projects.d.ts +161 -3
  14. package/packages/server/dist/projects.js +466 -64
  15. package/packages/server/dist/routes/git.js +6 -3
  16. package/packages/server/dist/routes/projects.js +16 -2
  17. package/packages/server/dist/self-mcp-types.d.ts +6 -4
  18. package/packages/server/dist/sweep.js +10 -5
  19. package/packages/server/package.json +1 -1
  20. package/packages/web/dist/assets/{InstanceConfigPage-CCk2hVT9.js → InstanceConfigPage-Blr1a-hx.js} +1 -1
  21. package/packages/web/dist/assets/{MarkdownRenderer-B6gST_0U.js → MarkdownRenderer-R6YzfI9Y.js} +1 -1
  22. package/packages/web/dist/assets/{ProjectRedirect-BCmKT4mf.js → ProjectRedirect-Cy-7Thc-.js} +1 -1
  23. package/packages/web/dist/assets/{ProjectView-CCN98MWu.js → ProjectView-bMX4GEau.js} +9 -9
  24. package/packages/web/dist/assets/{ProjectsGrid-CYzuN_yD.js → ProjectsGrid-C9XHf4eS.js} +1 -1
  25. package/packages/web/dist/assets/{arc-BxvtMCZQ.js → arc-B_42F7Yk.js} +1 -1
  26. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Bp6ffJ_u.js → architectureDiagram-3BPJPVTR-BAXzSOyw.js} +1 -1
  27. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BIY2X8nW.js → blockDiagram-GPEHLZMM-BdYEdA-3.js} +1 -1
  28. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Br9stEJS.js → c4Diagram-AAUBKEIU-Cc4o15R5.js} +1 -1
  29. package/packages/web/dist/assets/channel-BZnRb_8C.js +2 -0
  30. package/packages/web/dist/assets/{chunk-2J33WTMH-Djc7uqMV.js → chunk-2J33WTMH-CeuxHjIm.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-4BX2VUAB-yEVMLksS.js → chunk-4BX2VUAB-DnZfrAaV.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-55IACEB6-tJaEdPZg.js → chunk-55IACEB6-DFE6UYOT.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-727SXJPM-bWEqh672.js → chunk-727SXJPM-YVHQ5cHc.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B8U9fB-T.js → chunk-AQP2D5EJ-5BYSbg-F.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-FMBD7UC4-BeEITYig.js → chunk-FMBD7UC4-HBcelndR.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-ND2GUHAM-L2rpLaoK.js → chunk-ND2GUHAM-2AwEsdxJ.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-QZHKN3VN-qqzPMZL_.js → chunk-QZHKN3VN-BPpaZ6ct.js} +1 -1
  38. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +2 -0
  39. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +2 -0
  40. package/packages/web/dist/assets/{core-CSaNbmLO.js → core-Cu_4vw1U.js} +1 -1
  41. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BRF7Nech.js → cose-bilkent-S5V4N54A-DWkF0NrF.js} +1 -1
  42. package/packages/web/dist/assets/{dagre-BM42HDAG-D10rJWOR.js → dagre-BM42HDAG-DTgNmO9I.js} +1 -1
  43. package/packages/web/dist/assets/{diagram-2AECGRRQ-DGjT5mAO.js → diagram-2AECGRRQ-DC28b7YS.js} +1 -1
  44. package/packages/web/dist/assets/{diagram-5GNKFQAL-DaH3bxHh.js → diagram-5GNKFQAL-_WvuLno6.js} +1 -1
  45. package/packages/web/dist/assets/{diagram-KO2AKTUF-QrLW9Wn6.js → diagram-KO2AKTUF-BEYxPfWq.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-LMA3HP47-BX15wgYN.js → diagram-LMA3HP47-Biyev7x0.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-OG6HWLK6-DXQ_p63s.js → diagram-OG6HWLK6-u47NLW3S.js} +1 -1
  48. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C_ioRruE.js → erDiagram-TEJ5UH35-Bud2ISlK.js} +1 -1
  49. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-7d-xLXLM.js → flowDiagram-I6XJVG4X-D-18vLQ1.js} +1 -1
  50. package/packages/web/dist/assets/{format-BSDZezwD.js → format-CQ2BDlby.js} +1 -1
  51. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DZYx5xcj.js → ganttDiagram-6RSMTGT7-CzyRX39N.js} +1 -1
  52. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-dimo-osm.js → gitGraphDiagram-PVQCEYII-DLogfrjg.js} +1 -1
  53. package/packages/web/dist/assets/{index-CUnFKMga.js → index-B0gJ0qWe.js} +14 -14
  54. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BzApY25x.js → infoDiagram-5YYISTIA-OgNPt6RG.js} +1 -1
  55. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DtX47_9p.js → ishikawaDiagram-YF4QCWOH-CrM0t0_X.js} +1 -1
  56. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C7fITKg1.js → journeyDiagram-JHISSGLW-xbxfHfwR.js} +1 -1
  57. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BOrWLf_W.js → kanban-definition-UN3LZRKU-DpszBaZb.js} +1 -1
  58. package/packages/web/dist/assets/{linear-DuyAaBu6.js → linear-BLujdbjQ.js} +1 -1
  59. package/packages/web/dist/assets/{mermaid.core-BQlzuVvO.js → mermaid.core-DzEhy9Qq.js} +4 -4
  60. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CmP748B5.js → mindmap-definition-RKZ34NQL-DaH3Lmoo.js} +1 -1
  61. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CRd5Pdfq.js → pieDiagram-4H26LBE5-BxeW1kuf.js} +1 -1
  62. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DpJm0Yw_.js → quadrantDiagram-W4KKPZXB-BKs2CT0r.js} +1 -1
  63. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-B0o2PRMC.js → requirementDiagram-4Y6WPE33-CVdO-hXD.js} +1 -1
  64. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CBi3Ek3v.js → sankeyDiagram-5OEKKPKP-yusj_kJC.js} +1 -1
  65. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C1stxfQg.js → sequenceDiagram-3UESZ5HK-C0h-9c8Y.js} +1 -1
  66. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-lCkiETWK.js → stateDiagram-AJRCARHV-BSBY8Psb.js} +1 -1
  67. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +2 -0
  68. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D3DkWJMC.js → timeline-definition-PNZ67QCA-DEFUdNb8.js} +1 -1
  69. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CO2-yC7K.js → vennDiagram-CIIHVFJN-DunhMqxK.js} +1 -1
  70. package/packages/web/dist/assets/{wardley-L42UT6IY-Dfjp0kqX.js → wardley-L42UT6IY-CXJsBGKl.js} +1 -1
  71. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CIl0OPwS.js → wardleyDiagram-YWT4CUSO-DfT_-dqX.js} +1 -1
  72. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-CuvlQuup.js → xychartDiagram-2RQKCTM6-BysTtULW.js} +1 -1
  73. package/packages/web/dist/index.html +1 -1
  74. package/packages/web/dist/sw.js +1 -1
  75. package/packages/web/dist/assets/channel-CMdgNeFH.js +0 -2
  76. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +0 -2
  77. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +0 -2
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -123,6 +123,24 @@ export declare function isSweeperPrompt(preview: string): boolean;
123
123
  * to a real remote — the caller does not have to special-case it.
124
124
  */
125
125
  export declare function normalizeRemote(url: string): string;
126
+ /**
127
+ * Every remote URL configured in a git checkout, normalised.
128
+ *
129
+ * Reads `.git/config` directly rather than shelling out to git: this runs for
130
+ * candidate directories behind a count rendered in a header, and a subprocess per
131
+ * candidate is exactly the cost that made checking the remote look unaffordable
132
+ * in the first place.
133
+ *
134
+ * Handles a linked WORKTREE, where `.git` is a FILE holding `gitdir: <path>` and
135
+ * the config lives in the MAIN repository's git dir — found by resolving the
136
+ * `commondir` pointer. This repo is developed with worktrees, so a contributor's
137
+ * `wt-*` checkout is a real case, not a hypothetical.
138
+ *
139
+ * Returns `[]` for anything unreadable or not a checkout at all. An INI parse is
140
+ * enough here — `url = …` inside a `[remote "…"]` section — because the question
141
+ * is only "does any remote name this repo", not "what is the exact config".
142
+ */
143
+ export declare function gitRemotes(cwd: string): Promise<string[]>;
126
144
  /** The engine surface this module needs — narrowed so tests can fake it. */
127
145
  export interface AdoptableFleet {
128
146
  listAdoptableSessions(agentName: string, fromWorkingDir?: string): Promise<AdoptableSession[]>;
@@ -216,7 +216,7 @@ export function normalizeRemote(url) {
216
216
  * enough here — `url = …` inside a `[remote "…"]` section — because the question
217
217
  * is only "does any remote name this repo", not "what is the exact config".
218
218
  */
219
- async function gitRemotes(cwd) {
219
+ export async function gitRemotes(cwd) {
220
220
  const dot = path.join(cwd, ".git");
221
221
  const st = await fs.stat(dot).catch(() => null);
222
222
  if (st === null)
@@ -109,7 +109,9 @@ export async function buildApp(opts = {}) {
109
109
  // on a fatal misconfig (e.g. jwt mode without a JWKS URL) — fail closed.
110
110
  registerAuth(app, cfg.auth);
111
111
  // --- project layer + herdctl ------------------------------------------
112
- const projects = new ProjectStore(cfg.projectsRoot);
112
+ const projects = new ProjectStore(cfg.projectsRoot, cfg.dataDir, {
113
+ warn: (message) => app.log.warn(message),
114
+ });
113
115
  await projects.init();
114
116
  // The user's own MCP servers, under `claude.mcpServers: host` (#691 step 5).
115
117
  // Read BEFORE the service is constructed because every keeper's agent config
@@ -471,7 +471,15 @@ export async function ensureClaudeHome(cfg, env = process.env, probeKeychain = p
471
471
  }
472
472
  if (present.length > 0) {
473
473
  report.notices.push({
474
- level: "info",
474
+ // `warn`, not `info`, and deliberately (#706). `cli/paddock.ts` sets
475
+ // LOG_LEVEL=warn unless `--verbose`, so at `info` this was filtered out
476
+ // on the `npx` path — the documented install route, and the one
477
+ // population the notice exists for. It is not noise at `warn` because it
478
+ // is already inside `present.length > 0`: it fires only for someone who
479
+ // HAS ~/.claude instruction files that are not being loaded. An empty
480
+ // ~/.claude, or `instructions: host`, prints nothing at all. Do not
481
+ // "tidy" it back to `info` — that reintroduces #706.
482
+ level: "warn",
475
483
  message: `Claude instructions: this instance's own only (\`claude.instructions: own\`). Your ` +
476
484
  `~/.claude ${present.join(", ")} ${present.length === 1 ? "is" : "are"} NOT loaded — ` +
477
485
  `set \`claude.instructions: host\` (or PADDOCK_CLAUDE_INSTRUCTIONS=host) to share ` +
@@ -126,7 +126,7 @@ async function main() {
126
126
  if (args.dataDir !== undefined)
127
127
  process.env.PADDOCK_DATA_DIR = args.dataDir;
128
128
  const cfg = loadPaddockConfig();
129
- const projects = new ProjectStore(cfg.projectsRoot);
129
+ const projects = new ProjectStore(cfg.projectsRoot, cfg.dataDir);
130
130
  const herdctl = new HerdctlService(cfg);
131
131
  const project = await projects.get(args.project);
132
132
  // Register the keepers so the agent this imports INTO exists and its `.chats/`
@@ -46,6 +46,8 @@ export declare class GitService {
46
46
  private readonly author;
47
47
  /** Cached repo-detection result for `projectsRoot` (null = not yet checked). */
48
48
  private repoFlag;
49
+ /** Cached per-directory repo detection, keyed by resolved path (#597 / #206). */
50
+ private readonly repoAt;
49
51
  /**
50
52
  * @param projectsRoot Absolute path to the projects root the service operates on.
51
53
  * @param author Commit identity used when Paddock commits on a project's behalf.
@@ -62,9 +64,36 @@ export declare class GitService {
62
64
  * Whether the backing store is a git working tree. Cached after the first
63
65
  * successful check (repo-ness doesn't change under a running server); a thrown
64
66
  * git / missing-binary error resolves to `false`.
67
+ *
68
+ * Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
69
+ * {@link remote}, {@link push}). The PER-PROJECT surface asks
70
+ * {@link isRepoAt} about the project's own working directory instead — see
71
+ * that method for why the two had to be separated.
65
72
  */
66
73
  isRepo(): Promise<boolean>;
67
- /** Force the next `isRepo()` to re-check (e.g. after `git init` at runtime). */
74
+ /**
75
+ * Whether `dir` is inside a git working tree, cached per directory.
76
+ *
77
+ * The per-project git surface used to gate on {@link isRepo} — "is the PROJECTS
78
+ * ROOT a repo?" — which was right only while every project's tree lived under
79
+ * that root. It stopped being right twice over (issue #597, issue #206):
80
+ *
81
+ * - a project whose working directory is a linked checkout outside the root
82
+ * would report "not a repo" whenever the root itself wasn't one, hiding the
83
+ * Changes tab for the exact directory that IS a repo;
84
+ * - conversely a non-repo directory inside a repo root would claim to be one.
85
+ *
86
+ * Asking about the directory the project actually works in answers both. For
87
+ * every pre-existing shape it returns what {@link isRepo} did, because a
88
+ * project dir under a repo root is itself inside that work tree.
89
+ *
90
+ * Probing rather than requiring is the house style: nothing here refuses a
91
+ * directory for not being a repo, it just doesn't light up the git features.
92
+ */
93
+ isRepoAt(dir: string): Promise<boolean>;
94
+ /** One `rev-parse`, false on any failure (missing binary, missing dir, not a repo). */
95
+ private probeRepo;
96
+ /** Force the next repo check to re-run (e.g. after `git init` at runtime). */
68
97
  resetRepoCache(): void;
69
98
  /**
70
99
  * Status for one project's subtree. Returns `{ repo: false }` when the store
@@ -120,6 +149,20 @@ export declare class GitService {
120
149
  * when the store isn't a repo.
121
150
  */
122
151
  dirtyCounts(): Promise<Record<string, number>>;
152
+ /**
153
+ * Uncommitted-file count for ONE directory's own repository.
154
+ *
155
+ * The companion to {@link dirtyCounts} for a project whose working directory
156
+ * lives outside `projectsRoot` (issue #206). That method answers the whole grid
157
+ * in a single `git status` over the store, which is why it is cheap — and also
158
+ * why it structurally cannot see a linked checkout: it buckets paths by their
159
+ * first segment relative to the store, and a directory outside it has no such
160
+ * segment. This one costs a subprocess, so callers use it only for the projects
161
+ * the cheap sweep genuinely cannot cover.
162
+ *
163
+ * Returns 0 for a directory that isn't a repo, matching "nothing to report".
164
+ */
165
+ dirtyCountAt(dir: string): Promise<number>;
123
166
  /** Push the current branch to origin (sets upstream on first push). */
124
167
  push(): Promise<{
125
168
  pushed: boolean;
@@ -52,6 +52,8 @@ export class GitService {
52
52
  author;
53
53
  /** Cached repo-detection result for `projectsRoot` (null = not yet checked). */
54
54
  repoFlag = null;
55
+ /** Cached per-directory repo detection, keyed by resolved path (#597 / #206). */
56
+ repoAt = new Map();
55
57
  /**
56
58
  * @param projectsRoot Absolute path to the projects root the service operates on.
57
59
  * @param author Commit identity used when Paddock commits on a project's behalf.
@@ -74,25 +76,60 @@ export class GitService {
74
76
  * Whether the backing store is a git working tree. Cached after the first
75
77
  * successful check (repo-ness doesn't change under a running server); a thrown
76
78
  * git / missing-binary error resolves to `false`.
79
+ *
80
+ * Store-wide, so it still gates the store-wide operations ({@link dirtyCounts},
81
+ * {@link remote}, {@link push}). The PER-PROJECT surface asks
82
+ * {@link isRepoAt} about the project's own working directory instead — see
83
+ * that method for why the two had to be separated.
77
84
  */
78
85
  async isRepo() {
79
86
  if (this.repoFlag !== null)
80
87
  return this.repoFlag;
88
+ this.repoFlag = await this.probeRepo(this.projectsRoot);
89
+ return this.repoFlag;
90
+ }
91
+ /**
92
+ * Whether `dir` is inside a git working tree, cached per directory.
93
+ *
94
+ * The per-project git surface used to gate on {@link isRepo} — "is the PROJECTS
95
+ * ROOT a repo?" — which was right only while every project's tree lived under
96
+ * that root. It stopped being right twice over (issue #597, issue #206):
97
+ *
98
+ * - a project whose working directory is a linked checkout outside the root
99
+ * would report "not a repo" whenever the root itself wasn't one, hiding the
100
+ * Changes tab for the exact directory that IS a repo;
101
+ * - conversely a non-repo directory inside a repo root would claim to be one.
102
+ *
103
+ * Asking about the directory the project actually works in answers both. For
104
+ * every pre-existing shape it returns what {@link isRepo} did, because a
105
+ * project dir under a repo root is itself inside that work tree.
106
+ *
107
+ * Probing rather than requiring is the house style: nothing here refuses a
108
+ * directory for not being a repo, it just doesn't light up the git features.
109
+ */
110
+ async isRepoAt(dir) {
111
+ const key = path.resolve(dir);
112
+ const cached = this.repoAt.get(key);
113
+ if (cached !== undefined)
114
+ return cached;
115
+ const found = await this.probeRepo(key);
116
+ this.repoAt.set(key, found);
117
+ return found;
118
+ }
119
+ /** One `rev-parse`, false on any failure (missing binary, missing dir, not a repo). */
120
+ async probeRepo(dir) {
81
121
  try {
82
- const out = await this.git(this.projectsRoot, [
83
- "rev-parse",
84
- "--is-inside-work-tree",
85
- ]);
86
- this.repoFlag = out.trim() === "true";
122
+ const out = await this.git(dir, ["rev-parse", "--is-inside-work-tree"]);
123
+ return out.trim() === "true";
87
124
  }
88
125
  catch {
89
- this.repoFlag = false;
126
+ return false;
90
127
  }
91
- return this.repoFlag;
92
128
  }
93
- /** Force the next `isRepo()` to re-check (e.g. after `git init` at runtime). */
129
+ /** Force the next repo check to re-run (e.g. after `git init` at runtime). */
94
130
  resetRepoCache() {
95
131
  this.repoFlag = null;
132
+ this.repoAt.clear();
96
133
  }
97
134
  /**
98
135
  * Status for one project's subtree. Returns `{ repo: false }` when the store
@@ -100,7 +137,9 @@ export class GitService {
100
137
  * `projectDir`.
101
138
  */
102
139
  async projectStatus(projectDir) {
103
- if (!(await this.isRepo())) {
140
+ // Per-DIRECTORY, not store-wide: callers pass the project's workingDir, which
141
+ // may be a linked checkout outside `projectsRoot` entirely (#597 / #206).
142
+ if (!(await this.isRepoAt(projectDir))) {
104
143
  return { repo: false, files: [], clean: true };
105
144
  }
106
145
  let branch;
@@ -188,7 +227,7 @@ export class GitService {
188
227
  * (they're listed by `projectStatus`). Returns "" when not a repo / no diff.
189
228
  */
190
229
  async projectDiff(projectDir, file) {
191
- if (!(await this.isRepo()))
230
+ if (!(await this.isRepoAt(projectDir)))
192
231
  return "";
193
232
  const args = ["diff", "HEAD", "--", file ?? "."];
194
233
  try {
@@ -259,7 +298,7 @@ export class GitService {
259
298
  * Omitting `paths` keeps the legacy commit-the-whole-subtree behavior.
260
299
  */
261
300
  async commitProject(projectDir, message, paths) {
262
- if (!(await this.isRepo()))
301
+ if (!(await this.isRepoAt(projectDir)))
263
302
  return { committed: false, error: "not a repo" };
264
303
  const msg = message.trim() || "Update project";
265
304
  let pathspec;
@@ -325,6 +364,35 @@ export class GitService {
325
364
  return {};
326
365
  }
327
366
  }
367
+ /**
368
+ * Uncommitted-file count for ONE directory's own repository.
369
+ *
370
+ * The companion to {@link dirtyCounts} for a project whose working directory
371
+ * lives outside `projectsRoot` (issue #206). That method answers the whole grid
372
+ * in a single `git status` over the store, which is why it is cheap — and also
373
+ * why it structurally cannot see a linked checkout: it buckets paths by their
374
+ * first segment relative to the store, and a directory outside it has no such
375
+ * segment. This one costs a subprocess, so callers use it only for the projects
376
+ * the cheap sweep genuinely cannot cover.
377
+ *
378
+ * Returns 0 for a directory that isn't a repo, matching "nothing to report".
379
+ */
380
+ async dirtyCountAt(dir) {
381
+ if (!(await this.isRepoAt(dir)))
382
+ return 0;
383
+ try {
384
+ const out = await this.git(dir, [
385
+ "status",
386
+ "--porcelain=v1",
387
+ "-z",
388
+ "--untracked-files=all",
389
+ ]);
390
+ return parsePorcelainZ(out).length;
391
+ }
392
+ catch {
393
+ return 0;
394
+ }
395
+ }
328
396
  /** Push the current branch to origin (sets upstream on first push). */
329
397
  async push() {
330
398
  if (!(await this.isRepo()))
@@ -308,8 +308,9 @@ export function buildManagementOps(ctx, params) {
308
308
  name: project.name,
309
309
  dir: project.dir,
310
310
  workingDir: project.workingDir,
311
- repoBacked: project.repoBacked === true,
311
+ managed: project.managed === true,
312
312
  ...(project.repo ? { repo: project.repo } : {}),
313
+ ...(project.path ? { path: project.path } : {}),
313
314
  agentRegistered,
314
315
  };
315
316
  },
@@ -66,11 +66,79 @@ export declare function isValidRepoUrl(url: string): boolean;
66
66
  */
67
67
  export declare function repoCheckoutName(repo: string): string;
68
68
  /**
69
- * Resolve a project's working directory (keeper cwd) from its metadata dir + repo
70
- * URL: the nested checkout for a repo-backed project, else the metadata dir
71
- * itself for a notebook project (issue #187).
69
+ * Whether `child` is `parent` itself or lives underneath it. Both are resolved
70
+ * first, so this compares real path strings rather than the caller's spelling
71
+ * (it does NOT follow symlinks — callers that care resolve them beforehand).
72
+ *
73
+ * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
74
+ * `/data/projects-old` a child of `/data/projects`.
75
+ */
76
+ export declare function isPathInside(child: string, parent: string): boolean;
77
+ /**
78
+ * Resolve a project's working directory (keeper cwd) from its metadata dir plus
79
+ * whichever backing field is set:
80
+ *
81
+ * - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
82
+ * verbatim, used in place with no copy. Applies on BOTH sides of the
83
+ * managed/unmanaged axis: an unmanaged project links a code checkout you
84
+ * already have, a managed one nominates where its notes should live.
85
+ * - **`repo`** (issue #187) — the nested checkout Paddock cloned.
86
+ * - **neither** — the metadata dir itself.
87
+ *
88
+ * `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
89
+ * `repo` alongside a path is an ACQUISITION hint (clone here if the path is
90
+ * missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
91
+ * hint for disaster recovery. It never moves the cwd off the nominated path.
92
+ */
93
+ export declare function workingDirFor(dir: string, repo?: string, linkedPath?: string): string;
94
+ /** The subset of `project.yaml` that decides a project's shape. */
95
+ export interface ProjectBacking {
96
+ managed?: boolean;
97
+ repo?: string;
98
+ path?: string;
99
+ }
100
+ /**
101
+ * Whether Paddock looks after this project's own files — the real axis, and the
102
+ * one that replaced the overloaded `repoBacked` boolean (issue #206).
103
+ *
104
+ * **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
105
+ * here. This is what "notebook" used to mean.
106
+ * **unmanaged** — the content is code that you or your agents source-control
107
+ * OUTSIDE Paddock. Paddock never writes project files into it.
108
+ *
109
+ * Whether a git repo sits behind it is an INDEPENDENT question (just which of
110
+ * `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
111
+ * 4: the base store is a plain directory and git is an optional layer.
112
+ *
113
+ * ## The default is derived, never constant
114
+ *
115
+ * `managed ?? !(repo || path)` — an absent key means "whatever this project's
116
+ * backing implies". That is load-bearing for the upgrade path: every existing
117
+ * `project.yaml` predates the key, and if absent meant `true`, every existing
118
+ * repo-backed project would flip to managed on upgrade and the sweeper would
119
+ * start writing `CLAUDE.md` into somebody's checkout. New projects write the key
120
+ * explicitly, so the derivation only ever has to serve legacy files.
121
+ */
122
+ export declare function isManaged(backing: ProjectBacking): boolean;
123
+ /**
124
+ * Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
125
+ * `CLAUDE.md`, the three files the sweeper writes.
126
+ *
127
+ * Content follows the working directory; application state does not. So:
128
+ *
129
+ * - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
130
+ * dir (unchanged). For a managed project with an external `path`, the curated
131
+ * files live out there with the content — an accepted consequence being that
132
+ * they then do NOT live in the Paddock data dir.
133
+ * - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
134
+ * else's tree and Paddock does not write project files into it; `OVERVIEW.md`
135
+ * and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
136
+ * `CLAUDE.md` is not curated at all (the checkout owns its own).
137
+ *
138
+ * `project.yaml` and `.chats/` are NOT content and never move — see
139
+ * {@link import("./projects.js").ProjectStore.dirFor}.
72
140
  */
73
- export declare function workingDirFor(dir: string, repo?: string): string;
141
+ export declare function contentDirFor(dir: string, backing: ProjectBacking): string;
74
142
  export declare class ProjectError extends Error {
75
143
  readonly code: "not_found" | "exists" | "invalid" | "not_directory";
76
144
  constructor(message: string, code: "not_found" | "exists" | "invalid" | "not_directory");
@@ -74,13 +74,85 @@ export function repoCheckoutName(repo) {
74
74
  return safe || "repo";
75
75
  }
76
76
  /**
77
- * Resolve a project's working directory (keeper cwd) from its metadata dir + repo
78
- * URL: the nested checkout for a repo-backed project, else the metadata dir
79
- * itself for a notebook project (issue #187).
77
+ * Whether `child` is `parent` itself or lives underneath it. Both are resolved
78
+ * first, so this compares real path strings rather than the caller's spelling
79
+ * (it does NOT follow symlinks — callers that care resolve them beforehand).
80
+ *
81
+ * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
82
+ * `/data/projects-old` a child of `/data/projects`.
83
+ */
84
+ export function isPathInside(child, parent) {
85
+ const c = path.resolve(child);
86
+ const p = path.resolve(parent);
87
+ return c === p || c.startsWith(p + path.sep);
88
+ }
89
+ /**
90
+ * Resolve a project's working directory (keeper cwd) from its metadata dir plus
91
+ * whichever backing field is set:
92
+ *
93
+ * - **`linkedPath`** (issue #206) — the cwd IS that directory, returned
94
+ * verbatim, used in place with no copy. Applies on BOTH sides of the
95
+ * managed/unmanaged axis: an unmanaged project links a code checkout you
96
+ * already have, a managed one nominates where its notes should live.
97
+ * - **`repo`** (issue #187) — the nested checkout Paddock cloned.
98
+ * - **neither** — the metadata dir itself.
99
+ *
100
+ * `linkedPath` WINS over `repo`, which is why the two are not mutually exclusive:
101
+ * `repo` alongside a path is an ACQUISITION hint (clone here if the path is
102
+ * missing) plus a remote-match for adoption (`adoptable.ts`, #659) and a re-clone
103
+ * hint for disaster recovery. It never moves the cwd off the nominated path.
80
104
  */
81
- export function workingDirFor(dir, repo) {
105
+ export function workingDirFor(dir, repo, linkedPath) {
106
+ if (linkedPath)
107
+ return linkedPath;
82
108
  return repo ? path.join(dir, repoCheckoutName(repo)) : dir;
83
109
  }
110
+ /**
111
+ * Whether Paddock looks after this project's own files — the real axis, and the
112
+ * one that replaced the overloaded `repoBacked` boolean (issue #206).
113
+ *
114
+ * **managed** — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md`
115
+ * here. This is what "notebook" used to mean.
116
+ * **unmanaged** — the content is code that you or your agents source-control
117
+ * OUTSIDE Paddock. Paddock never writes project files into it.
118
+ *
119
+ * Whether a git repo sits behind it is an INDEPENDENT question (just which of
120
+ * `path`/`repo` are set), not a type. See `docs/DESIGN-backing-store.md` §2 goal
121
+ * 4: the base store is a plain directory and git is an optional layer.
122
+ *
123
+ * ## The default is derived, never constant
124
+ *
125
+ * `managed ?? !(repo || path)` — an absent key means "whatever this project's
126
+ * backing implies". That is load-bearing for the upgrade path: every existing
127
+ * `project.yaml` predates the key, and if absent meant `true`, every existing
128
+ * repo-backed project would flip to managed on upgrade and the sweeper would
129
+ * start writing `CLAUDE.md` into somebody's checkout. New projects write the key
130
+ * explicitly, so the derivation only ever has to serve legacy files.
131
+ */
132
+ export function isManaged(backing) {
133
+ return backing.managed ?? !(backing.repo || backing.path);
134
+ }
135
+ /**
136
+ * Where a project's CURATED CONTENT lives — `OVERVIEW.md`, `CHANGELOG.md` and
137
+ * `CLAUDE.md`, the three files the sweeper writes.
138
+ *
139
+ * Content follows the working directory; application state does not. So:
140
+ *
141
+ * - **managed** ⇒ the working dir. For a classic notebook that IS the metadata
142
+ * dir (unchanged). For a managed project with an external `path`, the curated
143
+ * files live out there with the content — an accepted consequence being that
144
+ * they then do NOT live in the Paddock data dir.
145
+ * - **unmanaged** ⇒ the metadata dir, always. The working dir is somebody
146
+ * else's tree and Paddock does not write project files into it; `OVERVIEW.md`
147
+ * and `CHANGELOG.md` are still curated, just sidecarred (issue #187), and
148
+ * `CLAUDE.md` is not curated at all (the checkout owns its own).
149
+ *
150
+ * `project.yaml` and `.chats/` are NOT content and never move — see
151
+ * {@link import("./projects.js").ProjectStore.dirFor}.
152
+ */
153
+ export function contentDirFor(dir, backing) {
154
+ return isManaged(backing) ? workingDirFor(dir, backing.repo, backing.path) : dir;
155
+ }
84
156
  export class ProjectError extends Error {
85
157
  code;
86
158
  constructor(message, code) {
@@ -175,6 +175,61 @@ export interface ProjectYaml {
175
175
  * (like `slug`/`started`).
176
176
  */
177
177
  repo?: string;
178
+ /**
179
+ * Whether Paddock looks after this project's own files (issue #206).
180
+ *
181
+ * `true` — Paddock curates `CLAUDE.md` / `OVERVIEW.md` / `CHANGELOG.md` here;
182
+ * this is what "notebook" used to mean. `false` — the content is code that you
183
+ * or your agents source-control OUTSIDE Paddock, and Paddock never writes
184
+ * project files into it.
185
+ *
186
+ * This is the real axis. It replaced the derived `repoBacked` boolean, which
187
+ * was one flag doing four jobs and broke the moment a fifth backing shape
188
+ * appeared. Whether a git repo sits behind the project is INDEPENDENT of this
189
+ * and is not a type — it is just which of `path`/`repo` are set.
190
+ *
191
+ * **Optional on disk, and the default is DERIVED rather than constant:**
192
+ * `managed ?? !(repo || path)` (see
193
+ * {@link import("./project-paths.js").isManaged}). Every `project.yaml` written
194
+ * before this key existed relies on that: were an absent value to mean `true`,
195
+ * every existing repo-backed project would flip to managed on upgrade and the
196
+ * sweeper would begin writing `CLAUDE.md` into its checkout. `create()` always
197
+ * writes the key explicitly, so the derivation only ever serves legacy files.
198
+ *
199
+ * `managed: true` together with `repo` is REJECTED at creation rather than
200
+ * silently ignored — Paddock curating files into a repo it also clones is not a
201
+ * combination with a sensible meaning.
202
+ */
203
+ managed?: boolean;
204
+ /**
205
+ * Absolute path to the directory holding this project's content (issue #206).
206
+ *
207
+ * Applies on BOTH sides of the managed axis, which is what makes it a location
208
+ * rather than a type:
209
+ *
210
+ * - **unmanaged + `path`** — LINK a code checkout you already have
211
+ * (`~/Code/foo`), used in place with no copy: its real history, branches and
212
+ * remotes. Paddock writes nothing into it (no `.chats/`, no sidecar
213
+ * `.gitignore`, no `CLAUDE.md`); transcripts stay in `<slug>/.chats/` via
214
+ * `ensureProjectChats`'s two-argument split. Transcripts key on this cwd, so
215
+ * prior `claude` sessions run there by hand are offered for adoption, and
216
+ * `claude.transcripts: host` / per-directory `~/.claude.json` MCP servers
217
+ * finally mean something for a code project.
218
+ * - **managed + `path`** — nominate where your NOTES live. The curated trio
219
+ * (`OVERVIEW.md`/`CHANGELOG.md`/`CLAUDE.md`) follows the path, so those files
220
+ * end up outside the Paddock data dir.
221
+ *
222
+ * Either way `project.yaml` (the registry entry Paddock discovers projects by
223
+ * scanning for) and `.chats/` (Paddock's own state) stay in
224
+ * `<projectsRoot>/<slug>/`.
225
+ *
226
+ * Acquisition when the path does NOT exist: with `repo`, Paddock clones into it;
227
+ * for a managed project, Paddock creates it; otherwise the create is rejected.
228
+ *
229
+ * Set at creation and immutable thereafter (like `slug`/`started`/`repo`) — the
230
+ * cwd is baked into every transcript path, so moving it would strand history.
231
+ */
232
+ path?: string;
178
233
  /**
179
234
  * Scheduled chat sessions for this project (issue #265 / DD-2), keyed by a
180
235
  * stable schedule name. Each value is herdctl's `ScheduleSchema` shape (plus the
@@ -211,22 +266,46 @@ export interface Project extends ProjectYaml {
211
266
  * empty string means "Unsorted". */
212
267
  group: string;
213
268
  /**
214
- * Absolute path to the project's METADATA directory — the per-slug dir in the
215
- * instance data repo that holds `project.yaml`, `OVERVIEW.md`, `CHANGELOG.md`,
216
- * `.chats/`, and (for a notebook project) `CLAUDE.md`. For a NOTEBOOK project
217
- * this is also the keeper's cwd; for a REPO-BACKED project the cwd is
218
- * {@link workingDir} (the nested checkout) instead — see #187.
269
+ * Absolute path to the project's METADATA directory — the per-slug dir under
270
+ * `projectsRoot`. Always holds `project.yaml` (the registry entry Paddock
271
+ * discovers projects by scanning for, so it can never move) and `.chats/`
272
+ * (Paddock's own state, and what backup tooling points at).
273
+ *
274
+ * It ALSO holds the curated trio (`OVERVIEW.md`/`CHANGELOG.md`/`CLAUDE.md`)
275
+ * unless the project is managed with an external `path`, in which case those
276
+ * follow the content — see {@link contentDir}.
219
277
  */
220
278
  dir: string;
221
279
  /**
222
- * The keeper agent's working directory (cwd). For a notebook project this
223
- * equals {@link dir}; for a repo-backed project it's the nested checkout
224
- * (`<dir>/<repo-name>`), so the repo's own `CLAUDE.md` + git tooling apply
225
- * (issue #187). Always concrete in the DTO.
280
+ * The keeper agent's working directory (cwd): {@link ProjectYaml.path} verbatim
281
+ * when set, else the nested checkout for a `repo`-backed project (#187), else
282
+ * {@link dir}. Always concrete in the DTO.
283
+ *
284
+ * This — not {@link dir} — is the directory the git surface reports on, which
285
+ * is what issue #597 was: the Changes tab read `dir`, so it showed the metadata
286
+ * directory rather than the code the project is actually about.
226
287
  */
227
288
  workingDir: string;
228
- /** Whether this project is backed by an external git repo (issue #187). */
229
- repoBacked: boolean;
289
+ /**
290
+ * Where the sweeper's curated trio lives — {@link workingDir} for a managed
291
+ * project, {@link dir} for an unmanaged one (issue #206). Always concrete.
292
+ *
293
+ * Equals {@link dir} in every pre-#206 shape; it differs only for a managed
294
+ * project with an external `path`.
295
+ */
296
+ contentDir: string;
297
+ /**
298
+ * Whether Paddock curates this project's own files — ALWAYS concrete in the
299
+ * DTO, derived via `managed ?? !(repo || path)` so legacy files keep their
300
+ * meaning (issue #206).
301
+ *
302
+ * This replaced `repoBacked`, which conflated four separate questions into one
303
+ * boolean and broke as soon as a fifth backing shape appeared. Each consumer
304
+ * now takes the fact it actually needs: the sweeper's `CLAUDE.md` suppression
305
+ * and the promote guard key on `managed`; the git surface keys on whether
306
+ * {@link workingDir} is a git working tree, asked per directory.
307
+ */
308
+ managed: boolean;
230
309
  /** Whether OVERVIEW.md exists in the project dir (sweep-curated context). */
231
310
  hasOverview: boolean;
232
311
  /** Always present in the DTO (defaults to []). */
@@ -259,6 +338,25 @@ export interface CreateProjectInput {
259
338
  * a notebook project (the classic type).
260
339
  */
261
340
  repo?: string;
341
+ /**
342
+ * Whether Paddock should curate this project's own files (issue #206). Absent ⇒
343
+ * derived as `!(repo || path)`, so a plain create is managed and a create that
344
+ * names a backing is not. `managed: true` together with `repo` is REJECTED.
345
+ */
346
+ managed?: boolean;
347
+ /**
348
+ * Absolute path to the directory this project's content lives in (issue #206).
349
+ *
350
+ * Validated at create time: absolute, and outside `projectsRoot` / the data dir
351
+ * / every other project's working directory. Immutable thereafter. If it does
352
+ * not exist, Paddock clones `repo` into it when one is given, creates it for a
353
+ * managed project, and otherwise rejects the create.
354
+ *
355
+ * There is deliberately NO requirement that it be a git repository — Paddock
356
+ * probes for git and lights up the git features when it finds one, exactly as
357
+ * it already does for the projects root.
358
+ */
359
+ path?: string;
262
360
  }
263
361
  /** Partial metadata update (slug + started are immutable). */
264
362
  export type UpdateProjectInput = Partial<Pick<ProjectYaml, "name" | "status" | "domain" | "group" | "visibility" | "summary" | "links" | "model" | "permissionMode" | "maxTurns" | "docker">> & {