@edspencer/paddock 0.0.1 → 0.57.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/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectStore — the project layer.
|
|
3
|
+
*
|
|
4
|
+
* A "project" is a directory under `projectsRoot` containing:
|
|
5
|
+
* - project.yaml (metadata; schema below)
|
|
6
|
+
* - CHANGELOG.md (reverse-chron, curator-appended + hand-edited)
|
|
7
|
+
* - freeform .md / files
|
|
8
|
+
*
|
|
9
|
+
* This mirrors the documented standard at ~/herds/personal/projects/
|
|
10
|
+
* (see _template/ and README.md there). The directory name == slug.
|
|
11
|
+
*
|
|
12
|
+
* The slug-as-directory IS the link to Claude Code sessions: Claude stores
|
|
13
|
+
* transcripts under ~/.claude/projects/<cwd-with-slashes-as-dashes>/, so the
|
|
14
|
+
* keeper agent's working_directory (= the project dir) ties sessions to the
|
|
15
|
+
* project with no extra tagging.
|
|
16
|
+
*
|
|
17
|
+
* The type surface (project.yaml schema + DTOs), the MIME/file-kind helpers, and
|
|
18
|
+
* the pure path/slug helpers were extracted into sibling modules (issue #403) —
|
|
19
|
+
* `project-types.ts`, `project-mime.ts`, `project-paths.ts` — and are re-exported
|
|
20
|
+
* below so every existing `import { ... } from "./projects.js"` keeps resolving.
|
|
21
|
+
*/
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import YAML from "yaml";
|
|
25
|
+
import { DEFAULT_MODEL, DEFAULT_PERMISSION_MODE, DEFAULT_MAX_TURNS, DEFAULT_DOCKER, isKnownModel, } from "./models.js";
|
|
26
|
+
import { cloneRepo } from "./git.js";
|
|
27
|
+
import { sanitizeSchedules } from "./schedule-config.js";
|
|
28
|
+
import { sanitizeHooks } from "./hook-config.js";
|
|
29
|
+
import { sanitizeTrigger, sanitizeTriggers, isValidTriggerName, } from "./trigger-config.js";
|
|
30
|
+
import { sanitizeRecoveryOverride } from "./recovery-config.js";
|
|
31
|
+
import { sanitizeCurationOverride } from "./curation-config.js";
|
|
32
|
+
import { sanitizeAttachmentsOverride } from "./attachments-config.js";
|
|
33
|
+
// --- re-export barrels (issue #403) ------------------------------------------
|
|
34
|
+
// The moved symbols are re-exported from here so external importers keep finding
|
|
35
|
+
// them at `./projects.js` — the same one-place-to-import discipline as before.
|
|
36
|
+
import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
|
|
37
|
+
export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
|
|
38
|
+
import { SLUG_RE, ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
|
|
39
|
+
export { ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
|
|
40
|
+
import { normalizeLinks, } from "./project-types.js";
|
|
41
|
+
import * as projectFiles from "./project-files.js";
|
|
42
|
+
// Barrel: re-export all type declarations + normalizeLinks + the config-type
|
|
43
|
+
// re-exports (PaddockSchedule/PaddockHook/PaddockTrigger/RecoveryOverride/
|
|
44
|
+
// AttachmentsOverride) that project-types carries forward.
|
|
45
|
+
export * from "./project-types.js";
|
|
46
|
+
const PROJECT_FILE = "project.yaml";
|
|
47
|
+
const CHANGELOG_FILE = "CHANGELOG.md";
|
|
48
|
+
const OVERVIEW_FILE = "OVERVIEW.md";
|
|
49
|
+
const CLAUDE_FILE = "CLAUDE.md";
|
|
50
|
+
/** Heading under which the sweeper appends newly-discovered durable facts. */
|
|
51
|
+
const CLAUDE_CURATED_HEADING = "## Curated notes";
|
|
52
|
+
/** Filename of the sidecar `.gitignore` that keeps a nested checkout out of the data repo. */
|
|
53
|
+
const GITIGNORE_FILE = ".gitignore";
|
|
54
|
+
/**
|
|
55
|
+
* Sanitise a per-project offered-models override (issue #457 Step 2): keep only
|
|
56
|
+
* known catalog ids, trimmed and de-duped, order-preserving. Returns `undefined`
|
|
57
|
+
* when the input isn't a usable non-empty list (absent / not an array / all
|
|
58
|
+
* unknown), so a project with no override — or a corrupt hand-edit — cleanly
|
|
59
|
+
* inherits the instance list rather than persisting/exposing a bad value. The
|
|
60
|
+
* subset-of-instance-list check is enforced separately at PATCH time (the store
|
|
61
|
+
* has no `cfg` handle); this is the shape/known-id backstop.
|
|
62
|
+
*/
|
|
63
|
+
function sanitizeModelsOverride(raw) {
|
|
64
|
+
if (!Array.isArray(raw))
|
|
65
|
+
return undefined;
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const ids = [];
|
|
68
|
+
for (const item of raw) {
|
|
69
|
+
if (typeof item !== "string")
|
|
70
|
+
continue;
|
|
71
|
+
const id = item.trim();
|
|
72
|
+
if (!id || seen.has(id) || !isKnownModel(id))
|
|
73
|
+
continue;
|
|
74
|
+
seen.add(id);
|
|
75
|
+
ids.push(id);
|
|
76
|
+
}
|
|
77
|
+
return ids.length > 0 ? ids : undefined;
|
|
78
|
+
}
|
|
79
|
+
export class ProjectStore {
|
|
80
|
+
root;
|
|
81
|
+
constructor(root) {
|
|
82
|
+
this.root = root;
|
|
83
|
+
}
|
|
84
|
+
/** Ensure the projects root exists. Call once at startup. */
|
|
85
|
+
async init() {
|
|
86
|
+
await fs.mkdir(this.root, { recursive: true });
|
|
87
|
+
// The root workspace always exists, so its transcripts always need ignoring
|
|
88
|
+
// — this is housekeeping, not a feature gate. It writes at most once.
|
|
89
|
+
await this.ensureRootGitignore();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The on-disk directory backing a workspace key.
|
|
93
|
+
*
|
|
94
|
+
* A key is a path RELATIVE to `projectsRoot`, and the root workspace's key is
|
|
95
|
+
* `""` — so `path.join` resolves it with no special case at all. The previous
|
|
96
|
+
* design needed a branch here for a reserved slug, and that branch had to be
|
|
97
|
+
* duplicated in `project-files.ts` (where it was missed, 404ing every root
|
|
98
|
+
* file route). There is now no branch to forget.
|
|
99
|
+
*/
|
|
100
|
+
dirFor(key) {
|
|
101
|
+
return path.join(this.root, key);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Keep the root's `.chats/` out of the backing repo. Root chats are ordinary
|
|
105
|
+
* chats and get the same treatment project chats already do
|
|
106
|
+
* ({@link ensureSidecarGitignore}) — transcripts are append-heavy JSONL and are
|
|
107
|
+
* not tracked anywhere today.
|
|
108
|
+
*/
|
|
109
|
+
async ensureRootGitignore() {
|
|
110
|
+
const file = path.join(this.root, GITIGNORE_FILE);
|
|
111
|
+
let existing = "";
|
|
112
|
+
try {
|
|
113
|
+
existing = await fs.readFile(file, "utf8");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* no .gitignore yet */
|
|
117
|
+
}
|
|
118
|
+
const have = new Set(existing.split("\n").map((l) => l.trim()));
|
|
119
|
+
// Tolerate the equivalent forms an instance-wide .gitignore may already use.
|
|
120
|
+
if (have.has("/.chats/") || have.has(".chats/") || have.has(".chats"))
|
|
121
|
+
return;
|
|
122
|
+
const body = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
|
|
123
|
+
await fs.writeFile(file, `${body}${existing ? "" : "# Paddock instance data repo.\n"}` +
|
|
124
|
+
`# Root chat transcripts (issue #516) — not tracked, like every project's.\n/.chats/\n`, "utf8");
|
|
125
|
+
}
|
|
126
|
+
/** List all projects, newest-updated first. Skips `_`-prefixed dirs. */
|
|
127
|
+
async list() {
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = await fs.readdir(this.root, { withFileTypes: true });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code === "ENOENT")
|
|
134
|
+
return [];
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
const projects = [];
|
|
138
|
+
for (const e of entries) {
|
|
139
|
+
if (!e.isDirectory())
|
|
140
|
+
continue;
|
|
141
|
+
if (e.name.startsWith("_") || e.name.startsWith("."))
|
|
142
|
+
continue;
|
|
143
|
+
const p = await this.readSafe(e.name);
|
|
144
|
+
if (p)
|
|
145
|
+
projects.push(p);
|
|
146
|
+
}
|
|
147
|
+
projects.sort((a, b) => (a.updated < b.updated ? 1 : a.updated > b.updated ? -1 : 0));
|
|
148
|
+
return projects;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get one workspace by key. Throws `ProjectError("not_found")` if missing.
|
|
152
|
+
*
|
|
153
|
+
* The root key (`""`) always resolves — the instance's own directory is always
|
|
154
|
+
* there — so this never throws for the root.
|
|
155
|
+
*/
|
|
156
|
+
async get(slug) {
|
|
157
|
+
const p = await this.readSafe(slug);
|
|
158
|
+
if (!p)
|
|
159
|
+
throw new ProjectError(`Project not found: ${slug}`, "not_found");
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether a workspace exists. For a project that means a `project.yaml`
|
|
164
|
+
* record; the root workspace always exists, record or not.
|
|
165
|
+
*/
|
|
166
|
+
async exists(slug) {
|
|
167
|
+
if (isRootKey(slug))
|
|
168
|
+
return true;
|
|
169
|
+
try {
|
|
170
|
+
await fs.access(path.join(this.dirFor(slug), PROJECT_FILE));
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Create a project: mkdir + write project.yaml (from template) + seed
|
|
179
|
+
* CHANGELOG.md. Idempotency: throws ProjectError("exists") if the slug
|
|
180
|
+
* directory already holds a project.yaml.
|
|
181
|
+
*/
|
|
182
|
+
async create(input) {
|
|
183
|
+
const name = input.name?.trim();
|
|
184
|
+
if (!name)
|
|
185
|
+
throw new ProjectError("Project name is required", "invalid");
|
|
186
|
+
const slug = (input.slug?.trim() || slugify(name));
|
|
187
|
+
if (!SLUG_RE.test(slug)) {
|
|
188
|
+
throw new ProjectError(`Invalid slug "${slug}" (must be kebab-case: a-z, 0-9, hyphens)`, "invalid");
|
|
189
|
+
}
|
|
190
|
+
if (await this.exists(slug)) {
|
|
191
|
+
throw new ProjectError(`Project already exists: ${slug}`, "exists");
|
|
192
|
+
}
|
|
193
|
+
// Repo-backed project (issue #187): validate the URL up front so a bad value
|
|
194
|
+
// never reaches `git clone` and the project isn't half-created.
|
|
195
|
+
const repo = input.repo?.trim() || undefined;
|
|
196
|
+
if (repo && !isValidRepoUrl(repo)) {
|
|
197
|
+
throw new ProjectError(`Invalid repo URL: ${repo}`, "invalid");
|
|
198
|
+
}
|
|
199
|
+
const now = today();
|
|
200
|
+
const yaml = {
|
|
201
|
+
name,
|
|
202
|
+
slug,
|
|
203
|
+
status: input.status ?? "active",
|
|
204
|
+
domain: input.domain ?? [],
|
|
205
|
+
// Keep `group` off the yaml when empty so unsorted projects round-trip
|
|
206
|
+
// without a noisy `group: ""` line (mirrors the optional `model` handling).
|
|
207
|
+
...(input.group?.trim() ? { group: input.group.trim().toLowerCase() } : {}),
|
|
208
|
+
visibility: input.visibility ?? "public",
|
|
209
|
+
started: now,
|
|
210
|
+
updated: now,
|
|
211
|
+
summary: input.summary ?? "",
|
|
212
|
+
links: input.links ?? [],
|
|
213
|
+
pinned: [],
|
|
214
|
+
// Carry `repo` only when repo-backed (same round-trip discipline as model).
|
|
215
|
+
...(repo ? { repo } : {}),
|
|
216
|
+
};
|
|
217
|
+
const dir = this.dirFor(slug);
|
|
218
|
+
await fs.mkdir(dir, { recursive: true });
|
|
219
|
+
// For a repo-backed project, clone the external repo into the nested checkout
|
|
220
|
+
// BEFORE writing any metadata, so a clone failure rolls the whole thing back
|
|
221
|
+
// (rm the freshly-made dir) rather than leaving a broken project.yaml behind.
|
|
222
|
+
if (repo) {
|
|
223
|
+
const checkoutName = repoCheckoutName(repo);
|
|
224
|
+
const checkoutDir = path.join(dir, checkoutName);
|
|
225
|
+
try {
|
|
226
|
+
await cloneRepo(repo, checkoutDir);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
230
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
231
|
+
}
|
|
232
|
+
// Keep the nested checkout (a full git repo) OUT of the instance data repo:
|
|
233
|
+
// a sidecar `.gitignore` ignores the checkout dir (git-in-git per #187's
|
|
234
|
+
// option A). `.chats/` (our transcript store) is likewise data-repo noise.
|
|
235
|
+
await this.ensureSidecarGitignore(dir, checkoutName);
|
|
236
|
+
}
|
|
237
|
+
await this.writeYaml(slug, yaml);
|
|
238
|
+
await fs.writeFile(path.join(dir, CHANGELOG_FILE), [
|
|
239
|
+
`# Changelog — ${name}`,
|
|
240
|
+
"",
|
|
241
|
+
"<!--",
|
|
242
|
+
"Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
|
|
243
|
+
"-->",
|
|
244
|
+
"",
|
|
245
|
+
`## ${now}`,
|
|
246
|
+
"- Project opened.",
|
|
247
|
+
"",
|
|
248
|
+
].join("\n"), "utf8");
|
|
249
|
+
// Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a NOTEBOOK
|
|
250
|
+
// project. A repo-backed project defers to the repo's OWN CLAUDE.md (loaded
|
|
251
|
+
// natively via the cwd walk-up from the checkout); the sweeper must never
|
|
252
|
+
// touch that upstream-owned file, so we don't seed a competing one (#187).
|
|
253
|
+
if (!repo) {
|
|
254
|
+
await fs.writeFile(path.join(dir, CLAUDE_FILE), claudeTemplate(name, yaml.summary), "utf8");
|
|
255
|
+
}
|
|
256
|
+
// No OVERVIEW.md at creation — the first sweep writes it.
|
|
257
|
+
return this.toDto(dir, yaml, false);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Promote an existing NOTEBOOK project into a REPO-BACKED one IN PLACE (issue
|
|
261
|
+
* #213), preserving its chats + sidecar metadata. This relaxes #187's create-time
|
|
262
|
+
* `repo` immutability on this ONE path: an existing project (a subdir of the data
|
|
263
|
+
* repo) gains an external git repo as its keeper's working directory.
|
|
264
|
+
*
|
|
265
|
+
* What it does (mirrors `create()`'s repo-backed branch, but non-destructively):
|
|
266
|
+
* 1. Clone the repo into the nested checkout `<dir>/<repo-name>/` — FIRST, so a
|
|
267
|
+
* clone failure rolls back (rm just the checkout) and leaves the notebook
|
|
268
|
+
* wholly intact (project.yaml, `.chats/`, OVERVIEW/CHANGELOG untouched).
|
|
269
|
+
* 2. Write the sidecar `.gitignore` (`/<repo-name>/` + `/.chats/`) so the
|
|
270
|
+
* checkout + transcript store stay out of the data repo.
|
|
271
|
+
* 3. Set `repo:` in project.yaml → the DTO flips to repo-backed and the keeper's
|
|
272
|
+
* cwd becomes the checkout ({@link workingDirFor}).
|
|
273
|
+
* 4. Drop the sweeper-owned per-project `CLAUDE.md`: a repo-backed project defers
|
|
274
|
+
* to the repo's OWN `CLAUDE.md` (loaded natively from the checkout). Leaving
|
|
275
|
+
* the notebook's would leak into the checkout's cwd walk-up (the metadata dir
|
|
276
|
+
* is an ancestor of the nested checkout) — so it's removed (it survives in the
|
|
277
|
+
* data repo's git history).
|
|
278
|
+
*
|
|
279
|
+
* The existing chats need NO transcript surgery: they already live in `<dir>/.chats/`;
|
|
280
|
+
* the caller's {@link import("./herdctl.js").HerdctlService.ensureProjectAgent} re-runs
|
|
281
|
+
* `ensureProjectChats(newWorkingDir, dir)` which re-symlinks the new cwd's encoded
|
|
282
|
+
* transcript path at that same `.chats/` store, so every chat stays listed + resumable
|
|
283
|
+
* (issue #213 open-question #1, resolved: Claude Code tolerates recorded-cwd ≠ process-cwd).
|
|
284
|
+
*
|
|
285
|
+
* Guards: throws `ProjectError("invalid")` for a not-yet-notebook (already
|
|
286
|
+
* repo-backed) project or a bad URL, and `ProjectError("exists")` if a
|
|
287
|
+
* `<repo-name>/` directory is already present (never clobber existing files).
|
|
288
|
+
*/
|
|
289
|
+
async promote(slug, repoUrl) {
|
|
290
|
+
// The root workspace IS `projectsRoot`, already the instance's own backing
|
|
291
|
+
// repo — cloning a second repo inside it is never what's meant.
|
|
292
|
+
if (isRootKey(slug)) {
|
|
293
|
+
throw new ProjectError("The root workspace cannot be repo-backed", "invalid");
|
|
294
|
+
}
|
|
295
|
+
const current = await this.get(slug); // throws not_found
|
|
296
|
+
if (current.repoBacked) {
|
|
297
|
+
throw new ProjectError(`Project is already repo-backed: ${slug}`, "invalid");
|
|
298
|
+
}
|
|
299
|
+
const repo = repoUrl?.trim();
|
|
300
|
+
if (!repo || !isValidRepoUrl(repo)) {
|
|
301
|
+
throw new ProjectError(`Invalid repo URL: ${repoUrl}`, "invalid");
|
|
302
|
+
}
|
|
303
|
+
const dir = current.dir;
|
|
304
|
+
const checkoutName = repoCheckoutName(repo);
|
|
305
|
+
const checkoutDir = path.join(dir, checkoutName);
|
|
306
|
+
// Never clobber an existing dir of that name (e.g. a stray checkout or a real
|
|
307
|
+
// subdirectory of the notebook) — refuse before cloning.
|
|
308
|
+
if (await fs
|
|
309
|
+
.access(checkoutDir)
|
|
310
|
+
.then(() => true)
|
|
311
|
+
.catch(() => false)) {
|
|
312
|
+
throw new ProjectError(`A "${checkoutName}" directory already exists in ${slug}; refusing to overwrite`, "exists");
|
|
313
|
+
}
|
|
314
|
+
// Clone FIRST so a failure rolls back to a clean notebook (rm the checkout only,
|
|
315
|
+
// never the project dir + its chats). Mirrors create()'s rollback discipline.
|
|
316
|
+
try {
|
|
317
|
+
await cloneRepo(repo, checkoutDir);
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
|
|
321
|
+
throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
|
|
322
|
+
}
|
|
323
|
+
// `writeYaml` is the atomic COMMIT point: until it succeeds the notebook is
|
|
324
|
+
// byte-identical to before (only the checkout dir exists), so a failure here
|
|
325
|
+
// rolls back by removing JUST the checkout — nothing else has been mutated.
|
|
326
|
+
// We deliberately DON'T touch the sidecar `.gitignore` or the notebook's
|
|
327
|
+
// CLAUDE.md until after the commit, so a rare `writeYaml` failure can't leave a
|
|
328
|
+
// botched promote's notebook altered (e.g. a `.gitignore` that now ignores
|
|
329
|
+
// `/.chats/`). (Warren #370.)
|
|
330
|
+
const next = {
|
|
331
|
+
...this.stripDto(current),
|
|
332
|
+
repo,
|
|
333
|
+
updated: today(),
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
await this.writeYaml(slug, next);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
// Post-commit finalization — the project is now validly repo-backed; these
|
|
343
|
+
// steps must NOT roll the promotion back (they only tidy up), so they're
|
|
344
|
+
// best-effort: keep the nested checkout + transcript store out of the data repo,
|
|
345
|
+
// and drop the notebook's sweeper-owned CLAUDE.md so the repo's OWN one applies
|
|
346
|
+
// (it survives in the data-repo history). A failure here leaves a valid
|
|
347
|
+
// repo-backed project, matching create()'s own non-transactional finalization.
|
|
348
|
+
await this.ensureSidecarGitignore(dir, checkoutName).catch(() => undefined);
|
|
349
|
+
await fs.rm(path.join(dir, CLAUDE_FILE), { force: true }).catch(() => undefined);
|
|
350
|
+
return this.toDto(dir, next, await this.overviewExists(slug));
|
|
351
|
+
}
|
|
352
|
+
/** Update mutable metadata fields and bump `updated`. */
|
|
353
|
+
async update(slug, patch) {
|
|
354
|
+
const current = await this.get(slug);
|
|
355
|
+
// driveMode + maxSpawnDepth are tri-state (set / clear / leave), so they're
|
|
356
|
+
// applied explicitly below rather than via the blanket spread — a plain spread
|
|
357
|
+
// can't express "delete this field", which is how an override is cleared back
|
|
358
|
+
// to inherit.
|
|
359
|
+
const { driveMode: driveModePatch, maxSpawnDepth: maxSpawnDepthPatch, hooksMcpEnabled: hooksMcpPatch, recovery: recoveryPatch, attachments: attachmentsPatch, curation: curationPatch, models: modelsPatch, ...rest } = patch;
|
|
360
|
+
const next = {
|
|
361
|
+
...this.stripDto(current),
|
|
362
|
+
...rest,
|
|
363
|
+
slug: current.slug, // immutable
|
|
364
|
+
started: current.started, // immutable
|
|
365
|
+
updated: today(),
|
|
366
|
+
};
|
|
367
|
+
if (driveModePatch === null) {
|
|
368
|
+
// Clear the per-project override -> inherit the global default (issue #122).
|
|
369
|
+
delete next.driveMode;
|
|
370
|
+
}
|
|
371
|
+
else if (driveModePatch !== undefined) {
|
|
372
|
+
next.driveMode = driveModePatch;
|
|
373
|
+
}
|
|
374
|
+
if (maxSpawnDepthPatch === null) {
|
|
375
|
+
// Clear the per-project override -> inherit the instance default (#262).
|
|
376
|
+
delete next.maxSpawnDepth;
|
|
377
|
+
}
|
|
378
|
+
else if (maxSpawnDepthPatch !== undefined) {
|
|
379
|
+
next.maxSpawnDepth = maxSpawnDepthPatch;
|
|
380
|
+
}
|
|
381
|
+
if (hooksMcpPatch === null) {
|
|
382
|
+
// Clear the per-project override -> inherit the instance default (G5).
|
|
383
|
+
delete next.hooksMcpEnabled;
|
|
384
|
+
}
|
|
385
|
+
else if (hooksMcpPatch !== undefined) {
|
|
386
|
+
next.hooksMcpEnabled = hooksMcpPatch;
|
|
387
|
+
}
|
|
388
|
+
if (recoveryPatch === null) {
|
|
389
|
+
// Clear the per-project override -> inherit every instance default (#301).
|
|
390
|
+
delete next.recovery;
|
|
391
|
+
}
|
|
392
|
+
else if (recoveryPatch !== undefined) {
|
|
393
|
+
// Sanitise the incoming override; an all-invalid object clears it (undefined).
|
|
394
|
+
const clean = sanitizeRecoveryOverride(recoveryPatch);
|
|
395
|
+
if (clean)
|
|
396
|
+
next.recovery = clean;
|
|
397
|
+
else
|
|
398
|
+
delete next.recovery;
|
|
399
|
+
}
|
|
400
|
+
if (attachmentsPatch === null) {
|
|
401
|
+
// Clear the per-project override -> inherit every instance default (#328).
|
|
402
|
+
delete next.attachments;
|
|
403
|
+
}
|
|
404
|
+
else if (attachmentsPatch !== undefined) {
|
|
405
|
+
const clean = sanitizeAttachmentsOverride(attachmentsPatch);
|
|
406
|
+
if (clean)
|
|
407
|
+
next.attachments = clean;
|
|
408
|
+
else
|
|
409
|
+
delete next.attachments;
|
|
410
|
+
}
|
|
411
|
+
if (curationPatch === null) {
|
|
412
|
+
// Clear the per-project override -> inherit every instance default (#384).
|
|
413
|
+
delete next.curation;
|
|
414
|
+
}
|
|
415
|
+
else if (curationPatch !== undefined) {
|
|
416
|
+
// Sanitise the incoming override; an all-invalid object clears it (undefined).
|
|
417
|
+
const clean = sanitizeCurationOverride(curationPatch);
|
|
418
|
+
if (clean)
|
|
419
|
+
next.curation = clean;
|
|
420
|
+
else
|
|
421
|
+
delete next.curation;
|
|
422
|
+
}
|
|
423
|
+
if (modelsPatch === null) {
|
|
424
|
+
// Clear the per-project override -> offer the instance list again (#457).
|
|
425
|
+
delete next.models;
|
|
426
|
+
}
|
|
427
|
+
else if (modelsPatch !== undefined) {
|
|
428
|
+
// Sanitise; an empty/all-invalid list clears the override (undefined). The
|
|
429
|
+
// subset-of-instance-list check is enforced upstream at the route.
|
|
430
|
+
const clean = sanitizeModelsOverride(modelsPatch);
|
|
431
|
+
if (clean)
|
|
432
|
+
next.models = clean;
|
|
433
|
+
else
|
|
434
|
+
delete next.models;
|
|
435
|
+
}
|
|
436
|
+
await this.writeYaml(slug, next);
|
|
437
|
+
return this.toDto(current.dir, next, await this.overviewExists(slug));
|
|
438
|
+
}
|
|
439
|
+
// --- overview (sweep-curated current state) ----------------------------
|
|
440
|
+
/** Read OVERVIEW.md, or "" if it doesn't exist yet. */
|
|
441
|
+
async readOverview(slug) {
|
|
442
|
+
try {
|
|
443
|
+
return await fs.readFile(path.join(this.dirFor(slug), OVERVIEW_FILE), "utf8");
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
if (err.code === "ENOENT")
|
|
447
|
+
return "";
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** Replace OVERVIEW.md wholesale (the sweep regenerates it each time). */
|
|
452
|
+
async writeOverview(slug, content) {
|
|
453
|
+
await fs.writeFile(path.join(this.dirFor(slug), OVERVIEW_FILE), content, "utf8");
|
|
454
|
+
}
|
|
455
|
+
/** Read CHANGELOG.md, or "" if it doesn't exist yet (issue #188). */
|
|
456
|
+
async readChangelog(slug) {
|
|
457
|
+
try {
|
|
458
|
+
return await fs.readFile(path.join(this.dirFor(slug), CHANGELOG_FILE), "utf8");
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
if (err.code === "ENOENT")
|
|
462
|
+
return "";
|
|
463
|
+
throw err;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/** Whether OVERVIEW.md exists for this project. */
|
|
467
|
+
async overviewExists(slug) {
|
|
468
|
+
try {
|
|
469
|
+
await fs.access(path.join(this.dirFor(slug), OVERVIEW_FILE));
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// --- CLAUDE.md (durable identity & conventions, sweep-amended) ----------
|
|
477
|
+
/** Read CLAUDE.md, or "" if it doesn't exist yet (issue #177). */
|
|
478
|
+
async readClaudeMd(slug) {
|
|
479
|
+
try {
|
|
480
|
+
return await fs.readFile(path.join(this.dirFor(slug), CLAUDE_FILE), "utf8");
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
if (err.code === "ENOENT")
|
|
484
|
+
return "";
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Replace the CLAUDE.md "Curated notes" section wholesale with a sweeper-
|
|
490
|
+
* curated body (issue #379), preserving ALL human-authored content above the
|
|
491
|
+
* managed heading. This supersedes the old amend-only `appendClaudeMd`, which
|
|
492
|
+
* blind-appended and could never dedup — the sweeper only ever saw the first
|
|
493
|
+
* 2000 chars of the file, so it re-added "known" facts believing they were new.
|
|
494
|
+
* The sweeper now sees the full file and returns the ENTIRE curated-notes body,
|
|
495
|
+
* dedup'd/pruned to fit its budget. Everything up to and including the
|
|
496
|
+
* `## Curated notes` heading is kept verbatim; only the body below is rewritten.
|
|
497
|
+
* The file/heading is seeded if absent (older projects predating #177). A blank
|
|
498
|
+
* body is a no-op — the caller passes content only when there's something to
|
|
499
|
+
* write (NOCHANGE means "leave the file untouched", not "empty it").
|
|
500
|
+
*/
|
|
501
|
+
async writeClaudeCurated(slug, curatedBody) {
|
|
502
|
+
const trimmed = curatedBody.trim();
|
|
503
|
+
if (!trimmed)
|
|
504
|
+
return;
|
|
505
|
+
const dir = this.dirFor(slug);
|
|
506
|
+
const file = path.join(dir, CLAUDE_FILE);
|
|
507
|
+
let body;
|
|
508
|
+
try {
|
|
509
|
+
body = await fs.readFile(file, "utf8");
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
body = claudeTemplate(slug, "");
|
|
513
|
+
}
|
|
514
|
+
const idx = body.indexOf(CLAUDE_CURATED_HEADING);
|
|
515
|
+
const head = idx === -1
|
|
516
|
+
? `${body.trimEnd()}\n\n${CLAUDE_CURATED_HEADING}`
|
|
517
|
+
: body.slice(0, idx + CLAUDE_CURATED_HEADING.length);
|
|
518
|
+
await fs.writeFile(file, `${head.trimEnd()}\n\n${trimmed}\n`, "utf8");
|
|
519
|
+
}
|
|
520
|
+
// --- pins (sibling-tab files) ------------------------------------------
|
|
521
|
+
/**
|
|
522
|
+
* Pin a file: validate it exists in the project dir, dedupe, persist in
|
|
523
|
+
* project.yaml. Returns the updated project. Throws ProjectError("invalid")
|
|
524
|
+
* if the file doesn't exist or escapes the project dir.
|
|
525
|
+
*/
|
|
526
|
+
async pinFile(slug, file) {
|
|
527
|
+
const current = await this.get(slug);
|
|
528
|
+
const name = file?.trim();
|
|
529
|
+
if (!name)
|
|
530
|
+
throw new ProjectError("File name is required", "invalid");
|
|
531
|
+
// Reuse readFile's traversal guard + existence check (throws if missing).
|
|
532
|
+
await this.readFile(slug, name).catch(() => {
|
|
533
|
+
throw new ProjectError(`File not found: ${name}`, "invalid");
|
|
534
|
+
});
|
|
535
|
+
const pinned = current.pinned.includes(name)
|
|
536
|
+
? current.pinned
|
|
537
|
+
: [...current.pinned, name];
|
|
538
|
+
const next = { ...this.stripDto(current), pinned, updated: today() };
|
|
539
|
+
await this.writeYaml(slug, next);
|
|
540
|
+
return this.toDto(current.dir, next, await this.overviewExists(slug));
|
|
541
|
+
}
|
|
542
|
+
/** Unpin a file (no-op if not pinned). Returns the updated project. */
|
|
543
|
+
async unpinFile(slug, file) {
|
|
544
|
+
const current = await this.get(slug);
|
|
545
|
+
const pinned = current.pinned.filter((f) => f !== file);
|
|
546
|
+
const next = { ...this.stripDto(current), pinned, updated: today() };
|
|
547
|
+
await this.writeYaml(slug, next);
|
|
548
|
+
return this.toDto(current.dir, next, await this.overviewExists(slug));
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Add or replace one unified trigger in `project.yaml`, keyed by name (Epic T /
|
|
552
|
+
* T1) — the persistence half of a trigger mutation (the caller arms it against
|
|
553
|
+
* herdctl separately via `TriggerService`/`HerdctlService`). The record is validated
|
|
554
|
+
* + normalised by the Zod schema ({@link sanitizeTrigger}); an invalid name or record
|
|
555
|
+
* throws `ProjectError("invalid")`. Returns the updated project DTO.
|
|
556
|
+
*/
|
|
557
|
+
async setTrigger(slug, name, trigger) {
|
|
558
|
+
const current = await this.get(slug);
|
|
559
|
+
if (!isValidTriggerName(name)) {
|
|
560
|
+
throw new ProjectError(`Invalid trigger name: ${name}`, "invalid");
|
|
561
|
+
}
|
|
562
|
+
const clean = sanitizeTrigger(trigger);
|
|
563
|
+
if (!clean)
|
|
564
|
+
throw new ProjectError("Invalid trigger definition", "invalid");
|
|
565
|
+
const triggers = { ...(current.triggers ?? {}), [name]: clean };
|
|
566
|
+
const next = { ...this.stripDto(current), triggers, updated: today() };
|
|
567
|
+
await this.writeYaml(slug, next);
|
|
568
|
+
return this.toDto(current.dir, next, await this.overviewExists(slug));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Remove a trigger from `project.yaml` (no-op if absent). Returns the updated
|
|
572
|
+
* project DTO. The caller disarms the trigger's agent / schedule via `TriggerService`.
|
|
573
|
+
*
|
|
574
|
+
*/
|
|
575
|
+
async removeTrigger(slug, name) {
|
|
576
|
+
const current = await this.get(slug);
|
|
577
|
+
const rest = { ...(current.triggers ?? {}) };
|
|
578
|
+
delete rest[name];
|
|
579
|
+
const stripped = this.stripDto(current);
|
|
580
|
+
if (Object.keys(rest).length > 0)
|
|
581
|
+
stripped.triggers = rest;
|
|
582
|
+
else
|
|
583
|
+
delete stripped.triggers;
|
|
584
|
+
const next = { ...stripped, updated: today() };
|
|
585
|
+
await this.writeYaml(slug, next);
|
|
586
|
+
return this.toDto(current.dir, next, await this.overviewExists(slug));
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Delete a project directory and everything in it (project.yaml, CHANGELOG.md,
|
|
590
|
+
* and any files the keeper agent created). Throws ProjectError("not_found")
|
|
591
|
+
* if the slug has no project.yaml, so callers can return a clean 404.
|
|
592
|
+
*
|
|
593
|
+
* Note: this removes the project DIRECTORY only. The caller (server) is
|
|
594
|
+
* responsible for dropping the generated keeper-agent yaml + regenerating
|
|
595
|
+
* herdctl.yaml + reloading the fleet — the inverse of the create flow.
|
|
596
|
+
*/
|
|
597
|
+
async remove(slug) {
|
|
598
|
+
// The root workspace's directory IS the projects root — deleting
|
|
599
|
+
// it would take every project with it. The resolved-path guard below already
|
|
600
|
+
// refuses, but say so explicitly rather than leaning on a coincidence.
|
|
601
|
+
if (isRootKey(slug)) {
|
|
602
|
+
throw new ProjectError("Refusing to delete the root workspace", "invalid");
|
|
603
|
+
}
|
|
604
|
+
const project = await this.get(slug); // throws not_found
|
|
605
|
+
const dir = this.dirFor(slug);
|
|
606
|
+
// Guard against deleting the projects root itself if a bad slug slipped in.
|
|
607
|
+
const resolved = path.resolve(dir);
|
|
608
|
+
if (resolved === path.resolve(this.root) || !resolved.startsWith(path.resolve(this.root) + path.sep)) {
|
|
609
|
+
throw new ProjectError("Refusing to delete outside the projects root", "invalid");
|
|
610
|
+
}
|
|
611
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
612
|
+
return project;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Replace CHANGELOG.md wholesale with a sweeper-curated body (issue #379). The
|
|
616
|
+
* sweeper now returns the FULL changelog — adding a dated entry for genuinely-
|
|
617
|
+
* new activity, coalescing duplicates, and dropping/summarizing the oldest to
|
|
618
|
+
* stay under its token budget — instead of one blind-appended bullet. That's
|
|
619
|
+
* what stops the file (and the per-chat preload that injects it) growing without
|
|
620
|
+
* bound. The `# Changelog — <slug>` title is owned by Paddock and re-asserted
|
|
621
|
+
* here so the model never has to reproduce it; if the model included its own
|
|
622
|
+
* top-level heading we drop it to avoid a duplicate title. A blank body is a
|
|
623
|
+
* no-op (guards against wiping the file on a malformed reply).
|
|
624
|
+
*/
|
|
625
|
+
async writeChangelog(slug, body) {
|
|
626
|
+
const trimmed = body.trim();
|
|
627
|
+
if (!trimmed)
|
|
628
|
+
return;
|
|
629
|
+
const file = path.join(this.dirFor(slug), CHANGELOG_FILE);
|
|
630
|
+
const title = `# Changelog — ${slug}`;
|
|
631
|
+
// Drop a leading top-level heading the model may have emitted, so the
|
|
632
|
+
// Paddock-owned title isn't duplicated.
|
|
633
|
+
const withoutTitle = /^#\s/.test(trimmed) ? trimmed.replace(/^#[^\n]*\n+/, "") : trimmed;
|
|
634
|
+
await fs.writeFile(file, `${title}\n\n${withoutTitle.trim()}\n`, "utf8");
|
|
635
|
+
}
|
|
636
|
+
// --- freeform file serving (delegated to project-files.ts, issue #403) ---
|
|
637
|
+
// Thin wrappers over the pure `(root, slug, …)` helpers so `ProjectStore`'s
|
|
638
|
+
// public file-read API (and its ProjectError codes) is unchanged.
|
|
639
|
+
/**
|
|
640
|
+
* List one level of a project directory (issue #259). See
|
|
641
|
+
* {@link import("./project-files.js").listFiles}.
|
|
642
|
+
*/
|
|
643
|
+
async listFiles(slug, subpath = "") {
|
|
644
|
+
return projectFiles.listFiles(this.root, slug, subpath);
|
|
645
|
+
}
|
|
646
|
+
/** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
|
|
647
|
+
async readFile(slug, name) {
|
|
648
|
+
return projectFiles.readProjectFile(this.root, slug, name);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Read a file's raw bytes + its MIME type (issue #61), for the binary/image
|
|
652
|
+
* endpoint. See {@link import("./project-files.js").readFileBytes}.
|
|
653
|
+
*/
|
|
654
|
+
async readFileBytes(slug, name) {
|
|
655
|
+
return projectFiles.readFileBytes(this.root, slug, name);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Read a file plus a render-kind hint derived from its extension (issues #3 /
|
|
659
|
+
* #61). See {@link import("./project-files.js").readFileWithKind}.
|
|
660
|
+
*/
|
|
661
|
+
async readFileWithKind(slug, name) {
|
|
662
|
+
return projectFiles.readFileWithKind(this.root, slug, name);
|
|
663
|
+
}
|
|
664
|
+
// --- internals ---------------------------------------------------------
|
|
665
|
+
/**
|
|
666
|
+
* Read one workspace, or `null` if the key doesn't name one.
|
|
667
|
+
*
|
|
668
|
+
* **The root workspace always exists.** For a project, `project.yaml` IS the
|
|
669
|
+
* existence gate — no record, not a project. But the root is the instance
|
|
670
|
+
* itself: its directory is always there, so a missing record just means
|
|
671
|
+
* "nothing has been customised yet" and every field falls back to a default
|
|
672
|
+
* (see {@link normalize}). The record is written lazily, on the first setting
|
|
673
|
+
* that actually changes.
|
|
674
|
+
*
|
|
675
|
+
* This is what removes the whole opt-in apparatus the previous design needed —
|
|
676
|
+
* no `createRoot`, no enable card, and no `/chat` that 404s on a fresh box.
|
|
677
|
+
*/
|
|
678
|
+
async readSafe(key) {
|
|
679
|
+
const dir = this.dirFor(key);
|
|
680
|
+
let yaml;
|
|
681
|
+
try {
|
|
682
|
+
const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
|
|
683
|
+
const parsed = YAML.parse(raw);
|
|
684
|
+
if (!parsed || typeof parsed !== "object") {
|
|
685
|
+
if (!isRootKey(key))
|
|
686
|
+
return null;
|
|
687
|
+
yaml = this.normalize({}, key);
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
yaml = this.normalize(parsed, key);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
if (!isRootKey(key))
|
|
695
|
+
return null;
|
|
696
|
+
yaml = this.normalize({}, key);
|
|
697
|
+
}
|
|
698
|
+
// overviewExists is a cheap fs.access; do it after the yaml parse succeeds.
|
|
699
|
+
const hasOverview = await this.overviewExists(key);
|
|
700
|
+
return this.toDto(dir, yaml, hasOverview);
|
|
701
|
+
}
|
|
702
|
+
/** Fill defaults / coerce a parsed project.yaml into a complete ProjectYaml. */
|
|
703
|
+
normalize(p, key) {
|
|
704
|
+
const started = p.started ?? today();
|
|
705
|
+
return {
|
|
706
|
+
// The root workspace has no slug to fall back to, so it reads as its own
|
|
707
|
+
// directory's basename. (No instance-name config field exists yet; when
|
|
708
|
+
// one lands, this is the single place it should feed.)
|
|
709
|
+
name: p.name ?? (isRootKey(key) ? path.basename(path.resolve(this.root)) : key),
|
|
710
|
+
slug: p.slug ?? key,
|
|
711
|
+
status: p.status ?? "active",
|
|
712
|
+
domain: Array.isArray(p.domain) ? p.domain : [],
|
|
713
|
+
// Carry `group` through only when it's a non-empty string (an absent area
|
|
714
|
+
// stays absent on disk — same round-trip discipline as `model`).
|
|
715
|
+
...(typeof p.group === "string" && p.group.trim()
|
|
716
|
+
? { group: p.group.trim().toLowerCase() }
|
|
717
|
+
: {}),
|
|
718
|
+
visibility: p.visibility ?? "public",
|
|
719
|
+
started,
|
|
720
|
+
updated: p.updated ?? started,
|
|
721
|
+
summary: p.summary ?? "",
|
|
722
|
+
// Coerce to well-formed {label,url} objects — a legacy/hand-edited file may
|
|
723
|
+
// carry a bare string list, which otherwise crashes the Settings pane.
|
|
724
|
+
links: normalizeLinks(p.links),
|
|
725
|
+
pinned: Array.isArray(p.pinned)
|
|
726
|
+
? p.pinned.filter((f) => typeof f === "string")
|
|
727
|
+
: [],
|
|
728
|
+
// Carry model through only when present on disk; the DTO resolves the
|
|
729
|
+
// default (an absent model stays absent in the yaml so existing files
|
|
730
|
+
// without `model` still round-trip unchanged).
|
|
731
|
+
...(typeof p.model === "string" ? { model: p.model } : {}),
|
|
732
|
+
// models allow-list (issue #457 Step 2): carried only when at least one known
|
|
733
|
+
// catalog id survives sanitization — an absent/all-invalid override stays
|
|
734
|
+
// absent so files without it round-trip unchanged, and the offered list is
|
|
735
|
+
// resolved against the instance list in the web, NOT baked concrete here.
|
|
736
|
+
...(() => {
|
|
737
|
+
const m = sanitizeModelsOverride(p.models);
|
|
738
|
+
return m ? { models: m } : {};
|
|
739
|
+
})(),
|
|
740
|
+
// Keeper-agent overrides (issue #12): same round-trip discipline as model
|
|
741
|
+
// — carried only when present so files without them are unchanged.
|
|
742
|
+
...(typeof p.permissionMode === "string" ? { permissionMode: p.permissionMode } : {}),
|
|
743
|
+
...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
|
|
744
|
+
...(typeof p.docker === "boolean" ? { docker: p.docker } : {}),
|
|
745
|
+
// driveMode (Paddock#111): carried only when explicitly set — an absent
|
|
746
|
+
// value means "inherit the global default" and is resolved at dispatch
|
|
747
|
+
// (`project.driveMode ?? cfg.driveMode`), NOT here, so the env-level
|
|
748
|
+
// global can still take effect for projects that don't override it.
|
|
749
|
+
...(typeof p.driveMode === "string" ? { driveMode: p.driveMode } : {}),
|
|
750
|
+
// maxSpawnDepth (issue #262): carried only when explicitly set — an absent
|
|
751
|
+
// value means "inherit the instance default" and is resolved at dispatch
|
|
752
|
+
// (`resolveMaxSpawnDepth(project.maxSpawnDepth, cfg.maxSpawnDepth)`), NOT
|
|
753
|
+
// here, so the instance default still applies to non-overriding projects.
|
|
754
|
+
...(typeof p.maxSpawnDepth === "number" ? { maxSpawnDepth: p.maxSpawnDepth } : {}),
|
|
755
|
+
// hooksMcpEnabled (Epic G / G5): carried only when explicitly set — an absent
|
|
756
|
+
// value means "inherit the instance default" and is resolved at dispatch
|
|
757
|
+
// (`resolveHooksMcpEnabled(project.hooksMcpEnabled, cfg.hooksMcpEnabled)`), NOT
|
|
758
|
+
// here, so the instance default still applies to non-overriding projects.
|
|
759
|
+
...(typeof p.hooksMcpEnabled === "boolean" ? { hooksMcpEnabled: p.hooksMcpEnabled } : {}),
|
|
760
|
+
// recovery (issue #301): carried only when at least one valid field survives
|
|
761
|
+
// sanitization — an absent/all-invalid override stays absent so files without
|
|
762
|
+
// it round-trip unchanged, and each field is resolved at dispatch
|
|
763
|
+
// (`resolveRecoveryConfig(project.recovery, cfg.recovery)`), NOT here.
|
|
764
|
+
...(() => {
|
|
765
|
+
const r = sanitizeRecoveryOverride(p.recovery);
|
|
766
|
+
return r ? { recovery: r } : {};
|
|
767
|
+
})(),
|
|
768
|
+
// curation (issue #384): carried only when at least one valid field survives
|
|
769
|
+
// sanitization — an absent/all-invalid override stays absent so files without
|
|
770
|
+
// it round-trip unchanged, and each field is resolved at sweep time
|
|
771
|
+
// (`resolveCurationConfig(project.curation, cfg.curation)`), NOT here.
|
|
772
|
+
...(() => {
|
|
773
|
+
const c = sanitizeCurationOverride(p.curation);
|
|
774
|
+
return c ? { curation: c } : {};
|
|
775
|
+
})(),
|
|
776
|
+
// repo (issue #187): carried only when present — its presence is what marks
|
|
777
|
+
// the project repo-backed and drives the workingDir resolution in toDto.
|
|
778
|
+
...(typeof p.repo === "string" && p.repo.trim() ? { repo: p.repo.trim() } : {}),
|
|
779
|
+
// schedules (issue #265): carried only when at least one well-formed entry
|
|
780
|
+
// survives sanitization — an absent/empty map stays absent on disk, so files
|
|
781
|
+
// without schedules round-trip unchanged. A malformed entry is dropped (not
|
|
782
|
+
// thrown) so a bad hand-edit can't brick the project's keeper registration.
|
|
783
|
+
...(() => {
|
|
784
|
+
const s = sanitizeSchedules(p.schedules);
|
|
785
|
+
return s && Object.keys(s).length > 0 ? { schedules: s } : {};
|
|
786
|
+
})(),
|
|
787
|
+
// hooks (Epic G / G1): same discipline as schedules — carried only when at
|
|
788
|
+
// least one well-formed entry survives sanitization, so hook-less files
|
|
789
|
+
// round-trip byte-identically and a malformed hand-edit is dropped (not thrown)
|
|
790
|
+
// rather than bricking the project's agent registration.
|
|
791
|
+
...(() => {
|
|
792
|
+
const h = sanitizeHooks(p.hooks);
|
|
793
|
+
return h && Object.keys(h).length > 0 ? { hooks: h } : {};
|
|
794
|
+
})(),
|
|
795
|
+
// triggers (Epic T / T1): same discipline as schedules/hooks — carried only
|
|
796
|
+
// when at least one well-formed entry survives Zod validation, so trigger-less
|
|
797
|
+
// files round-trip byte-identically and a malformed hand-edit is dropped (not
|
|
798
|
+
// thrown) rather than bricking the project's agent registration.
|
|
799
|
+
...(() => {
|
|
800
|
+
const t = sanitizeTriggers(p.triggers);
|
|
801
|
+
return t && Object.keys(t).length > 0 ? { triggers: t } : {};
|
|
802
|
+
})(),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Ensure the project dir's sidecar `.gitignore` ignores the nested repo-backed
|
|
807
|
+
* checkout (`/<checkoutName>/`) and the transcript store (`/.chats/`), so neither
|
|
808
|
+
* is tracked by the instance data repo (issue #187 option A). Idempotent and
|
|
809
|
+
* merge-aware: an existing `.gitignore` (rare for a notebook, but possible) keeps
|
|
810
|
+
* its lines and only the missing entries are appended — used by both `create()`
|
|
811
|
+
* and the in-place `promote()` (#213).
|
|
812
|
+
*/
|
|
813
|
+
async ensureSidecarGitignore(dir, checkoutName) {
|
|
814
|
+
const file = path.join(dir, GITIGNORE_FILE);
|
|
815
|
+
let existing = "";
|
|
816
|
+
try {
|
|
817
|
+
existing = await fs.readFile(file, "utf8");
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
/* no .gitignore yet — write a fresh one below */
|
|
821
|
+
}
|
|
822
|
+
const want = [`/${checkoutName}/`, `/.chats/`];
|
|
823
|
+
const have = new Set(existing.split("\n").map((l) => l.trim()));
|
|
824
|
+
const missing = want.filter((l) => !have.has(l));
|
|
825
|
+
if (existing && missing.length === 0)
|
|
826
|
+
return; // already covers everything
|
|
827
|
+
if (!existing) {
|
|
828
|
+
await fs.writeFile(file, [
|
|
829
|
+
`# Repo-backed project checkout (issue #187) — not tracked by the data repo.`,
|
|
830
|
+
...want,
|
|
831
|
+
"",
|
|
832
|
+
].join("\n"), "utf8");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
// Append only the missing lines to the existing file (preserve user content).
|
|
836
|
+
const body = existing.endsWith("\n") ? existing : `${existing}\n`;
|
|
837
|
+
await fs.writeFile(file, `${body}${missing.join("\n")}\n`, "utf8");
|
|
838
|
+
}
|
|
839
|
+
async writeYaml(slug, yaml) {
|
|
840
|
+
const header = "# Paddock project metadata. Directory name MUST equal `slug`.\n" +
|
|
841
|
+
"# status: idea | active | paused | blocked | done | abandoned\n";
|
|
842
|
+
const body = YAML.stringify(yaml);
|
|
843
|
+
await fs.writeFile(path.join(this.dirFor(slug), PROJECT_FILE), header + body, "utf8");
|
|
844
|
+
}
|
|
845
|
+
toDto(dir, yaml, hasOverview) {
|
|
846
|
+
return {
|
|
847
|
+
...yaml,
|
|
848
|
+
// Always concrete in the DTO; "" means Unsorted.
|
|
849
|
+
group: yaml.group ?? "",
|
|
850
|
+
pinned: yaml.pinned ?? [],
|
|
851
|
+
// Always concrete in the DTO: an absent on-disk model resolves to the
|
|
852
|
+
// keeper default (CONTRACT-v3 §4).
|
|
853
|
+
model: yaml.model ?? DEFAULT_MODEL,
|
|
854
|
+
// Offered-models override stays RAW (per-project only — resolved against the
|
|
855
|
+
// instance allow-list in the web, never baked concrete here; the `model`/
|
|
856
|
+
// `recovery` discipline), re-sanitised so a corrupt hand-edit never reaches
|
|
857
|
+
// the web. Absent ⇒ omitted (undefined), so the project offers the instance
|
|
858
|
+
// list (issue #457 Step 2).
|
|
859
|
+
models: sanitizeModelsOverride(yaml.models),
|
|
860
|
+
// Keeper-agent overrides — always concrete in the DTO (issue #12).
|
|
861
|
+
permissionMode: yaml.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
|
862
|
+
maxTurns: yaml.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
863
|
+
docker: yaml.docker ?? DEFAULT_DOCKER,
|
|
864
|
+
// Recovery override stays RAW (per-project only — resolved against the
|
|
865
|
+
// instance default at dispatch, never baked concrete here; the `driveMode`
|
|
866
|
+
// discipline), but re-sanitised so a corrupt hand-edit never reaches the
|
|
867
|
+
// web. Absent ⇒ omitted (undefined), so the project inherits every default.
|
|
868
|
+
recovery: sanitizeRecoveryOverride(yaml.recovery),
|
|
869
|
+
// Attachment override stays RAW (per-project only — resolved against the
|
|
870
|
+
// instance default at request time, never baked concrete here), re-sanitised
|
|
871
|
+
// so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#328).
|
|
872
|
+
attachments: sanitizeAttachmentsOverride(yaml.attachments),
|
|
873
|
+
// Curation-budget override stays RAW (per-project only — resolved against
|
|
874
|
+
// the instance default at sweep time, never baked concrete here), re-
|
|
875
|
+
// sanitised so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#384).
|
|
876
|
+
curation: sanitizeCurationOverride(yaml.curation),
|
|
877
|
+
dir,
|
|
878
|
+
// Repo-backed (#187): the keeper's cwd is the nested checkout; a notebook's
|
|
879
|
+
// cwd is the metadata dir itself. `repoBacked` is the presence of `repo`.
|
|
880
|
+
workingDir: workingDirFor(dir, yaml.repo),
|
|
881
|
+
repoBacked: Boolean(yaml.repo),
|
|
882
|
+
hasOverview,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
stripDto(p) {
|
|
886
|
+
const { dir: _dir, workingDir: _workingDir, repoBacked: _repoBacked, hasOverview: _hasOverview, group, ...rest } = p;
|
|
887
|
+
void _dir;
|
|
888
|
+
void _workingDir;
|
|
889
|
+
void _repoBacked;
|
|
890
|
+
void _hasOverview;
|
|
891
|
+
// Keep an empty area off the yaml so it isn't persisted as `group: ""`.
|
|
892
|
+
return group?.trim() ? { ...rest, group } : rest;
|
|
893
|
+
}
|
|
894
|
+
}
|