@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.63.0",
3
+ "version": "0.65.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,7 +308,39 @@ 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
+ ...(project.repo ? { repo: project.repo } : {}),
313
+ ...(project.path ? { path: project.path } : {}),
314
+ agentRegistered,
315
+ };
316
+ },
317
+ // Promotion (#470), sharing create's gate. Same discipline: the SAME two calls,
318
+ // in the same order, that `POST /api/projects/:slug/promote` makes — store
319
+ // promote, then agent re-registration. Every guard (already unmanaged, the
320
+ // root workspace, an existing checkout dir, a bad URL) and the rollback to an
321
+ // untouched notebook on clone failure live in `ProjectStore.promote` (#213).
322
+ promoteProject: async (projectSlug, repo) => {
323
+ const project = await deps.projects.promote(projectSlug, repo);
324
+ // Non-fatal, as in the route — but it matters more than it does for create:
325
+ // re-registering is what re-symlinks the NEW working dir at the project's
326
+ // existing `.chats/` store, which is what keeps its chats listed + resumable.
327
+ let agentRegistered = true;
328
+ try {
329
+ await deps.herdctl.ensureProjectAgent(project);
330
+ }
331
+ catch {
332
+ agentRegistered = false;
333
+ }
334
+ return {
335
+ slug: project.slug,
336
+ name: project.name,
337
+ dir: project.dir,
338
+ workingDir: project.workingDir,
339
+ // #206 replaced `repoBacked` with the `managed` axis. Promotion always
340
+ // lands on `managed: false` — that IS what promoting means now — but read
341
+ // it off the project rather than hard-coding, so this reports what the
342
+ // store actually did.
343
+ managed: project.managed,
312
344
  ...(project.repo ? { repo: project.repo } : {}),
313
345
  agentRegistered,
314
346
  };
@@ -404,12 +436,20 @@ export function enforceManagementPolicy(ops, principal) {
404
436
  const write = {
405
437
  currentProjectSlug: w.currentProjectSlug,
406
438
  currentSessionId: w.currentSessionId,
439
+ // The project block is offered when EITHER of its verbs is granted; the
440
+ // per-tool filter (`managementToolFilter`) is what hides the other one, so a
441
+ // client scoped to just one of them isn't denied both.
442
+ projectsMcpEnabled: w.projectsMcpEnabled &&
443
+ (isOperationAllowed(scope, "create_project") || isOperationAllowed(scope, "promote_project")),
407
444
  // Instance-level, so there is no project to scope-check — only the verb.
408
- projectsMcpEnabled: w.projectsMcpEnabled && isOperationAllowed(scope, "create_project"),
409
445
  createProject: (input) => {
410
446
  assertOperation(principal, "create_project");
411
447
  return w.createProject(input);
412
448
  },
449
+ // Promotion DOES name an existing project, so unlike create it is scope-checked
450
+ // against `projects` too — a client confined to one project must not restructure
451
+ // another.
452
+ promoteProject: (projectSlug, repo) => guard("promote_project", projectSlug, () => w.promoteProject(projectSlug, repo)),
413
453
  createChat: (projectSlug, prompt, o) => guard("create_chat", projectSlug, () => w.createChat(projectSlug, prompt, o)),
414
454
  // NOTE: `fork_chat_batch` is a convenience wrapper that calls this same
415
455
  // callback N times, so it is gated on `fork_chat` here AND on
@@ -66,7 +66,7 @@ export interface ManagementPrincipal {
66
66
  /** Read-only operations: no state change, no turn started. */
67
67
  export declare const READ_OPERATIONS: readonly ["list_projects", "list_chats", "read_chat"];
68
68
  /** Chat write operations. The first four START TURNS (see the RCE note above). */
69
- export declare const WRITE_OPERATIONS: readonly ["create_project", "create_chat", "fork_chat", "send_message", "fork_chat_batch", "archive_chat", "unarchive_chat"];
69
+ export declare const WRITE_OPERATIONS: readonly ["create_project", "promote_project", "create_chat", "fork_chat", "send_message", "fork_chat_batch", "archive_chat", "unarchive_chat"];
70
70
  /** Unified trigger operations (Epic T / T3). `list_triggers` is the read one. */
71
71
  export declare const TRIGGER_OPERATIONS: readonly ["list_triggers", "set_trigger", "remove_trigger", "run_trigger"];
72
72
  /** Every operation the management surface knows about. */
@@ -81,8 +81,9 @@ export declare const ALL_OPERATIONS: readonly string[];
81
81
  /**
82
82
  * Operations that grant code execution on the host, and so define the risk class
83
83
  * a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
84
- * `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
85
- * creates instance-level state, which belongs in the same warning.
84
+ * `create_project`/`promote_project` start no turn, but they clone a
85
+ * CALLER-SUPPLIED git URL and create (or restructure) instance-level state, which
86
+ * belongs in the same warning.
86
87
  */
87
88
  export declare const HIGH_RISK_OPERATIONS: readonly string[];
88
89
  export declare const TURN_SPAWNING_OPERATIONS: readonly string[];
@@ -34,6 +34,7 @@ export const READ_OPERATIONS = ["list_projects", "list_chats", "read_chat"];
34
34
  /** Chat write operations. The first four START TURNS (see the RCE note above). */
35
35
  export const WRITE_OPERATIONS = [
36
36
  "create_project",
37
+ "promote_project",
37
38
  "create_chat",
38
39
  "fork_chat",
39
40
  "send_message",
@@ -64,11 +65,13 @@ export const ALL_OPERATIONS = [
64
65
  /**
65
66
  * Operations that grant code execution on the host, and so define the risk class
66
67
  * a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
67
- * `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
68
- * creates instance-level state, which belongs in the same warning.
68
+ * `create_project`/`promote_project` start no turn, but they clone a
69
+ * CALLER-SUPPLIED git URL and create (or restructure) instance-level state, which
70
+ * belongs in the same warning.
69
71
  */
70
72
  export const HIGH_RISK_OPERATIONS = [
71
73
  "create_project",
74
+ "promote_project",
72
75
  "create_chat",
73
76
  "fork_chat",
74
77
  "fork_chat_batch",
@@ -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) {