@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.
- package/package.json +1 -1
- package/packages/server/dist/adoptable.d.ts +18 -0
- package/packages/server/dist/adoptable.js +1 -1
- package/packages/server/dist/app.js +3 -1
- package/packages/server/dist/claude-home.js +9 -1
- package/packages/server/dist/cli/import-chats.js +1 -1
- package/packages/server/dist/git.d.ts +44 -1
- package/packages/server/dist/git.js +79 -11
- package/packages/server/dist/management-ops.js +42 -2
- package/packages/server/dist/management-policy.d.ts +4 -3
- package/packages/server/dist/management-policy.js +5 -2
- package/packages/server/dist/project-paths.d.ts +72 -4
- package/packages/server/dist/project-paths.js +76 -4
- package/packages/server/dist/project-types.d.ts +109 -11
- package/packages/server/dist/projects.d.ts +170 -3
- package/packages/server/dist/projects.js +477 -66
- package/packages/server/dist/routes/git.js +6 -3
- package/packages/server/dist/routes/projects.js +16 -2
- package/packages/server/dist/self-mcp-descriptions.d.ts +1 -0
- package/packages/server/dist/self-mcp-descriptions.js +14 -1
- package/packages/server/dist/self-mcp-types.d.ts +32 -8
- package/packages/server/dist/self-mcp-write.d.ts +21 -0
- package/packages/server/dist/self-mcp-write.js +44 -0
- package/packages/server/dist/self-mcp.d.ts +7 -6
- package/packages/server/dist/self-mcp.js +36 -10
- package/packages/server/dist/sweep.js +10 -5
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CCk2hVT9.js → InstanceConfigPage-DcprTH2W.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-B6gST_0U.js → MarkdownRenderer-Ri1LwAUN.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-BCmKT4mf.js → ProjectRedirect-Dze5H8Xy.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-CCN98MWu.js → ProjectView-PrA8purE.js} +9 -9
- package/packages/web/dist/assets/{ProjectsGrid-CYzuN_yD.js → ProjectsGrid-BidsM7ah.js} +1 -1
- package/packages/web/dist/assets/{arc-BxvtMCZQ.js → arc-eQ3ryE9c.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Bp6ffJ_u.js → architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BIY2X8nW.js → blockDiagram-GPEHLZMM-DBX5pma_.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Br9stEJS.js → c4Diagram-AAUBKEIU-CnWrtTpa.js} +1 -1
- package/packages/web/dist/assets/channel-B4sED4OP.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-Djc7uqMV.js → chunk-2J33WTMH-2qmLvtsr.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-yEVMLksS.js → chunk-4BX2VUAB-Dn-LUDMV.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-tJaEdPZg.js → chunk-55IACEB6-DB0augUY.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-bWEqh672.js → chunk-727SXJPM-BPQQM0i8.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-B8U9fB-T.js → chunk-AQP2D5EJ-DOhfwA-x.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BeEITYig.js → chunk-FMBD7UC4-CatHHdFD.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-L2rpLaoK.js → chunk-ND2GUHAM-B645aAlG.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-qqzPMZL_.js → chunk-QZHKN3VN-D1fAaPSZ.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +2 -0
- package/packages/web/dist/assets/{core-CSaNbmLO.js → core-B4t7D8Om.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BRF7Nech.js → cose-bilkent-S5V4N54A-BLdKDAwa.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-D10rJWOR.js → dagre-BM42HDAG-CbTbTVKv.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DGjT5mAO.js → diagram-2AECGRRQ-CorSS29_.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-DaH3bxHh.js → diagram-5GNKFQAL-PLKivts0.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-QrLW9Wn6.js → diagram-KO2AKTUF-CUSIBacZ.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BX15wgYN.js → diagram-LMA3HP47-CZ69mAZs.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DXQ_p63s.js → diagram-OG6HWLK6-Cmf042Rh.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C_ioRruE.js → erDiagram-TEJ5UH35-DyIKGj6e.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-7d-xLXLM.js → flowDiagram-I6XJVG4X-B79pLdLD.js} +1 -1
- package/packages/web/dist/assets/{format-BSDZezwD.js → format-C5nhFL8q.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DZYx5xcj.js → ganttDiagram-6RSMTGT7-BFTR_oNA.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-dimo-osm.js → gitGraphDiagram-PVQCEYII-DdGUlJxS.js} +1 -1
- package/packages/web/dist/assets/{index-CUnFKMga.js → index-DVSsta3B.js} +14 -14
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BzApY25x.js → infoDiagram-5YYISTIA-B80FyUOy.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DtX47_9p.js → ishikawaDiagram-YF4QCWOH-_wRlphn9.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C7fITKg1.js → journeyDiagram-JHISSGLW-qu4yvjW8.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BOrWLf_W.js → kanban-definition-UN3LZRKU-D2DEUp2-.js} +1 -1
- package/packages/web/dist/assets/{linear-DuyAaBu6.js → linear-D-CLRUEW.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-BQlzuVvO.js → mermaid.core-DdihBJl4.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CmP748B5.js → mindmap-definition-RKZ34NQL-BEh_TDBf.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-CRd5Pdfq.js → pieDiagram-4H26LBE5-BbrmLVaZ.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DpJm0Yw_.js → quadrantDiagram-W4KKPZXB-DfVuygAv.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-B0o2PRMC.js → requirementDiagram-4Y6WPE33-BHp2QpZt.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-CBi3Ek3v.js → sankeyDiagram-5OEKKPKP-D2G6lRMY.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C1stxfQg.js → sequenceDiagram-3UESZ5HK-Dj-jzR-c.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-lCkiETWK.js → stateDiagram-AJRCARHV-C5D6bT13.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D3DkWJMC.js → timeline-definition-PNZ67QCA-DE4gGjhP.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CO2-yC7K.js → vennDiagram-CIIHVFJN-C9QNrl92.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dfjp0kqX.js → wardley-L42UT6IY-U2fSGy-d.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CIl0OPwS.js → wardleyDiagram-YWT4CUSO-B9RhObdH.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-CuvlQuup.js → xychartDiagram-2RQKCTM6-BnlPrtQ9.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/channel-CMdgNeFH.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +0 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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(
|
|
83
|
-
|
|
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
|
-
|
|
126
|
+
return false;
|
|
90
127
|
}
|
|
91
|
-
return this.repoFlag;
|
|
92
128
|
}
|
|
93
|
-
/** Force the next
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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`
|
|
85
|
-
*
|
|
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`
|
|
68
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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) {
|