From 40c1937634cbda547e465b7547ad1b69c6c1eeb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:29:41 +0000 Subject: [PATCH 01/17] Add draft CLI goals doc for review Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaADA545v1Fw4im5hHFoTq --- docs/goals.md | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/goals.md diff --git a/docs/goals.md b/docs/goals.md new file mode 100644 index 0000000..830a199 --- /dev/null +++ b/docs/goals.md @@ -0,0 +1,174 @@ +# Delphi CLI — Goals + +What the CLI must achieve, not how it does it. Use this as the acceptance checklist for any +rewrite. The IDs are stable, so refer to goals by ID. The design spec is +`context/docs/delphi-design.md`. Where the spec and the bash implementation disagree, this file +follows the code and records the difference in section 5. + +## 1. Purpose + +Delphi stores NER's AI-harness context (instructions, knowledge blocks, docs, skills, MCP, settings) +once, in a compressed form organized by the org chart under `context/`. The CLI turns a chosen +combination of that context into a working directory where a harness (Claude Code) runs. It keeps +that directory current as Delphi changes. It sends edits made there back to Delphi as a pull +request, with each edit landing in the source file it came from and a record of the model, harness, +and effort that produced it. + +## 2. Concepts + +| Term | Meaning | +|---|---| +| Scope | A directory under `context/` for an org unit. It has a `scope.yml` and may contain `blocks/`, `docs/`, `harness/`, `layouts/`, and child scopes. | +| Block | A tracked source file, identified by its path relative to `context/`. It can be a basic block, a doc, an instruction fragment, a skill, an MCP fragment, or a settings file. | +| Layout | `/layouts//manifest.yml`. It selects blocks, a harness, and code repos. Its name is unique across the repo. | +| Workspace | A local git repo outside Delphi, compiled from a layout. One layout can have many workspaces. | +| Compile | A deterministic function from (Delphi commit, layout) to files plus a lock. | +| Lock | `.delphi/lock.tsv`. It maps every line range of every compiled file to its source block, or marks it as generated or separator text. | +| Pending diff | Everything the workspace has changed since its latest merged compile. This is what propose sends. | + +## 3. Goals by command + +### Compile +- **C1** The output is determined entirely by the Delphi commit and the layout. The same inputs give byte-identical files and lock. +- **C2** The instruction file is a short Delphi header, then the listed fragments in order, with one blank line between each. It is emitted even when no fragments are listed. +- **C3** Basic blocks are mirrored at `context/`. Docs go to `docs/`. Native skill directories are copied 1:1, keeping the executable bit. The settings file is copied 1:1. +- **C4** A built skill (`.skill` spec) becomes one `SKILL.md`: generated frontmatter (`name`, `description`) followed by the listed body blocks. Its references are copied to `references/`. +- **C5** MCP fragments are wrapped into a single `{"mcpServers": {...}}` file with comma separators. The file is omitted when no fragments are listed. +- **C6** An editable copy of the layout manifest is always emitted at `.delphi/manifest.yml`. +- **C7** Every line of every output (except the lock itself) belongs to exactly one lock segment: a source block with its blob id, a separator (`@glue`), or generated text (`@gen:`). Each source ends with a newline, so segments never share a line. +- **C8** A trailing `/*` glob in `blocks`/`docs` expands to that directory's files, non-recursively and sorted. +- **C9** Compile fails with no partial output on any of these: a missing or empty source, an empty glob, a parse error, two sources mapping to one output, an unsafe path, or an entry under the wrong key (for example a `blocks:` entry outside `blocks/`). + +### workspace new ` [--as ws] [--ref branch]` +- **WN1** Fetches, then finds the layout by name in `origin/` (default `main`) and compiles it at that commit. +- **WN2** Creates `/` (default name = layout name) as a new git repo with no Delphi history. It fails if the directory already exists. +- **WN3** Afterwards the user is on their own branch, with a clean tree equal to the compile. The compile is also recorded as the "latest merged compile" baseline. +- **WN4** Clones each manifest repo into `repos/`. A failed clone produces a warning and a summary at the end, but the workspace is still created. `repos/`, `worktrees/`, and the harness's local-settings file are git-ignored locally. +- **WN5** Installs a commit hook that tags the user's commits with provenance (V3). +- **WN6** `--ref` lets a user try a layout before its PR merges. The workspace then tracks that branch. + +### workspace open `[ws] [--model] [--effort] [--shell]` +- **WO1** Finds the workspace from the argument, then the current directory, then a numbered picker (TTY only). +- **WO2** Warns if the workspace is behind its ref, and if any workspace has been unproposed longer than `stale_days`. +- **WO3** Launches the harness in the workspace with `DELPHI_HARNESS/MODEL/EFFORT` exported. With `--shell`, it opens `$SHELL` instead. + +### workspace refresh `[ws] [--ref branch]` +- **WR1** Brings the workspace up to date with `origin/` by merging the new compile into the user's branch. User edits are kept. +- **WR2** Refuses to run on a dirty tree or while a merge is in progress. `--ref` changes the tracked branch first. +- **WR3** Idempotent and resumable. Re-running picks up where it left off, and there is no `--continue`. A merge conflict exits with **2** and tells the user to resolve it, commit, and re-run. +- **WR4** If the compiled content is unchanged, refresh adds no commits. It still records the new Delphi commit as the baseline and reports "up to date". +- **WR5** Follows block and layout moves recorded since the last compile. The layout path is updated, and moved paths in `.delphi/manifest.yml` are rewritten and committed. +- **WR6** If the tracked branch no longer exists, refresh fails and tells the user to run `refresh --ref main`. +- **WR7** Delphi's own commits in the workspace never receive provenance trailers. + +### workspace status `[--offline]` +- **WS1** Prints one row per workspace: name, layout, ref, dirty, state, age, behind, and a suggested next command. It ends with a summary line. +- **WS2** State is `clean` (empty pending diff), `proposed` (pending diff unchanged since the last push), or `unproposed`. A change that only shifts line numbers does not flip a workspace back to `unproposed`. +- **WS3** Behind means `origin/` has commits since the baseline that touch this workspace's sources, its layout manifest, or a globbed directory. If the branch is gone, the value is `gone`. +- **WS4** Age is shown only for unproposed workspaces. A workspace unproposed longer than `stale_days` is marked stale. `--offline` skips the fetch. + +### workspace propose `[ws] [--dry-run] [--model] [--effort] [--yes]` +- **WP1** `--dry-run` changes nothing. It routes against the current baseline (warning if behind) and prints the plan and the unresolved items with their diffs. +- **WP2** Refuses unless the workspace tracks `main`. Refreshes first and continues only on success (exit 2 propagates). +- **WP3** Each workspace has one live PR, on branch `delphi/propose//`. Every propose rebuilds that branch from the baseline commit with the entire pending diff and force-updates it. Proposing twice never duplicates, and merged changes drop out after the next refresh. +- **WP4** The PR is built in ordered commits: the layout manifest, then block edits, then new files. Each commit carries provenance trailers. `check` must pass, or nothing is pushed. +- **WP5** If there is nothing to propose, it says so and exits 0. It warns if a previously pushed PR is still open. +- **WP6** Refuses to overwrite a propose branch that someone else pushed to. It records what it saw, so the next run overwrites it deliberately. It also refuses when an open PR on the branch belongs to a different `gh` user. +- **WP7** The PR body contains the routed changes, an **Unresolved** section (path, reason, and fenced diff for each item), and the provenance table. Unresolved items never block the PR. +- **WP8** After a push, it records the date, the pending-diff hash (which drives WS2), and the pushed commit. + +### Routing (how propose maps the pending diff back to Delphi) +The lock is read from the baseline. `repos/`, `worktrees/`, and the lock are excluded. Delphi never +guesses: anything it cannot place is unresolved. +- **R1** A modified `.delphi/manifest.yml` replaces the layout manifest. Any other `.delphi/` change is unresolved. +- **R2** Binary files and mode-only changes are unresolved. +- **R3** A deleted output is a no-op if its source was dropped from `.delphi/manifest.yml`, and unresolved otherwise. A file inside a built skill counts as dropped only when its `.skill` spec was dropped. +- **R4** A modified output is routed hunk by hunk (R8–R11). +- **R5** A new `context//blocks/...` file under an existing scope becomes a new block and is added to the manifest's `blocks:` unless an entry or glob already covers it. +- **R6** A new `docs//` file goes beside the compiled docs already in `docs//`, otherwise under `/docs/`, and is added to `docs:` if not covered. +- **R7** A new file in a compiled native skill goes into that skill's source directory. A new skill directory becomes `/harness/skills//` and is added to `skills:`. A new file in a built skill is unresolved. So is a new file whose target already exists upstream. +- **R8** A hunk entirely inside one block becomes a patch to that block. All hunks for one block are combined into a single patch. An insertion at a block's end appends to it. An insertion before a block that follows generated or separator lines prepends to it. +- **R9** **New sections in the instruction file become new fragments.** An insertion at a segment boundary that is set off from the neighbouring blocks by blank lines is written to `/harness/instructions/.md`. The slug comes from the section's first line, with `-2`, `-3`, ... added when the name is taken. The fragment is listed in `instructions:` right after the preceding fragment (first if there is none), and several new fragments keep their file order. Lines that touch a neighbouring block with no blank line between them extend that block instead. +- **R10** Edits to only the `name:`/`description:` lines of a built skill's frontmatter rewrite those keys in the `.skill` spec. +- **R11** These are unresolved: hunks in generated or separator lines, hunks spanning segments, deletion of a whole block via hunks, a second patch to the same block (for example the same block edited in two outputs), and patches that fail to apply. + +### layout new ` [--from file]` / layout list +- **L1** Validates that `` is a scope on `origin/main` and that `` matches `[a-z0-9-]+` and is not already used. +- **L2** With `--from`, uses the given manifest as-is. Its `name` must equal ``, and it must name a harness. +- **L3** Without `--from` (TTY only), offers the scope's and its ancestors' recommendations plus the scope's own blocks, one y/n question each. Answers are sorted into the right key by path, keeping at most one settings file. It asks for the harness when there is more than one adapter, then for repos. +- **L4** Writes the manifest through the PR path on branch `delphi/layout/` after `check` passes, and prints the branch name. +- **L5** `layout list` prints `namescopeharness` for every layout on `origin/main`. + +### block mv ` ` +- **B1** Only paths under a scope's `blocks/`, `docs/`, or `harness/` can be moved. `old` must exist on `origin/main` and `new` must not. +- **B2** Moves the file or directory, appends `oldnewdate` to `moves.tsv`, and rewrites exact and directory-prefix references in every manifest, `scope.yml`, and `.skill`. +- **B3** Runs `check`, then opens a PR on branch `delphi/mv/-` and prints the branch. Workspaces pick up the move on their next refresh (WR5). +- **B4** `moves.tsv` is append-only. Each consumer applies only the rows that are new to it, in order, once each. That means paths can be reused and moves can be reversed. + +### check +- **K1** Lists every violation, then exits non-zero if there are any. It prints "ok" otherwise. +- **K2** Structure: every scope has a `scope.yml`, scopes contain no stray files, `context/` contains no symlinks, and no file is empty or missing its trailing newline. +- **K3** No file under `context/` is named after any adapter's instruction file (`CLAUDE.md`, ...). +- **K4** Every `.yml`/`.skill` parses. Recommendations and `.skill` body/reference paths exist. `.skill` specs have `name` and `description` and no unknown keys. +- **K5** Manifests: `name` equals the directory and is unique, the harness adapter exists, there are no unknown keys, there is at most one settings file, entries sit under the key that matches their location, and every layout compiles. +- **K6** `moves.tsv` rows have three fields. When a JSON tool is available, MCP fragments form valid JSON once wrapped. + +### setup `[dir]` +- **S1** Puts a `delphi` launcher in `dir` (default `~/.local/bin`) that runs this checkout's CLI. It warns if `dir` is not on `PATH`. + +### Harness adapters +- **H1** An adapter supplies only names (instruction file, skills directory, MCP file, settings file, locally ignored file), a provenance fallback (harness+version, model, effort), and a launch action (model, effort). All file logic is generic. +- **H2** Adding a harness means adding one adapter. The `claude-code` adapter uses `CLAUDE.md`, `.claude/skills`, `.mcp.json`, `.claude/settings.json`, and `.claude/settings.local.json`. + +### Provenance +- **V1** Every commit Delphi writes to Delphi carries `Delphi-Harness`, `Delphi-Model`, and `Delphi-Effort` trailers. A propose commit also carries `Delphi-Layout`, `Delphi-Workspace`, and `Delphi-Base`. +- **V2** Each field is resolved from the CLI flag, then the `DELPHI_*` env var, then the adapter fallback, then a prompt (TTY only; `none` is accepted). With no TTY it is an error. A field is never recorded as "unknown". +- **V3** Workspace commits made by the user are tagged from `DELPHI_*` env vars when those are set and the trailers are not already present. +- **V4** The PR body's provenance table lists the proposing session plus each distinct trailer combination from non-merge workspace commits since the workspace was last clean. + +### Writing to Delphi (PR path) +- **P1** Every write to Delphi goes through one path: a temporary worktree on the command's branch, starting from a given commit (default `origin/main`). The user's own Delphi checkout (working tree, index, HEAD) is never modified. +- **P2** Shows the title and body, then asks `Push ... ? [y/N]` unless `--yes` or `DELPHI_YES=1` is set. A non-interactive run without `--yes` fails before doing any work. +- **P3** Pushes the branch, then updates the open PR for it with `gh pr edit`, or creates one against `main` with `gh pr create`. Propose branches are force-pushed with a lease on the last pushed commit. +- **P4** Temporary worktrees and directories are removed on success and on failure. If the user declines, the branch stays committed locally and nothing is pushed. + +## 4. Cross-cutting invariants + +- **X1 Path safety.** Every path from config, flags, the lock, manifests, or `moves.tsv` is rejected if it is absolute, contains `..` or `.` components, or resolves through a symlink to a location outside its root. This check runs before any read, write, or delete. +- **X2 Determinism.** Compile (C1), routing, and the pending-diff hash depend only on git content, never on time or the environment. The only exceptions are dates in `moves.tsv` rows and branch names. +- **X3 Idempotence.** Refresh, propose, and re-running after a failure converge on the same result. Nothing is left half-applied without a message. +- **X4 Hands off the user's checkout.** Delphi is read through `origin/*` refs and temporary worktrees. Workspaces live outside the repo, so Delphi's own `CLAUDE.md` is never an ancestor file of a workspace. +- **X5 Workspace bookkeeping stays inside `.git/`.** It never dirties the working tree or appears in the pending diff. +- **X6 gh is stubbable.** `gh` is used only for identity (`api user`) and PR list/create/edit, so a stub plus a bare origin exercises everything end to end (`dev/sandbox.sh`). +- **X7 Exit codes.** 0 means success or up to date, 1 means error, and 2 means a refresh (or the refresh inside propose) stopped on merge conflicts. +- **X8 Actionable errors.** Messages name the file or line and the command to run next. A non-interactive session never hangs on a prompt. +- **X9 Config.** `delphi.conf` holds `key=value` lines and is never executed. `workspace_root` defaults to `../Delphi-workspaces` relative to the repo, and `DELPHI_WORKSPACE_ROOT` overrides it. `stale_days` defaults to 14. +- **X10 Offline tolerance.** A failed fetch produces a warning, and the command continues with local `origin/*` refs. + +## 5. Open questions / candidates to drop or simplify + +These are flagged, not decided. + +1. **Three records of the baseline.** The `generated` branch, the `generated-merged` tag, and `meta.compile_commit` (with `Delphi-Compile` trailers) overlap. Could one ref plus meta suffice? +2. **Three overlapping push guards.** The `last_pushed` lease pre-check via `ls-remote`, `--force-with-lease`, and the "open PR authored by someone else" check. Is one enough? +3. **Proposing with only unresolved items.** The spec says unresolved items are listed in the PR. The code opens no PR when nothing is routable; it prints the items and exits 0. +4. **The `$USER` fallback in propose branch names.** When origin is not on github.com, the branch uses `$USER` instead of the `gh` login. This is not in the spec and exists for the sandbox. +5. **`block mv` hardcodes the `claude-code` adapter for provenance fallback.** The spec is silent on this. +6. **Local branches in the user's Delphi repo.** The PR path creates a local branch there (and force-resets a same-named branch; it fails if that branch is checked out elsewhere). This leaves `delphi/*` branches behind, which strains P1/X4. +7. **Built `.skill` specs as a second skill mechanism.** They need their own routing (R3 special case, R7 unresolved, R10 key rewriting). Is reusing a block as a skill worth that? +8. **The `moves.tsv` machinery.** Rows are counted by line number since `compile_commit`, prefix-rewritten, and applied during refresh. Could `block mv` just rewrite manifests and let workspaces re-resolve? Rewriting also silently drops trailing comments on changed lines. +9. **Fragment routing (R9).** Blank-line splitting, slug generation, `-2` suffixes, and ordering are a lot of rules. Could a new section simply be a hunk that falls outside all blocks and becomes one fragment? +10. **Edge hunk rules.** Prepend after glue, append at a block end when the next segment is not a block, and "one patch per block per propose". Each adds a case; which ones do users actually rely on? +11. **Pending-diff hash normalization** (strip `@@`/`index` lines) plus a `proposed` state that cannot see closed PRs. Would asking `gh` for PR state be simpler and more truthful? +12. **The interactive `layout new` picker duplicates the `delphi-new-layout` skill (`--from`).** The code also offers every file in the scope's own `blocks/`, which the spec does not mention. The spec says a second settings file triggers a re-ask; the code keeps the first. +13. **The numbered workspace picker in WO1,** and the `open` behavior of scanning every workspace for staleness. Both are conveniences with unclear value. +14. **Status extras not in the spec:** the NEXT column, the summary line, the `!` stale marker, and `gone` as a behind value. +15. **`delphi setup` exists because of Git Bash symlink behavior.** A compiled binary may not need it. +16. **The jq-optional MCP check is a bash workaround.** A native implementation can always validate the JSON. +17. **The strict YAML-subset parser.** Should a rewrite keep rejecting everything outside the subset (tabs, flow style, nesting) or accept real YAML? +18. **Adapters as sourced shell files.** Check also has to source every adapter to collect instruction file names. In Rust this could be a built-in table. +19. **Check rules missing from the spec:** stray files in scopes, no symlinks, no empty files, a required trailing newline. Compile also rejects empty sources, which the spec does not mention. +20. **Undocumented meta keys.** Meta stores `harness` and `last_pushed`, and `created`/`last_proposed` are stored as epoch seconds (the spec says dates). +21. **Creating an empty `worktrees/` in every workspace** has no other behavior attached. +22. **Rejected changes reappear.** A closed (rejected) PR's change keeps reappearing in every propose until it is reverted in the workspace. Is that the desired UX, or should rejected hunks be suppressible? +23. **The workspace commit hook (V3) and the `DELPHI_*` env vars go stale** when the model changes mid-session, a known gap. Is per-commit tagging worth it compared with a propose-time flag only? From b56688947015e6c6ea57a3ba5477087b2aa3b207 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:42:36 +0000 Subject: [PATCH 02/17] Cut goals doc down to high-level goals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaADA545v1Fw4im5hHFoTq --- docs/goals.md | 226 ++++++++++++-------------------------------------- 1 file changed, 54 insertions(+), 172 deletions(-) diff --git a/docs/goals.md b/docs/goals.md index 830a199..37ca4e7 100644 --- a/docs/goals.md +++ b/docs/goals.md @@ -1,174 +1,56 @@ # Delphi CLI — Goals -What the CLI must achieve, not how it does it. Use this as the acceptance checklist for any -rewrite. The IDs are stable, so refer to goals by ID. The design spec is -`context/docs/delphi-design.md`. Where the spec and the bash implementation disagree, this file -follows the code and records the difference in section 5. - -## 1. Purpose - -Delphi stores NER's AI-harness context (instructions, knowledge blocks, docs, skills, MCP, settings) -once, in a compressed form organized by the org chart under `context/`. The CLI turns a chosen -combination of that context into a working directory where a harness (Claude Code) runs. It keeps -that directory current as Delphi changes. It sends edits made there back to Delphi as a pull -request, with each edit landing in the source file it came from and a record of the model, harness, -and effort that produced it. - -## 2. Concepts - -| Term | Meaning | -|---|---| -| Scope | A directory under `context/` for an org unit. It has a `scope.yml` and may contain `blocks/`, `docs/`, `harness/`, `layouts/`, and child scopes. | -| Block | A tracked source file, identified by its path relative to `context/`. It can be a basic block, a doc, an instruction fragment, a skill, an MCP fragment, or a settings file. | -| Layout | `/layouts//manifest.yml`. It selects blocks, a harness, and code repos. Its name is unique across the repo. | -| Workspace | A local git repo outside Delphi, compiled from a layout. One layout can have many workspaces. | -| Compile | A deterministic function from (Delphi commit, layout) to files plus a lock. | -| Lock | `.delphi/lock.tsv`. It maps every line range of every compiled file to its source block, or marks it as generated or separator text. | -| Pending diff | Everything the workspace has changed since its latest merged compile. This is what propose sends. | - -## 3. Goals by command - -### Compile -- **C1** The output is determined entirely by the Delphi commit and the layout. The same inputs give byte-identical files and lock. -- **C2** The instruction file is a short Delphi header, then the listed fragments in order, with one blank line between each. It is emitted even when no fragments are listed. -- **C3** Basic blocks are mirrored at `context/`. Docs go to `docs/`. Native skill directories are copied 1:1, keeping the executable bit. The settings file is copied 1:1. -- **C4** A built skill (`.skill` spec) becomes one `SKILL.md`: generated frontmatter (`name`, `description`) followed by the listed body blocks. Its references are copied to `references/`. -- **C5** MCP fragments are wrapped into a single `{"mcpServers": {...}}` file with comma separators. The file is omitted when no fragments are listed. -- **C6** An editable copy of the layout manifest is always emitted at `.delphi/manifest.yml`. -- **C7** Every line of every output (except the lock itself) belongs to exactly one lock segment: a source block with its blob id, a separator (`@glue`), or generated text (`@gen:`). Each source ends with a newline, so segments never share a line. -- **C8** A trailing `/*` glob in `blocks`/`docs` expands to that directory's files, non-recursively and sorted. -- **C9** Compile fails with no partial output on any of these: a missing or empty source, an empty glob, a parse error, two sources mapping to one output, an unsafe path, or an entry under the wrong key (for example a `blocks:` entry outside `blocks/`). - -### workspace new ` [--as ws] [--ref branch]` -- **WN1** Fetches, then finds the layout by name in `origin/` (default `main`) and compiles it at that commit. -- **WN2** Creates `/` (default name = layout name) as a new git repo with no Delphi history. It fails if the directory already exists. -- **WN3** Afterwards the user is on their own branch, with a clean tree equal to the compile. The compile is also recorded as the "latest merged compile" baseline. -- **WN4** Clones each manifest repo into `repos/`. A failed clone produces a warning and a summary at the end, but the workspace is still created. `repos/`, `worktrees/`, and the harness's local-settings file are git-ignored locally. -- **WN5** Installs a commit hook that tags the user's commits with provenance (V3). -- **WN6** `--ref` lets a user try a layout before its PR merges. The workspace then tracks that branch. - -### workspace open `[ws] [--model] [--effort] [--shell]` -- **WO1** Finds the workspace from the argument, then the current directory, then a numbered picker (TTY only). -- **WO2** Warns if the workspace is behind its ref, and if any workspace has been unproposed longer than `stale_days`. -- **WO3** Launches the harness in the workspace with `DELPHI_HARNESS/MODEL/EFFORT` exported. With `--shell`, it opens `$SHELL` instead. - -### workspace refresh `[ws] [--ref branch]` -- **WR1** Brings the workspace up to date with `origin/` by merging the new compile into the user's branch. User edits are kept. -- **WR2** Refuses to run on a dirty tree or while a merge is in progress. `--ref` changes the tracked branch first. -- **WR3** Idempotent and resumable. Re-running picks up where it left off, and there is no `--continue`. A merge conflict exits with **2** and tells the user to resolve it, commit, and re-run. -- **WR4** If the compiled content is unchanged, refresh adds no commits. It still records the new Delphi commit as the baseline and reports "up to date". -- **WR5** Follows block and layout moves recorded since the last compile. The layout path is updated, and moved paths in `.delphi/manifest.yml` are rewritten and committed. -- **WR6** If the tracked branch no longer exists, refresh fails and tells the user to run `refresh --ref main`. -- **WR7** Delphi's own commits in the workspace never receive provenance trailers. - -### workspace status `[--offline]` -- **WS1** Prints one row per workspace: name, layout, ref, dirty, state, age, behind, and a suggested next command. It ends with a summary line. -- **WS2** State is `clean` (empty pending diff), `proposed` (pending diff unchanged since the last push), or `unproposed`. A change that only shifts line numbers does not flip a workspace back to `unproposed`. -- **WS3** Behind means `origin/` has commits since the baseline that touch this workspace's sources, its layout manifest, or a globbed directory. If the branch is gone, the value is `gone`. -- **WS4** Age is shown only for unproposed workspaces. A workspace unproposed longer than `stale_days` is marked stale. `--offline` skips the fetch. - -### workspace propose `[ws] [--dry-run] [--model] [--effort] [--yes]` -- **WP1** `--dry-run` changes nothing. It routes against the current baseline (warning if behind) and prints the plan and the unresolved items with their diffs. -- **WP2** Refuses unless the workspace tracks `main`. Refreshes first and continues only on success (exit 2 propagates). -- **WP3** Each workspace has one live PR, on branch `delphi/propose//`. Every propose rebuilds that branch from the baseline commit with the entire pending diff and force-updates it. Proposing twice never duplicates, and merged changes drop out after the next refresh. -- **WP4** The PR is built in ordered commits: the layout manifest, then block edits, then new files. Each commit carries provenance trailers. `check` must pass, or nothing is pushed. -- **WP5** If there is nothing to propose, it says so and exits 0. It warns if a previously pushed PR is still open. -- **WP6** Refuses to overwrite a propose branch that someone else pushed to. It records what it saw, so the next run overwrites it deliberately. It also refuses when an open PR on the branch belongs to a different `gh` user. -- **WP7** The PR body contains the routed changes, an **Unresolved** section (path, reason, and fenced diff for each item), and the provenance table. Unresolved items never block the PR. -- **WP8** After a push, it records the date, the pending-diff hash (which drives WS2), and the pushed commit. - -### Routing (how propose maps the pending diff back to Delphi) -The lock is read from the baseline. `repos/`, `worktrees/`, and the lock are excluded. Delphi never -guesses: anything it cannot place is unresolved. -- **R1** A modified `.delphi/manifest.yml` replaces the layout manifest. Any other `.delphi/` change is unresolved. -- **R2** Binary files and mode-only changes are unresolved. -- **R3** A deleted output is a no-op if its source was dropped from `.delphi/manifest.yml`, and unresolved otherwise. A file inside a built skill counts as dropped only when its `.skill` spec was dropped. -- **R4** A modified output is routed hunk by hunk (R8–R11). -- **R5** A new `context//blocks/...` file under an existing scope becomes a new block and is added to the manifest's `blocks:` unless an entry or glob already covers it. -- **R6** A new `docs//` file goes beside the compiled docs already in `docs//`, otherwise under `/docs/`, and is added to `docs:` if not covered. -- **R7** A new file in a compiled native skill goes into that skill's source directory. A new skill directory becomes `/harness/skills//` and is added to `skills:`. A new file in a built skill is unresolved. So is a new file whose target already exists upstream. -- **R8** A hunk entirely inside one block becomes a patch to that block. All hunks for one block are combined into a single patch. An insertion at a block's end appends to it. An insertion before a block that follows generated or separator lines prepends to it. -- **R9** **New sections in the instruction file become new fragments.** An insertion at a segment boundary that is set off from the neighbouring blocks by blank lines is written to `/harness/instructions/.md`. The slug comes from the section's first line, with `-2`, `-3`, ... added when the name is taken. The fragment is listed in `instructions:` right after the preceding fragment (first if there is none), and several new fragments keep their file order. Lines that touch a neighbouring block with no blank line between them extend that block instead. -- **R10** Edits to only the `name:`/`description:` lines of a built skill's frontmatter rewrite those keys in the `.skill` spec. -- **R11** These are unresolved: hunks in generated or separator lines, hunks spanning segments, deletion of a whole block via hunks, a second patch to the same block (for example the same block edited in two outputs), and patches that fail to apply. - -### layout new ` [--from file]` / layout list -- **L1** Validates that `` is a scope on `origin/main` and that `` matches `[a-z0-9-]+` and is not already used. -- **L2** With `--from`, uses the given manifest as-is. Its `name` must equal ``, and it must name a harness. -- **L3** Without `--from` (TTY only), offers the scope's and its ancestors' recommendations plus the scope's own blocks, one y/n question each. Answers are sorted into the right key by path, keeping at most one settings file. It asks for the harness when there is more than one adapter, then for repos. -- **L4** Writes the manifest through the PR path on branch `delphi/layout/` after `check` passes, and prints the branch name. -- **L5** `layout list` prints `namescopeharness` for every layout on `origin/main`. - -### block mv ` ` -- **B1** Only paths under a scope's `blocks/`, `docs/`, or `harness/` can be moved. `old` must exist on `origin/main` and `new` must not. -- **B2** Moves the file or directory, appends `oldnewdate` to `moves.tsv`, and rewrites exact and directory-prefix references in every manifest, `scope.yml`, and `.skill`. -- **B3** Runs `check`, then opens a PR on branch `delphi/mv/-` and prints the branch. Workspaces pick up the move on their next refresh (WR5). -- **B4** `moves.tsv` is append-only. Each consumer applies only the rows that are new to it, in order, once each. That means paths can be reused and moves can be reversed. - -### check -- **K1** Lists every violation, then exits non-zero if there are any. It prints "ok" otherwise. -- **K2** Structure: every scope has a `scope.yml`, scopes contain no stray files, `context/` contains no symlinks, and no file is empty or missing its trailing newline. -- **K3** No file under `context/` is named after any adapter's instruction file (`CLAUDE.md`, ...). -- **K4** Every `.yml`/`.skill` parses. Recommendations and `.skill` body/reference paths exist. `.skill` specs have `name` and `description` and no unknown keys. -- **K5** Manifests: `name` equals the directory and is unique, the harness adapter exists, there are no unknown keys, there is at most one settings file, entries sit under the key that matches their location, and every layout compiles. -- **K6** `moves.tsv` rows have three fields. When a JSON tool is available, MCP fragments form valid JSON once wrapped. - -### setup `[dir]` -- **S1** Puts a `delphi` launcher in `dir` (default `~/.local/bin`) that runs this checkout's CLI. It warns if `dir` is not on `PATH`. - -### Harness adapters -- **H1** An adapter supplies only names (instruction file, skills directory, MCP file, settings file, locally ignored file), a provenance fallback (harness+version, model, effort), and a launch action (model, effort). All file logic is generic. -- **H2** Adding a harness means adding one adapter. The `claude-code` adapter uses `CLAUDE.md`, `.claude/skills`, `.mcp.json`, `.claude/settings.json`, and `.claude/settings.local.json`. - -### Provenance -- **V1** Every commit Delphi writes to Delphi carries `Delphi-Harness`, `Delphi-Model`, and `Delphi-Effort` trailers. A propose commit also carries `Delphi-Layout`, `Delphi-Workspace`, and `Delphi-Base`. -- **V2** Each field is resolved from the CLI flag, then the `DELPHI_*` env var, then the adapter fallback, then a prompt (TTY only; `none` is accepted). With no TTY it is an error. A field is never recorded as "unknown". -- **V3** Workspace commits made by the user are tagged from `DELPHI_*` env vars when those are set and the trailers are not already present. -- **V4** The PR body's provenance table lists the proposing session plus each distinct trailer combination from non-merge workspace commits since the workspace was last clean. - -### Writing to Delphi (PR path) -- **P1** Every write to Delphi goes through one path: a temporary worktree on the command's branch, starting from a given commit (default `origin/main`). The user's own Delphi checkout (working tree, index, HEAD) is never modified. -- **P2** Shows the title and body, then asks `Push ... ? [y/N]` unless `--yes` or `DELPHI_YES=1` is set. A non-interactive run without `--yes` fails before doing any work. -- **P3** Pushes the branch, then updates the open PR for it with `gh pr edit`, or creates one against `main` with `gh pr create`. Propose branches are force-pushed with a lease on the last pushed commit. -- **P4** Temporary worktrees and directories are removed on success and on failure. If the user declines, the branch stays committed locally and nothing is pushed. - -## 4. Cross-cutting invariants - -- **X1 Path safety.** Every path from config, flags, the lock, manifests, or `moves.tsv` is rejected if it is absolute, contains `..` or `.` components, or resolves through a symlink to a location outside its root. This check runs before any read, write, or delete. -- **X2 Determinism.** Compile (C1), routing, and the pending-diff hash depend only on git content, never on time or the environment. The only exceptions are dates in `moves.tsv` rows and branch names. -- **X3 Idempotence.** Refresh, propose, and re-running after a failure converge on the same result. Nothing is left half-applied without a message. -- **X4 Hands off the user's checkout.** Delphi is read through `origin/*` refs and temporary worktrees. Workspaces live outside the repo, so Delphi's own `CLAUDE.md` is never an ancestor file of a workspace. -- **X5 Workspace bookkeeping stays inside `.git/`.** It never dirties the working tree or appears in the pending diff. -- **X6 gh is stubbable.** `gh` is used only for identity (`api user`) and PR list/create/edit, so a stub plus a bare origin exercises everything end to end (`dev/sandbox.sh`). -- **X7 Exit codes.** 0 means success or up to date, 1 means error, and 2 means a refresh (or the refresh inside propose) stopped on merge conflicts. -- **X8 Actionable errors.** Messages name the file or line and the command to run next. A non-interactive session never hangs on a prompt. -- **X9 Config.** `delphi.conf` holds `key=value` lines and is never executed. `workspace_root` defaults to `../Delphi-workspaces` relative to the repo, and `DELPHI_WORKSPACE_ROOT` overrides it. `stale_days` defaults to 14. -- **X10 Offline tolerance.** A failed fetch produces a warning, and the command continues with local `origin/*` refs. - -## 5. Open questions / candidates to drop or simplify - -These are flagged, not decided. - -1. **Three records of the baseline.** The `generated` branch, the `generated-merged` tag, and `meta.compile_commit` (with `Delphi-Compile` trailers) overlap. Could one ref plus meta suffice? -2. **Three overlapping push guards.** The `last_pushed` lease pre-check via `ls-remote`, `--force-with-lease`, and the "open PR authored by someone else" check. Is one enough? -3. **Proposing with only unresolved items.** The spec says unresolved items are listed in the PR. The code opens no PR when nothing is routable; it prints the items and exits 0. -4. **The `$USER` fallback in propose branch names.** When origin is not on github.com, the branch uses `$USER` instead of the `gh` login. This is not in the spec and exists for the sandbox. -5. **`block mv` hardcodes the `claude-code` adapter for provenance fallback.** The spec is silent on this. -6. **Local branches in the user's Delphi repo.** The PR path creates a local branch there (and force-resets a same-named branch; it fails if that branch is checked out elsewhere). This leaves `delphi/*` branches behind, which strains P1/X4. -7. **Built `.skill` specs as a second skill mechanism.** They need their own routing (R3 special case, R7 unresolved, R10 key rewriting). Is reusing a block as a skill worth that? -8. **The `moves.tsv` machinery.** Rows are counted by line number since `compile_commit`, prefix-rewritten, and applied during refresh. Could `block mv` just rewrite manifests and let workspaces re-resolve? Rewriting also silently drops trailing comments on changed lines. -9. **Fragment routing (R9).** Blank-line splitting, slug generation, `-2` suffixes, and ordering are a lot of rules. Could a new section simply be a hunk that falls outside all blocks and becomes one fragment? -10. **Edge hunk rules.** Prepend after glue, append at a block end when the next segment is not a block, and "one patch per block per propose". Each adds a case; which ones do users actually rely on? -11. **Pending-diff hash normalization** (strip `@@`/`index` lines) plus a `proposed` state that cannot see closed PRs. Would asking `gh` for PR state be simpler and more truthful? -12. **The interactive `layout new` picker duplicates the `delphi-new-layout` skill (`--from`).** The code also offers every file in the scope's own `blocks/`, which the spec does not mention. The spec says a second settings file triggers a re-ask; the code keeps the first. -13. **The numbered workspace picker in WO1,** and the `open` behavior of scanning every workspace for staleness. Both are conveniences with unclear value. -14. **Status extras not in the spec:** the NEXT column, the summary line, the `!` stale marker, and `gone` as a behind value. -15. **`delphi setup` exists because of Git Bash symlink behavior.** A compiled binary may not need it. -16. **The jq-optional MCP check is a bash workaround.** A native implementation can always validate the JSON. -17. **The strict YAML-subset parser.** Should a rewrite keep rejecting everything outside the subset (tabs, flow style, nesting) or accept real YAML? -18. **Adapters as sourced shell files.** Check also has to source every adapter to collect instruction file names. In Rust this could be a built-in table. -19. **Check rules missing from the spec:** stray files in scopes, no symlinks, no empty files, a required trailing newline. Compile also rejects empty sources, which the spec does not mention. -20. **Undocumented meta keys.** Meta stores `harness` and `last_pushed`, and `created`/`last_proposed` are stored as epoch seconds (the spec says dates). -21. **Creating an empty `worktrees/` in every workspace** has no other behavior attached. -22. **Rejected changes reappear.** A closed (rejected) PR's change keeps reappearing in every propose until it is reverted in the workspace. Is that the desired UX, or should rejected hunks be suppressible? -23. **The workspace commit hook (V3) and the `DELPHI_*` env vars go stale** when the model changes mid-session, a known gap. Is per-commit tagging worth it compared with a propose-time flag only? +What the CLI must achieve, not how. Any rewrite or simplification must keep these. +Details live in the spec (`context/docs/delphi-design.md`). + +## Purpose + +Store NER's AI context (instructions, blocks, docs, skills, MCP, settings) once, organized by the +org chart. Let anyone turn a chosen set of it into a working directory, keep that directory current, +and send improvements back — with or without an AI harness. + +## Goals + +**G1. Compile.** A layout (a manifest picking context + a harness) compiles into a workspace's +files. Same Delphi commit + same layout = identical output. Every output line is traceable to its +source file (the lock). + +**G2. Workspaces are independent.** Each workspace is its own git repo outside Delphi, where the +user works freely. Many workspaces can come from one layout. Code repos listed in the layout are +cloned into it. + +**G3. Refresh.** Pull the latest Delphi `main` into a workspace without losing the user's edits. +Conflicts are shown with git's normal tools. Re-running always picks up where it left off. +Renamed/moved blocks are followed. + +**G4. Know what changed.** At any time, show what the user changed versus what came from Delphi, +and whether those changes have been proposed yet. + +**G5. Propose.** Send the workspace's changes back as one pull request per workspace. Each edit +lands in the source file it came from; new files and new instruction sections become new blocks. +Anything Delphi can't place is listed in the PR, never guessed. + +**G6. Provenance.** Every change written to Delphi records which harness, model, and effort made +it. + +**G7. Keep Delphi valid.** `check` catches broken manifests, missing paths, and bad structure +before anything is pushed. Layouts and block moves are created through PRs too. + +**G8. Harness-agnostic.** Supporting a new harness (beyond Claude Code) means adding one small +adapter: file names plus how to launch it. + +## Invariants + +- **I1.** Never modify the user's own Delphi checkout; all writes go through PRs. +- **I2.** Reject unsafe paths (absolute, `..`, symlinks escaping) before any read or write. +- **I3.** Workspace bookkeeping never shows up as a user change. +- **I4.** Clear errors that say what to run next; never hang waiting for input in scripts. +- **I5.** Works offline where possible; GitHub (`gh`) is only needed to open PRs. +- **I6.** Easy to install (`cargo install`) and to test end to end without real GitHub. + +## Open questions + +- Should "proposed" come from GitHub's PR state instead of a local hash? +- Should a rejected change stop reappearing without reverting it? +- Keep built `.skill` specs, or only native skill folders? +- Keep the strict YAML subset, or accept real YAML? From 54d289c34ce9eddd2185d451040d9c7f10a436d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:50:38 +0000 Subject: [PATCH 03/17] Port the Delphi CLI to Rust (parity with bash) Cargo crate at the repo root (binary: delphi, dep: anyhow). Same commands, files, refs and outputs; still shells out to git and gh. Repo root comes from DELPHI_ROOT, the current directory, or the path saved by 'delphi setup'. Integration tests run bash and Rust on the same sandbox and compare outputs. The bash CLI stays in place until the port is adopted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaADA545v1Fw4im5hHFoTq --- .gitignore | 1 + Cargo.lock | 16 + Cargo.toml | 13 + rustfmt.toml | 2 + src/block.rs | 72 +++++ src/check.rs | 257 +++++++++++++++ src/compile.rs | 224 +++++++++++++ src/core.rs | 647 +++++++++++++++++++++++++++++++++++++ src/harness.rs | 63 ++++ src/layout.rs | 206 ++++++++++++ src/main.rs | 82 +++++ src/parse.rs | 139 ++++++++ src/pr.rs | 113 +++++++ src/provenance.rs | 87 +++++ src/route.rs | 753 ++++++++++++++++++++++++++++++++++++++++++++ src/setup.rs | 41 +++ src/workspace.rs | 678 +++++++++++++++++++++++++++++++++++++++ tests/cli.rs | 632 +++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 286 +++++++++++++++++ 19 files changed, 4312 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 rustfmt.toml create mode 100644 src/block.rs create mode 100644 src/check.rs create mode 100644 src/compile.rs create mode 100644 src/core.rs create mode 100644 src/harness.rs create mode 100644 src/layout.rs create mode 100644 src/main.rs create mode 100644 src/parse.rs create mode 100644 src/pr.rs create mode 100644 src/provenance.rs create mode 100644 src/route.rs create mode 100644 src/setup.rs create mode 100644 src/workspace.rs create mode 100644 tests/cli.rs create mode 100644 tests/common/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2d49b21 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "delphi" +version = "0.1.0" +dependencies = [ + "anyhow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a1b53c4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "delphi" +version = "0.1.0" +edition = "2021" +description = "Delphi CLI: compile layouts into workspaces, refresh them, propose edits back as PRs" +publish = false + +[[bin]] +name = "delphi" +path = "src/main.rs" + +[dependencies] +anyhow = "1" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..c775577 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +use_small_heuristics = "Max" diff --git a/src/block.rs b/src/block.rs new file mode 100644 index 0000000..e5a6864 --- /dev/null +++ b/src/block.rs @@ -0,0 +1,72 @@ +//! `delphi block mv `: move a block, log it in moves.tsv, rewrite references. Port of +//! lib/block.sh. + +use crate::check::check_tree; +use crate::core::{ + basename, date, delphi_commit, delphi_fetch, find, git_c, glob, ok, parse_args, parse_moves, path_ok, + rewrite_moves, safe_path, +}; +use crate::{die, provenance}; +use anyhow::Result; +use std::fs::{self, OpenOptions}; +use std::io::Write; + +pub fn main(args: &[String]) -> Result<()> { + let verb = args.first().map(String::as_str).unwrap_or(""); + let (o, pos) = parse_args("--model --effort --yes", args.get(1..).unwrap_or(&[]))?; + if verb != "mv" || pos.len() != 2 { + die!("usage: delphi block mv "); + } + let t = |s: &str| s.strip_suffix('/').unwrap_or(s).to_string(); + mv(&t(&pos[0]), &t(&pos[1]), &o.model, &o.effort) +} + +fn mv(old: &str, new: &str, model: &str, effort: &str) -> Result<()> { + for p in [old, new] { + if !path_ok(p) { + die!("unsafe path: '{p}'"); + } + let s = format!("/{p}"); + if !(glob("*/blocks/?*", &s) || glob("*/docs/?*", &s) || glob("*/harness/?*", &s)) { + die!("'{p}' is not under a scope's blocks/, docs/, or harness/"); + } + } + delphi_fetch(); + let c = delphi_commit("main")?; + let prov = provenance::resolve(model, effort, "claude-code")?; + let mut pr = crate::pr::begin(&format!("delphi/mv/{}-{}", basename(old), date("%Y%m%d")), &c, prov)?; + let ctx = pr.wt.join("context"); + let src = safe_path(&ctx, old)?; + let dst = safe_path(&ctx, new)?; + if !src.exists() { + die!("no such block on origin/main: {old}"); + } + if dst.exists() { + die!("already exists on origin/main: {new}"); + } + let moved = dst.parent().is_some_and(|d| fs::create_dir_all(d).is_ok()) + && ok(git_c(&pr.wt).args(["mv", &format!("context/{old}"), &format!("context/{new}")])); + if !moved { + die!("git mv failed"); + } + let row = format!("{old}\t{new}\t{}\n", date("%Y-%m-%d")); + OpenOptions::new().create(true).append(true).open(pr.wt.join("moves.tsv"))?.write_all(row.as_bytes())?; + let rows = parse_moves(&row); + for (f, ft) in find(&ctx) { + let n = f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if ft.is_file() && (n == "manifest.yml" || n == "scope.yml" || glob("*.skill", &n)) { + let _ = rewrite_moves(&rows, &f); + } + } + if !check_tree(&pr.wt)? { + die!("check failed after the move; nothing pushed"); + } + let title = format!("delphi: move {old} -> {new}"); + if !pr.commit(&title, "")? { + die!("nothing to commit"); + } + let body = format!("Moves `{old}` to `{new}` and rewrites references. Workspaces follow on their next refresh."); + pr.finish(&title, &body, None)?; + println!("{}", pr.branch); + Ok(()) +} diff --git a/src/check.rs b/src/check.rs new file mode 100644 index 0000000..7531e00 --- /dev/null +++ b/src/check.rs @@ -0,0 +1,257 @@ +//! Repo-wide validation (port of lib/check.sh). `check_tree(root)` prints every violation and +//! returns false if any. Layouts are validated by compiling them; the rules here cover what +//! compile doesn't enforce. + +use crate::compile::compile; +use crate::core::{basename, find, find_into, glob, glob_dir, have, make_tmp, path_ok, root, Exit, Raw}; +use crate::harness::HARNESSES; +use crate::parse::parse_yaml; +use crate::{die, info}; +use anyhow::Result; +use std::fs; +use std::path::Path; +use std::process::{Command, Stdio}; + +pub fn main(args: &[String]) -> Result<()> { + if !args.is_empty() { + die!("usage: delphi check"); + } + if check_tree(root())? { + info!("check: ok"); + Ok(()) + } else { + Err(Exit(1).into()) + } +} + +struct Ck<'a> { + root: &'a str, + errs: Vec, +} + +impl Ck<'_> { + fn e(&mut self, m: String) { + self.errs.push(m); + } + + /// `${f#$root/}`. + fn rel(&self, p: &Path) -> String { + let s = p.to_string_lossy(); + s.strip_prefix(&format!("{}/", self.root)).unwrap_or(&s).to_string() + } + + /// Placement rule for the kind (instructions|skills|mcp|settings|body); `body` (skill specs) + /// and `any` (recommendations) must also exist. + fn entry(&mut self, f: &str, key: &str, p: &str, kind: &str) { + if !path_ok(p.strip_suffix("/*").unwrap_or(p)) { + return self.e(format!("{f}: {key}: unsafe path '{p}'")); + } + let want = match kind { + "instructions" => "*/harness/instructions/*", + "skills" => "*/harness/skills/*", + "mcp" => "*/harness/mcp/*.json", + "settings" => "*/harness/settings/*", + "body" => "*/blocks/*", + _ => "*", + }; + if !glob(want, &format!("/{p}")) { + let w = want.strip_prefix("*/").unwrap_or(want); + self.e(format!("{f}: {key}: '{p}' is not under {w}")); + } + if matches!(kind, "body" | "any") && !Path::new(&format!("{}/context/{p}", self.root)).exists() { + self.e(format!("{f}: {key}: missing '{p}'")); + } + } +} + +pub fn check_tree(root_dir: &Path) -> Result { + let root_s = root_dir.to_string_lossy().into_owned(); + let mut ck = Ck { root: &root_s, errs: vec![] }; + let ctx = Path::new(&format!("{root_s}/context")).to_path_buf(); + if !ctx.is_dir() { + eprintln!("missing context/ directory"); + return Ok(false); + } + + // scopes: every non-reserved directory needs scope.yml + let reserved = |p: &Path| matches!(basename(&p.to_string_lossy()), "blocks" | "docs" | "harness" | "layouts"); + let mut dirs = vec![]; + find_into(&ctx, &reserved, &mut dirs); + let dirs = std::iter::once(ctx.clone()).chain(dirs.into_iter().filter(|(_, ft)| ft.is_dir()).map(|(p, _)| p)); + for d in dirs { + if !d.join("scope.yml").is_file() { + ck.e(format!("{}: scope directory has no scope.yml", ck.rel(&d))); + } + for n in glob_dir(&d) { + if d.join(&n).is_file() && n != "scope.yml" { + let r = ck.rel(&d.join(&n)); + ck.e(format!( + "{r}: stray file (scopes hold only scope.yml, blocks/, docs/, harness/, layouts/, child scopes)" + )); + } + } + } + + // files: no symlinks, no empty files, trailing newline, no harness instruction file names + let all = find(&ctx); + let links: Vec = + all.iter().filter(|(_, ft)| ft.is_symlink()).map(|(p, _)| format!("{} ", ck.rel(p))).collect(); + if !links.is_empty() { + ck.e(format!("context/ must not contain symlinks: {}", links.concat())); + } + let files: Vec<&Path> = all.iter().filter(|(_, ft)| ft.is_file()).map(|(p, _)| p.as_path()).collect(); + for f in &files { + let r = ck.rel(f); + let data = fs::read(f).unwrap_or_default(); + if data.is_empty() { + ck.e(format!("{r}: empty file")); + continue; + } + if data.last() != Some(&b'\n') { + ck.e(format!("{r}: missing trailing newline")); + } + let name = basename(&r); + if HARNESSES.iter().any(|h| h.instructions == name) { + ck.e(format!("{r}: harness instruction file names are not allowed under context/")); + } + } + let named = |pat: &str| -> Vec<&Path> { + files.iter().copied().filter(|f| glob(pat, basename(&f.to_string_lossy()))).collect() + }; + + // every yml/skill parses + for f in files.iter().filter(|f| { + let n = f.to_string_lossy(); + glob("*.yml", basename(&n)) || glob("*.skill", basename(&n)) + }) { + if let Err(e) = parse_yaml(f) { + ck.e(e.to_string()); + } + } + + // scope recommendations exist + for f in named("scope.yml") { + let Ok(recs) = parse_yaml(f) else { continue }; + let r = ck.rel(f); + for p in recs.list("recommend").into_iter().filter(|p| !p.is_empty()) { + ck.entry(&r, "recommend", &p, "any"); + } + } + + // skill specs + for f in named("*.skill") { + let Ok(recs) = parse_yaml(f) else { continue }; + let r = ck.rel(f); + if recs.get("name").is_empty() { + ck.e(format!("{r}: missing name")); + } + if recs.get("description").is_empty() { + ck.e(format!("{r}: missing description")); + } + for k in recs.keys() { + if !matches!(k.as_str(), "name" | "description" | "body" | "references") { + ck.e(format!("{r}: unknown key '{k}'")); + } + } + for p in recs.list("body").into_iter().chain(recs.list("references")).filter(|p| !p.is_empty()) { + ck.entry(&r, "body", &p, "body"); + } + } + + // layouts + let tmp = make_tmp()?; + let mut names = vec![String::new()]; + let mut i = 0; + for f in files.iter().filter(|f| glob("*/layouts/*/manifest.yml", &f.to_string_lossy())) { + let Ok(recs) = parse_yaml(f) else { continue }; + let r = ck.rel(f); + let name = recs.get("name"); + let dir = f.parent().map(|p| basename(&p.to_string_lossy()).to_string()).unwrap_or_default(); + if name.is_empty() { + ck.e(format!("{r}: missing name")); + } + if name != dir { + ck.e(format!("{r}: name '{name}' must equal its directory '{dir}'")); + } + if names.contains(&name) { + ck.e(format!("{r}: layout name '{name}' is not unique")); + } + names.push(name); + let h = recs.get("harness"); + if h.is_empty() { + ck.e(format!("{r}: missing harness")); + } else if !HARNESSES.iter().any(|x| x.name == h) { + ck.e(format!("{r}: unknown harness '{h}'")); + } else { + // compile it: catches missing paths, empty globs, duplicate outputs, bad skill specs + i += 1; + let out = tmp.join(format!("errs.{i}")); + fs::create_dir(&out)?; + let lay = r.strip_prefix("context/").unwrap_or(&r); + let lay = &lay[..lay.rfind('/').unwrap_or(0)]; + if let Err(e) = compile(root_dir, lay, &out) { + match e.downcast_ref::() { + Some(raw) => ck.e(raw.0.clone()), + None => ck.e(format!("{r}: {e:#}")), + } + } + } + for k in recs.keys() { + let known = ["name", "harness", "instructions", "blocks", "docs", "skills", "mcp", "settings", "repos"]; + if !known.contains(&k.as_str()) { + ck.e(format!("{r}: unknown key '{k}'")); + } + } + if recs.list("settings").iter().filter(|s| !s.trim().is_empty()).count() > 1 { + ck.e(format!("{r}: at most one settings file")); + } + for k in ["instructions", "skills", "mcp", "settings"] { + for p in recs.list(k).into_iter().filter(|p| !p.is_empty()) { + ck.entry(&r, k, &p, k); + } + } + } + + // moves.tsv: three fields + if let Ok(m) = fs::read_to_string(format!("{root_s}/moves.tsv")) { + for (n, l) in crate::core::awk_lines(&m).into_iter().enumerate() { + if !l.starts_with('#') && !l.is_empty() && l.split('\t').count() != 3 { + ck.e(format!("moves.tsv:{}: expected oldnewdate", n + 1)); + } + } + } + + // MCP fragments are valid JSON (only if jq is installed) + if have("jq") { + for f in files.iter().filter(|f| glob("*/harness/mcp/*.json", &f.to_string_lossy())) { + let mut doc = b"{".to_vec(); + doc.extend(fs::read(f).unwrap_or_default()); + doc.push(b'}'); + let valid = Command::new("jq") + .arg("empty") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .and_then(|mut c| { + use std::io::Write; + if let Some(mut s) = c.stdin.take() { + let _ = s.write_all(&doc); + } + c.wait() + }) + .is_ok_and(|s| s.success()); + if !valid { + let r = ck.rel(f); + ck.e(format!("{r}: not a valid mcpServers member")); + } + } + } else { + info!("check: jq not installed; skipping MCP JSON validation"); + } + + for e in &ck.errs { + eprintln!("check: {e}"); + } + Ok(ck.errs.is_empty()) +} diff --git a/src/compile.rs b/src/compile.rs new file mode 100644 index 0000000..5f2f810 --- /dev/null +++ b/src/compile.rs @@ -0,0 +1,224 @@ +//! Layout -> workspace files + `.delphi/lock.tsv` (segment map). Deterministic. Port of +//! lib/compile.sh. +//! +//! `compile(src, layout, out)`: `src` is a checked-out Delphi tree (usually a temp worktree at a +//! specific commit), `layout` the layout dir relative to context/, `out` an empty directory. +//! Returns the layout's harness. + +use crate::core::{basename, find, glob, glob_dir, out_q, safe_path}; +use crate::harness::{self, Harness}; +use crate::parse::parse_yaml; +use anyhow::Result; +use std::fmt::Write as _; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +struct Compiler<'a> { + ctx: PathBuf, + out: &'a Path, + lock: String, +} + +fn count(p: &Path) -> usize { + fs::read(p).map(|b| b.iter().filter(|&&c| c == b'\n').count()).unwrap_or(0) +} + +fn append(p: &Path, data: &[u8]) -> Result<()> { + if let Some(d) = p.parent() { + fs::create_dir_all(d)?; + } + OpenOptions::new().create(true).append(true).open(p)?.write_all(data)?; + Ok(()) +} + +impl Compiler<'_> { + fn seg(&mut self, out: &str, start: usize, end: usize, src: &str, sha: &str) { + let _ = writeln!(self.lock, "{out}\t{start}\t{end}\t{src}\t{sha}"); + } + + /// Append a source file (relative to context/) to an output file. + fn file(&mut self, out: &str, src: &str) -> Result<()> { + let o = self.out.join(out); + let s = safe_path(&self.ctx, src)?; + if !s.is_file() { + crate::die!("compile: missing file context/{src}"); + } + let data = fs::read(&s)?; + if data.is_empty() { + crate::die!("compile: empty file context/{src}"); + } + let start = count(&o) + 1; + append(&o, &data)?; + if data.last() != Some(&b'\n') { + append(&o, b"\n")?; + } + let sha = out_q(Command::new("git").arg("hash-object").arg(&s)).unwrap_or_default(); + self.seg(out, start, count(&o), src, &sha); + Ok(()) + } + + /// 1:1 copy (keeps the executable bit); the output path must not already exist. + fn copy(&mut self, out: &str, src: &str) -> Result<()> { + if self.out.join(out).exists() { + crate::die!("compile: two sources map to the same output '{out}'"); + } + self.file(out, src)?; + let exec = fs::metadata(self.ctx.join(src)).map(|m| m.permissions().mode() & 0o111 != 0).unwrap_or(false); + if exec { + let o = self.out.join(out); + let mode = fs::metadata(&o)?.permissions().mode(); + if fs::set_permissions(&o, fs::Permissions::from_mode(mode | 0o111)).is_err() { + crate::die!("compile: cannot chmod {out}"); + } + } + Ok(()) + } + + /// Append generated (`@gen:…`) or separator (`@glue`) lines. + fn text(&mut self, out: &str, label: &str, text: &str) -> Result<()> { + let o = self.out.join(out); + let start = count(&o) + 1; + append(&o, format!("{text}\n").as_bytes())?; + self.seg(out, start, count(&o), label, "-"); + Ok(()) + } + + /// The files an entry names (a file, or a trailing `/*` glob). + fn expand(&self, entry: &str) -> Result> { + let Some(dir) = entry.strip_suffix("/*") else { return Ok(vec![entry.to_string()]) }; + let abs = safe_path(&self.ctx, dir)?; + if !abs.is_dir() { + crate::die!("compile: no such directory context/{dir}"); + } + let v: Vec = + glob_dir(&abs).into_iter().filter(|n| abs.join(n).is_file()).map(|n| format!("{dir}/{n}")).collect(); + if v.is_empty() { + crate::die!("compile: glob matches nothing: {entry}"); + } + Ok(v) + } +} + +fn header(name: &str) -> String { + format!( + "\n\ + " + ) +} + +pub fn compile(src: &Path, layout: &str, out: &Path) -> Result<&'static Harness> { + if fs::create_dir_all(out.join(".delphi")).is_err() { + crate::die!("compile: cannot write {}", out.display()); + } + let mut c = Compiler { ctx: src.join("context"), out, lock: String::new() }; + let mf = safe_path(&c.ctx, &format!("{layout}/manifest.yml"))?; + let recs = parse_yaml(&mf)?; + let hname = recs.get("harness"); + if hname.is_empty() { + crate::die!("compile: {layout}/manifest.yml has no harness"); + } + let h = harness::load(&hname)?; + let nonempty = |k: &str| recs.list(k).into_iter().filter(|s| !s.is_empty()).collect::>(); + + // instructions: header, then fragments separated by one blank line + c.text(h.instructions, "@gen:delphi", &header(&recs.get("name")))?; + for item in nonempty("instructions") { + c.text(h.instructions, "@glue", "")?; + c.file(h.instructions, &item)?; + } + + // blocks: mirrored at context/ + for item in nonempty("blocks") { + for f in c.expand(&item)? { + if !glob("*/blocks/*", &format!("/{f}")) { + crate::die!("compile: '{f}' is listed under blocks: but is not in a blocks/ directory"); + } + c.copy(&format!("context/{f}"), &f)?; + } + } + + // docs: at docs/ + for item in nonempty("docs") { + for f in c.expand(&item)? { + let sub = format!("/{f}"); + if !glob("*/docs/?*", &sub) { + crate::die!("compile: '{f}' is listed under docs: but is not in a docs/ directory"); + } + let below = &sub[sub.find("/docs/").unwrap_or(0) + 6..]; + c.copy(&format!("docs/{below}"), &f)?; + } + } + + // skills: native dirs copied 1:1; .skill specs assembled + for item in nonempty("skills") { + if item.ends_with(".skill") { + let spec = safe_path(&c.ctx, &item)?; + let srecs = parse_yaml(&spec)?; + let (sname, sdesc) = (srecs.get("name"), srecs.get("description")); + if sname.is_empty() || sdesc.is_empty() { + crate::die!("compile: {item} needs name and description"); + } + let skdir = format!("{}/{sname}", h.skills_dir); + if out.join(&skdir).exists() { + crate::die!("compile: two skills named '{sname}'"); + } + let md = format!("{skdir}/SKILL.md"); + c.text(&md, &format!("@gen:{item}"), &format!("---\nname: {sname}\ndescription: {sdesc}\n---"))?; + for f in srecs.list("body").into_iter().filter(|s| !s.is_empty()) { + c.text(&md, "@glue", "")?; + c.file(&md, &f)?; + } + for f in srecs.list("references").into_iter().filter(|s| !s.is_empty()) { + c.copy(&format!("{skdir}/references/{}", basename(&f)), &f)?; + } + } else { + let abs = safe_path(&c.ctx, &item)?; + if !abs.join("SKILL.md").is_file() { + crate::die!("compile: skill '{item}' has no SKILL.md"); + } + let skdir = format!("{}/{}", h.skills_dir, basename(&item)); + if out.join(&skdir).exists() { + crate::die!("compile: two skills named '{}'", basename(&item)); + } + let mut subs: Vec = find(&abs) + .into_iter() + .filter(|(_, ft)| ft.is_file()) + .map(|(p, _)| crate::core::rel_to(&p, &abs)) + .collect(); + subs.sort(); + for sub in subs { + c.copy(&format!("{skdir}/{sub}"), &format!("{item}/{sub}"))?; + } + } + } + + // mcp: fragments wrapped in {"mcpServers": { … }}, omitted when empty + let mut first = true; + for item in nonempty("mcp") { + if first { + c.text(h.mcp_file, "@gen:delphi", "{\"mcpServers\": {")?; + first = false; + } else { + c.text(h.mcp_file, "@glue", ",")?; + } + c.file(h.mcp_file, &item)?; + } + if !first { + c.text(h.mcp_file, "@gen:delphi", "}}")?; + } + + // settings: single file copied 1:1 + let item = recs.get("settings"); + if !item.is_empty() { + c.copy(h.settings_file, &item)?; + } + + // the layout manifest itself, editable in the workspace + c.copy(".delphi/manifest.yml", &format!("{layout}/manifest.yml"))?; + + fs::write(out.join(".delphi/lock.tsv"), format!("# output\tstart\tend\tsource\tsha\n{}", c.lock))?; + Ok(h) +} diff --git a/src/core.rs b/src/core.rs new file mode 100644 index 0000000..3ac05c9 --- /dev/null +++ b/src/core.rs @@ -0,0 +1,647 @@ +//! Shared helpers (port of lib/core.sh): messages, deferred cleanup, prompts, config, repo-root +//! discovery, path safety, Delphi git access, moves, flag parsing, and small awk/sh equivalents. + +use anyhow::Result; +use std::collections::hash_map::RandomState; +use std::fmt; +use std::fs; +use std::hash::BuildHasher; +use std::io::{self, BufRead, IsTerminal, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +/// Return an error printed as `delphi: ` (exit status 1). +#[macro_export] +macro_rules! die { + ($($t:tt)*) => { return Err(anyhow::anyhow!($($t)*)) }; +} + +/// Print `delphi: warning: ` to stderr. +#[macro_export] +macro_rules! warn { + ($($t:tt)*) => { eprintln!("delphi: warning: {}", format!($($t)*)) }; +} + +/// Print a plain message to stderr. +#[macro_export] +macro_rules! info { + ($($t:tt)*) => { eprintln!($($t)*) }; +} + +/// Exit silently with this status (any message was already printed). +#[derive(Debug)] +pub struct Exit(pub i32); +impl fmt::Display for Exit { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "exit {}", self.0) + } +} +impl std::error::Error for Exit {} + +/// An error printed verbatim, without the `delphi: ` prefix (e.g. `file:line: msg`). +#[derive(Debug)] +pub struct Raw(pub String); +impl fmt::Display for Raw { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for Raw {} + +// ---- globals ---- +static ROOT: OnceLock = OnceLock::new(); +pub static YES: AtomicBool = AtomicBool::new(false); +pub static OFFLINE: AtomicBool = AtomicBool::new(false); + +/// The Delphi repo root (DELPHI_ROOT). +pub fn root() -> &'static Path { + ROOT.get().expect("Delphi root not resolved") +} +pub fn yes() -> bool { + YES.load(Ordering::Relaxed) +} +pub fn offline() -> bool { + OFFLINE.load(Ordering::Relaxed) +} + +/// `$XDG_CONFIG_HOME/delphi/root` (default `~/.config/delphi/root`), written by `delphi setup`. +pub fn root_config_file() -> PathBuf { + let base = env_nonempty("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env_nonempty("HOME").unwrap_or_default()).join(".config")); + base.join("delphi").join("root") +} + +pub fn is_delphi_repo(d: &Path) -> bool { + d.join("delphi.conf").is_file() && d.join("context").is_dir() +} + +/// Resolve the repo root: DELPHI_ROOT, else the nearest ancestor of the current directory +/// holding delphi.conf and context/, else the path recorded by `delphi setup`. +pub fn resolve_root() -> Result<()> { + let found = if let Some(r) = env_nonempty("DELPHI_ROOT") { + match fs::canonicalize(&r) { + Ok(p) if p.is_dir() => p, + _ => die!("DELPHI_ROOT is not a directory: {r}"), + } + } else if let Some(p) = cwd().ancestors().find(|d| is_delphi_repo(d)) { + fs::canonicalize(p)? + } else { + let f = root_config_file(); + let rec = fs::read_to_string(&f).unwrap_or_default(); + let rec = rec.trim(); + match fs::canonicalize(rec) { + Ok(p) if !rec.is_empty() && is_delphi_repo(&p) => p, + _ => die!( + "cannot find the Delphi repo: set DELPHI_ROOT, run inside a Delphi checkout, or run 'delphi setup' in one" + ), + } + }; + std::env::set_var("DELPHI_ROOT", &found); + let _ = ROOT.set(found); + Ok(()) +} + +/// The current directory, preferring a valid logical $PWD like the shell does. +pub fn cwd() -> PathBuf { + use std::os::unix::fs::MetadataExt; + let phys = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + if let Some(p) = env_nonempty("PWD") { + let p = PathBuf::from(p); + if let (true, Ok(a), Ok(b)) = (p.is_absolute(), fs::metadata(&p), fs::metadata(".")) { + if a.dev() == b.dev() && a.ino() == b.ino() { + return p; + } + } + } + phys +} + +pub fn env_nonempty(k: &str) -> Option { + std::env::var(k).ok().filter(|v| !v.is_empty()) +} + +// ---- cleanup: deferred actions run LIFO before exit ---- +type Deferred = Box; +static DEFERRED: Mutex> = Mutex::new(Vec::new()); + +pub fn defer(f: impl FnOnce() + Send + 'static) { + DEFERRED.lock().unwrap().push(Box::new(f)); +} + +pub fn run_deferred() { + loop { + let f = DEFERRED.lock().unwrap().pop(); + match f { + Some(f) => f(), + None => break, + } + } +} + +/// New temp dir, removed on exit. +pub fn make_tmp() -> Result { + let base = env_nonempty("TMPDIR").unwrap_or_else(|| "/tmp".into()); + const CH: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + for i in 0..100u32 { + let mut x = RandomState::new().hash_one((std::process::id(), i)); + let suffix: String = (0..6) + .map(|_| { + let c = CH[(x % CH.len() as u64) as usize] as char; + x /= CH.len() as u64; + c + }) + .collect(); + let p = PathBuf::from(format!("{base}/delphi.{suffix}")); + match fs::create_dir(&p) { + Ok(()) => { + let _ = fs::set_permissions(&p, fs::Permissions::from_mode(0o700)); + let q = p.clone(); + defer(move || { + let _ = fs::remove_dir_all(&q); + }); + return Ok(p); + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(_) => break, + } + } + die!("mktemp failed") +} + +// ---- prompts (stdin + stderr) ---- +pub fn is_tty() -> bool { + io::stdin().is_terminal() && io::stderr().is_terminal() +} + +fn read_answer(prompt: &str) -> Option { + eprint!("{prompt}"); + let _ = io::stderr().flush(); + let mut s = String::new(); + match io::stdin().lock().read_line(&mut s) { + Ok(0) | Err(_) => None, + Ok(_) => Some(s.trim_end_matches('\n').trim_matches([' ', '\t']).to_string()), + } +} + +/// Yes if --yes / DELPHI_YES=1; fails fast when non-interactive. +pub fn confirm(q: &str) -> Result { + if yes() { + return Ok(true); + } + if !is_tty() { + die!("non-interactive session: re-run with --yes (or DELPHI_YES=1)"); + } + Ok(matches!(read_answer(&format!("{q} [y/N] ")).as_deref(), Some("y" | "Y" | "yes"))) +} + +/// Ask a question; empty answer gives the default. +pub fn ask(q: &str, default: &str) -> Result { + if !is_tty() { + die!("non-interactive session: cannot ask '{q}'"); + } + let prompt = if default.is_empty() { format!("{q} ") } else { format!("{q} [{default}] ") }; + match read_answer(&prompt) { + None => Err(Exit(1).into()), + Some(a) if a.is_empty() => Ok(default.to_string()), + Some(a) => Ok(a), + } +} + +// ---- config ---- +/// Reads delphi.conf (key=value lines; `#` comments). +pub fn conf_get(key: &str, default: &str) -> String { + let text = fs::read_to_string(root().join("delphi.conf")).unwrap_or_default(); + let v = awk_lines(&text) + .into_iter() + .filter(|l| !l.starts_with('#')) + .map(|l| l.split_once('=').unwrap_or((l, l))) + .find(|(k, _)| *k == key) + .map(|(_, v)| v.to_string()) + .unwrap_or_default(); + if v.is_empty() { + default.to_string() + } else { + v + } +} + +pub fn workspace_root() -> PathBuf { + let r = env_nonempty("DELPHI_WORKSPACE_ROOT").unwrap_or_else(|| conf_get("workspace_root", "../Delphi-workspaces")); + let r = if r.starts_with('/') { r } else { format!("{}/{r}", root().display()) }; + let p = PathBuf::from(&r); + if p.is_dir() { + return fs::canonicalize(&p).unwrap_or(p); + } + if let Some(i) = r.rfind('/') { + let (dir, base) = (&r[..i], &r[i + 1..]); + if !dir.is_empty() && Path::new(dir).is_dir() { + if let Ok(d) = fs::canonicalize(dir) { + return d.join(base); + } + } + } + p +} + +// ---- path safety ---- +/// Relative, no `..` or `.` components, not empty. +pub fn path_ok(rel: &str) -> bool { + if rel.is_empty() || rel.starts_with('/') { + return false; + } + let s = format!("/{rel}/"); + !(s.contains("/../") || s.contains("/./") || s.contains("//")) +} + +/// `/` after proving it stays inside `` (lexically and through symlinks). +pub fn safe_path(root: &Path, rel: &str) -> Result { + if !path_ok(rel) { + die!("unsafe path: '{rel}'"); + } + let rroot = match fs::canonicalize(root) { + Ok(p) if p.is_dir() => p, + _ => die!("missing directory: {}", root.display()), + }; + let full = PathBuf::from(format!("{}/{rel}", rroot.display())); + let mut probe = full.clone(); + if fs::symlink_metadata(&probe).map(|m| m.file_type().is_symlink()).unwrap_or(false) { + die!("unsafe path (symlink): '{rel}'"); + } + while !probe.exists() { + probe = probe.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("/")); + } + if !probe.is_dir() { + probe = probe.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("/")); + } + let real = fs::canonicalize(&probe)?; + if !format!("{}/", real.display()).starts_with(&format!("{}/", rroot.display())) { + die!("unsafe path (escapes {}): '{rel}'", root.display()); + } + Ok(full) +} + +// ---- running commands ---- +/// `git -c core.quotePath=false -C `. +pub fn git_in(dir: &Path) -> Command { + let mut c = Command::new("git"); + c.args(["-c", "core.quotePath=false", "-C"]).arg(dir); + c +} + +/// Plain `git -C `. +pub fn git_c(dir: &Path) -> Command { + let mut c = Command::new("git"); + c.arg("-C").arg(dir); + c +} + +/// git on the Delphi repo. +pub fn dgit, S: AsRef>(args: I) -> Command { + let mut c = git_in(root()); + c.args(args); + c +} + +pub fn trim_nl(s: &str) -> &str { + s.trim_end_matches('\n') +} + +/// Like `$(cmd)`: stdout with trailing newlines stripped, stderr passed through; None on failure. +pub fn out(c: &mut Command) -> Option { + let o = c.stdin(Stdio::inherit()).stderr(Stdio::inherit()).output().ok()?; + o.status.success().then(|| trim_nl(&String::from_utf8_lossy(&o.stdout)).to_string()) +} + +/// Like `$(cmd 2>/dev/null)`. +pub fn out_q(c: &mut Command) -> Option { + c.stderr(Stdio::null()); + let o = c.stdin(Stdio::null()).output().ok()?; + o.status.success().then(|| trim_nl(&String::from_utf8_lossy(&o.stdout)).to_string()) +} + +/// Raw stdout bytes (stderr passed through), with the exit status. +pub fn out_raw(c: &mut Command) -> (bool, Vec) { + match c.stdin(Stdio::inherit()).stderr(Stdio::inherit()).output() { + Ok(o) => (o.status.success(), o.stdout), + Err(_) => (false, Vec::new()), + } +} + +/// Run with stdin fed from `input`; stdout captured like `out`. +pub fn out_stdin(c: &mut Command, input: &[u8]) -> Option { + let mut ch = c.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn().ok()?; + ch.stdin.take()?.write_all(input).ok()?; + let o = ch.wait_with_output().ok()?; + o.status.success().then(|| trim_nl(&String::from_utf8_lossy(&o.stdout)).to_string()) +} + +/// Run with inherited stdio; true on success. +pub fn ok(c: &mut Command) -> bool { + c.status().map(|s| s.success()).unwrap_or(false) +} + +/// Run with stdout and stderr discarded; true on success. +pub fn ok_q(c: &mut Command) -> bool { + c.stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) +} + +/// Whether a command is on PATH (`command -v`). +pub fn have(prog: &str) -> bool { + std::env::var_os("PATH").map(|p| std::env::split_paths(&p).any(|d| is_exec(&d.join(prog)))).unwrap_or(false) +} + +pub fn is_exec(p: &Path) -> bool { + fs::metadata(p).map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false) +} + +/// `date +` (local time). +pub fn date(fmt: &str) -> String { + out(Command::new("date").arg(format!("+{fmt}"))).unwrap_or_default() +} + +// ---- Delphi repo access ---- +pub fn delphi_fetch() { + if offline() { + return; + } + if !dgit(["fetch", "--prune", "--quiet", "origin"]).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) { + warn!("git fetch failed; using local refs"); + } +} + +/// Full sha of origin/. +pub fn delphi_commit(branch: &str) -> Result { + match out(&mut dgit(["rev-parse", "--verify", "--quiet", &format!("origin/{branch}^{{commit}}")])) { + Some(c) => Ok(c), + None => die!("no such Delphi branch: origin/{branch}"), + } +} + +/// Detached temp worktree of Delphi at a commit. +pub fn delphi_worktree_at(c: &str) -> Result { + let d = make_tmp()?.join("src"); + if !ok_q(dgit(["worktree", "add", "--quiet", "--detach"]).arg(&d).arg(c)) { + die!("cannot check out Delphi at {c}"); + } + let dd = d.clone(); + defer(move || { + ok_q(git_c(root()).args(["worktree", "remove", "--force"]).arg(&dd)); + }); + Ok(d) +} + +pub fn name_ok(name: &str) -> bool { + !name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// The layout dir (relative to context/) of layout in a tree. +pub fn find_layout(tree: &Path, name: &str) -> Result { + if !name_ok(name) { + die!("invalid layout name: '{name}'"); + } + let suffix = format!("/layouts/{name}/manifest.yml"); + let ctx = tree.join("context"); + let hits: Vec = find(&ctx) + .into_iter() + .filter(|(_, ft)| ft.is_file()) + .map(|(p, _)| format!("./{}", rel_to(&p, &ctx))) + .filter(|p| p.ends_with(&suffix)) + .map(|p| p[2..p.len() - "/manifest.yml".len()].to_string()) + .collect(); + match hits.len() { + 1 => Ok(hits[0].clone()), + 0 => die!("layout not found: {name}"), + _ => die!("layout name not unique: {name}"), + } +} + +// ---- moves (moves.tsv is append-only; rows apply in order, each once) ---- +pub type Moves = Vec<(String, String)>; + +pub fn parse_moves(text: &str) -> Moves { + awk_lines(text) + .into_iter() + .filter(|l| !l.starts_with('#')) + .filter_map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + (!l.is_empty() && f.len() >= 2).then(|| (f[0].to_string(), f[1].to_string())) + }) + .collect() +} + +/// Rows of moves.tsv added between two Delphi commits. +pub fn moves_since(old: &str, new: &str) -> Moves { + let show = |c: &str| { + let o = dgit(["show", &format!("{c}:moves.tsv")]).stderr(Stdio::null()).output(); + o.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default() + }; + let n = awk_lines(&show(old)).len(); + parse_moves(&awk_lines(&show(new)).into_iter().skip(n).collect::>().join("\n")) +} + +pub fn move_path(rows: &Moves, p: &str) -> String { + let mut p = p.to_string(); + for (o, n) in rows { + if &p == o { + p = n.clone(); + } else if p.starts_with(&format!("{o}/")) { + p = format!("{n}{}", &p[o.len()..]); + } + } + p +} + +/// Apply move rows to ` - item` lines and `settings:` values in place (trailing comments on +/// rewritten lines are dropped). True if anything changed. +pub fn rewrite_moves(rows: &Moves, file: &Path) -> Result { + let text = String::from_utf8_lossy(&fs::read(file)?).into_owned(); + let mut ch = false; + let mut res = String::new(); + for l in awk_lines(&text) { + let pre = if l.starts_with(" - ") { + " - " + } else if l.starts_with("settings: ") { + "settings: " + } else { + "" + }; + let mut line = l.to_string(); + if !pre.is_empty() { + let mut v = &l[pre.len()..]; + if let Some(i) = v.find(" #") { + v = &v[..i]; + } + let v = v.trim_end_matches(' '); + let m = move_path(rows, v); + if m != v { + line = format!("{pre}{m}"); + ch = true; + } + } + res.push_str(&line); + res.push('\n'); + } + if ch { + write_replace(file, res.as_bytes())?; + } + Ok(ch) +} + +/// Write `.tmp` then rename it over ``. +pub fn write_replace(file: &Path, data: &[u8]) -> Result<()> { + let tmp = PathBuf::from(format!("{}.tmp", file.display())); + fs::write(&tmp, data)?; + fs::rename(&tmp, file)?; + Ok(()) +} + +// ---- flags ---- +/// Parsed flags. --yes and --offline set the YES / OFFLINE globals. +#[derive(Default, Debug)] +pub struct Opts { + pub as_: String, + pub ref_: String, + pub from: String, + pub model: String, + pub effort: String, + pub shell: bool, + pub dry: bool, +} + +/// Parse flags allowed by `allowed` (space-separated); returns options and positionals. +pub fn parse_args(allowed: &str, args: &[String]) -> Result<(Opts, Vec)> { + let ok: Vec<&str> = allowed.split_whitespace().collect(); + let mut o = Opts::default(); + let mut pos = Vec::new(); + let mut i = 0; + while i < args.len() { + let a = args[i].as_str(); + if a.starts_with('-') && !ok.contains(&a) { + die!("unknown flag for this command: {a} (allowed: {allowed})"); + } + match a { + "--as" | "--ref" | "--from" | "--model" | "--effort" => { + let Some(v) = args.get(i + 1).cloned() else { die!("{a} needs a value") }; + match a { + "--as" => o.as_ = v, + "--ref" => o.ref_ = v, + "--from" => o.from = v, + "--model" => o.model = v, + _ => o.effort = v, + } + i += 1; + } + "--yes" => YES.store(true, Ordering::Relaxed), + "--shell" => o.shell = true, + "--dry-run" => o.dry = true, + "--offline" => OFFLINE.store(true, Ordering::Relaxed), + _ => pos.push(a.to_string()), + } + i += 1; + } + Ok((o, pos)) +} + +// ---- small awk / sh equivalents ---- +/// Lines as awk reads them: split on '\n', no trailing empty record. +pub fn awk_lines(s: &str) -> Vec<&str> { + let mut v: Vec<&str> = s.split('\n').collect(); + if v.last() == Some(&"") { + v.pop(); + } + v +} + +/// awk `substr(s, m, n)` (1-based, clamped, characters). +pub fn substr(s: &str, m: i64, n: Option) -> String { + let len = s.chars().count() as i64; + let start = m.max(1); + let end = n.map_or(len + 1, |n| (m + n).min(len + 1)); + if end <= start { + return String::new(); + } + s.chars().skip((start - 1) as usize).take((end - start) as usize).collect() +} + +/// awk numeric conversion of a string (leading integer prefix, else 0). +pub fn num(s: &str) -> i64 { + let s = s.trim_start(); + let (neg, d) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s.strip_prefix('+').unwrap_or(s)), + }; + let n: i64 = d.chars().take_while(char::is_ascii_digit).collect::().parse().unwrap_or(0); + if neg { + -n + } else { + n + } +} + +/// Shell glob match where only `*` (anything, including `/`) and `?` are special. +pub fn glob(pat: &str, s: &str) -> bool { + fn m(p: &[char], s: &[char]) -> bool { + match p.first() { + None => s.is_empty(), + Some('*') => (0..=s.len()).any(|i| m(&p[1..], &s[i..])), + Some('?') => !s.is_empty() && m(&p[1..], &s[1..]), + Some(c) => s.first() == Some(c) && m(&p[1..], &s[1..]), + } + } + m(&pat.chars().collect::>(), &s.chars().collect::>()) +} + +/// `${lp%layouts/*}`: the layout's scope prefix (with trailing slash). +pub fn scope_of_layout(lp: &str) -> &str { + lp.rfind("layouts/").map_or(lp, |i| &lp[..i]) +} + +/// `${p##*/}`. +pub fn basename(p: &str) -> &str { + p.rsplit('/').next().unwrap_or(p) +} + +/// Path of `p` relative to `base`, as a string. +pub fn rel_to(p: &Path, base: &Path) -> String { + p.strip_prefix(base).unwrap_or(p).to_string_lossy().into_owned() +} + +/// Pre-order walk like `find ` (symlinks not followed, readdir order), excluding . +pub fn find(dir: &Path) -> Vec<(PathBuf, fs::FileType)> { + let mut v = Vec::new(); + find_into(dir, &|_| false, &mut v); + v +} + +/// Like `find`, but directories matching `prune` are neither listed nor entered. +pub fn find_into(dir: &Path, prune: &dyn Fn(&Path) -> bool, v: &mut Vec<(PathBuf, fs::FileType)>) { + let Ok(rd) = fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let (p, Ok(ft)) = (e.path(), e.file_type()) else { continue }; + if ft.is_dir() && prune(&p) { + continue; + } + v.push((p.clone(), ft)); + if ft.is_dir() { + find_into(&p, prune, v); + } + } +} + +/// Names in a directory as `dir/*` expands them: sorted, no dot-files. +pub fn glob_dir(dir: &Path) -> Vec { + let mut v: Vec = fs::read_dir(dir) + .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()) + .unwrap_or_default(); + v.retain(|n| !n.starts_with('.')); + v.sort(); + v +} + +pub fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} diff --git a/src/harness.rs b/src/harness.rs new file mode 100644 index 0000000..5d94b98 --- /dev/null +++ b/src/harness.rs @@ -0,0 +1,63 @@ +//! Harness adapters (port of lib/harness/*.sh): output file names, provenance fallback, launch. +//! Adapters hold names and two functions; no file logic. + +use crate::core::{name_ok, out_q}; +use anyhow::Result; +use std::process::Command; + +pub struct Harness { + pub name: &'static str, + /// Instruction file at the workspace root. + pub instructions: &'static str, + pub skills_dir: &'static str, + pub mcp_file: &'static str, + pub settings_file: &'static str, + /// Added to the workspace's .git/info/exclude. + pub ignore: &'static str, + /// Harness+version, model, effort (blank if unknown). + pub provenance: fn() -> [String; 3], + /// The command that starts the harness with a model and effort (either may be empty). + pub launch: fn(&str, &str) -> Command, +} + +pub const HARNESSES: &[Harness] = &[Harness { + name: "claude-code", + instructions: "CLAUDE.md", + skills_dir: ".claude/skills", + mcp_file: ".mcp.json", + settings_file: ".claude/settings.json", + ignore: ".claude/settings.local.json", + provenance: claude_provenance, + launch: claude_launch, +}]; + +fn claude_provenance() -> [String; 3] { + let v = out_q(Command::new("claude").arg("--version")) + .and_then(|o| o.lines().next().and_then(|l| l.split_whitespace().next()).map(String::from)) + .unwrap_or_default(); + let env = |k| std::env::var(k).unwrap_or_default(); + let h = if v.is_empty() { "claude-code".to_string() } else { format!("claude-code {v}") }; + [h, env("ANTHROPIC_MODEL"), env("CLAUDE_CODE_EFFORT_LEVEL")] +} + +fn claude_launch(model: &str, effort: &str) -> Command { + let mut c = Command::new("claude"); + if !effort.is_empty() { + c.env("CLAUDE_CODE_EFFORT_LEVEL", effort); + } + if !model.is_empty() { + c.args(["--model", model]); + } + c +} + +/// Look up an adapter by name. +pub fn load(name: &str) -> Result<&'static Harness> { + if !name_ok(name) { + crate::die!("invalid harness name: '{name}'"); + } + match HARNESSES.iter().find(|h| h.name == name) { + Some(h) => Ok(h), + None => crate::die!("unknown harness: {name}"), + } +} diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 0000000..04ff5e7 --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,206 @@ +//! `delphi layout new|list` (port of lib/layout.sh). `new` is a basic y/n picker; the +//! delphi-new-layout skill drafts richer manifests and passes them with --from. + +use crate::check::check_tree; +use crate::core::{ + ask, delphi_commit, delphi_fetch, dgit, is_tty, make_tmp, name_ok, ok_q, out, out_raw, path_ok, Opts, +}; +use crate::harness::HARNESSES; +use crate::parse::parse_yaml; +use crate::{die, provenance, warn}; +use anyhow::Result; +use std::fs; +use std::path::Path; + +pub fn main(args: &[String]) -> Result<()> { + let verb = args.first().map(String::as_str).unwrap_or(""); + let flags = if verb == "new" { "--from --model --effort --yes" } else { "" }; + let (o, pos) = crate::core::parse_args(flags, args.get(1..).unwrap_or(&[]))?; + match (verb, pos.len()) { + ("new", 2) => new(pos[0].strip_suffix('/').unwrap_or(&pos[0]), &pos[1], &o), + ("list", 0) => list(), + _ => die!("usage: delphi layout new [--from ] | delphi layout list"), + } +} + +/// Layout manifests on a commit, as paths. +fn manifests(c: &str) -> Vec { + let t = out(&mut dgit(["ls-tree", "-r", "--name-only", c, "--", "context"])).unwrap_or_default(); + t.lines() + .filter(|f| { + f.strip_suffix("/manifest.yml") + .and_then(|d| d.rfind("/layouts/").map(|i| &d[i + 9..])) + .is_some_and(|n| !n.is_empty() && !n.contains('/')) + }) + .map(String::from) + .collect() +} + +fn list() -> Result<()> { + delphi_fetch(); + let c = delphi_commit("main")?; + let m = make_tmp()?.join("m"); + for f in manifests(&c) { + let (ok, data) = out_raw(&mut dgit(["show", &format!("{c}:{f}")])); + let recs = match fs::write(&m, data).ok().filter(|_| ok).map(|_| parse_yaml(&m)) { + Some(Ok(r)) => r, + Some(Err(e)) => { + eprintln!("{}", e.downcast_ref::().map_or(format!("delphi: {e}"), |r| r.0.clone())); + warn!("cannot parse {f}"); + continue; + } + None => { + warn!("cannot parse {f}"); + continue; + } + }; + let scope = f.strip_prefix("context/").unwrap_or(&f); + let scope = crate::core::scope_of_layout(scope); + let scope = scope.strip_suffix('/').unwrap_or(scope); + println!("{}\t{}\t{}", recs.get("name"), if scope.is_empty() { "." } else { scope }, recs.get("harness")); + } + Ok(()) +} + +/// Interactive manifest (prompts on stderr). +fn pick(c: &str, scope: &str, name: &str, tmp: &Path) -> Result { + if !is_tty() { + die!("non-interactive session: pass a drafted manifest with --from "); + } + let mut items: Vec = vec![]; + let mut s = scope.to_string(); + loop { + // recommendations, closest scope first + let spec = if s.is_empty() { format!("{c}:context/scope.yml") } else { format!("{c}:context/{s}/scope.yml") }; + let (ok, data) = out_raw(dgit(["show", &spec]).stderr(std::process::Stdio::null())); + let f = tmp.join("scope.yml"); + if ok && fs::write(&f, data).is_ok() { + if let Ok(r) = parse_yaml(&f) { + items.extend(r.list("recommend")); + } + } + if s.is_empty() { + break; + } + s = s.rfind('/').map_or(String::new(), |i| s[..i].to_string()); + } + let blocks = out(&mut dgit(["ls-tree", "-r", "--name-only", c, "--", &format!("context/{scope}/blocks")])) + .unwrap_or_default(); + items.extend(blocks.lines().map(|l| l.strip_prefix("context/").unwrap_or(l).to_string())); + let mut seen: Vec = vec![]; + let mut sel: Vec<(&str, String)> = vec![]; + for it in items { + if it.trim().is_empty() || seen.contains(&it) { + continue; + } + seen.push(it.clone()); + let p = format!("/{it}"); + let g = |pat: &str| crate::core::glob(pat, &p); + let key = if g("*/harness/instructions/*") { + "instructions" + } else if g("*/harness/skills/*") { + "skills" + } else if g("*/harness/mcp/*") { + "mcp" + } else if g("*/harness/settings/*") { + "settings" + } else if g("*/blocks/*") { + "blocks" + } else if g("*/docs/*") { + "docs" + } else { + warn!("skipping '{it}': not a block or harness path"); + continue; + }; + if !matches!(ask(&format!("Include {it}? [y/N]"), "")?.as_str(), "y" | "Y" | "yes") { + continue; + } + if key == "settings" && sel.iter().any(|(k, _)| *k == "settings") { + warn!("only one settings file; keeping the first"); + continue; + } + sel.push((key, it)); + } + let hs: Vec<&str> = HARNESSES.iter().map(|h| h.name).collect(); + let h = if hs.len() > 1 { ask(&format!("Harness ({})?", hs.join(" ")), "claude-code")? } else { hs[0].to_string() }; + let mut m = format!("name: {name}\nharness: {h}\n"); + for k in ["instructions", "blocks", "docs", "skills", "mcp"] { + let l: Vec = sel.iter().filter(|(x, _)| *x == k).map(|(_, v)| format!(" - {v}")).collect(); + if !l.is_empty() { + m.push_str(&format!("{k}:\n{}\n", l.join("\n"))); + } + } + if let Some((_, v)) = sel.iter().find(|(k, _)| *k == "settings") { + m.push_str(&format!("settings: {v}\n")); + } + let mut repos = String::new(); + loop { + let line = ask("Repo as 'name url' (blank to finish):", "")?; + if line.is_empty() { + break; + } + match line.split_once(' ') { + Some((n, u)) if !u.is_empty() => repos.push_str(&format!("\n {n}: {u}")), + _ => warn!("expected 'name url'"), + } + } + if !repos.is_empty() { + m.push_str(&format!("repos:{repos}\n")); + } + Ok(m) +} + +fn new(scope: &str, name: &str, o: &Opts) -> Result<()> { + if !name_ok(name) { + die!("invalid layout name: '{name}' (use a-z, 0-9, -)"); + } + if !path_ok(scope) { + die!("invalid scope: '{scope}'"); + } + if !o.from.is_empty() && !Path::new(&o.from).is_file() { + die!("no such file: {}", o.from); + } + delphi_fetch(); + let c = delphi_commit("main")?; + if !ok_q(&mut dgit(["cat-file", "-e", &format!("{c}:context/{scope}/scope.yml")])) { + die!("not a scope on origin/main: {scope}"); + } + if manifests(&c).iter().any(|f| f.ends_with(&format!("/layouts/{name}/manifest.yml"))) { + die!("layout name already used: {name}"); + } + let tmp = make_tmp()?; + let mf = tmp.join("manifest.yml"); + if !o.from.is_empty() { + if fs::copy(&o.from, &mf).is_err() { + die!("cannot read {}", o.from); + } + } else { + let m = pick(&c, scope, name, &tmp)?; + fs::write(&mf, m)?; + } + let recs = parse_yaml(&mf)?; + if recs.get("name") != name { + die!("manifest name must be '{name}'"); + } + let h = recs.get("harness"); + if h.is_empty() { + die!("manifest has no harness"); + } + let prov = provenance::resolve(&o.model, &o.effort, &h)?; + let mut pr = crate::pr::begin(&format!("delphi/layout/{name}"), &c, prov)?; + let dir = pr.wt.join("context").join(scope).join("layouts").join(name); + if fs::create_dir_all(&dir).is_err() || fs::copy(&mf, dir.join("manifest.yml")).is_err() { + die!("cannot write manifest"); + } + if !check_tree(&pr.wt)? { + die!("layout fails check; nothing pushed"); + } + if !pr.commit(&format!("delphi: add layout {name}"), "")? { + die!("nothing to commit"); + } + let yaml = fs::read_to_string(&mf).unwrap_or_default(); + let body = format!("Adds layout `{name}` in scope `{scope}`:\n\n```yaml\n{}\n```", yaml.trim_end_matches('\n')); + pr.finish(&format!("delphi: add layout {name}"), &body, None)?; + println!("{}", pr.branch); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8558a47 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,82 @@ +//! Delphi CLI entry point (port of bin/delphi): resolves the Delphi repo, then dispatches on the +//! command group. Errors print as `delphi: ` (exit 1); refresh conflicts exit 2. + +mod block; +mod check; +mod compile; +mod core; +mod harness; +mod layout; +mod parse; +mod pr; +mod provenance; +mod route; +mod setup; +mod workspace; + +use crate::core::{Exit, Raw}; +use std::sync::atomic::Ordering; + +const USAGE: &str = "usage: delphi [args] + + layout new [--from ] create a layout (branch + PR) + layout list list layouts on origin/main + + workspace new [--as ] [--ref ] + workspace open [] [--model m] [--effort e] [--shell] + workspace refresh [] [--ref ] + workspace propose [] [--dry-run] + workspace status [--offline] (alias: ws) + + block mv move/rename a block + + check validate the repo + setup [dir] record this Delphi checkout for use anywhere + +Commands that write to Delphi accept --model, --effort, --yes. +The Delphi repo is $DELPHI_ROOT, else the checkout containing the current directory, +else the one recorded by `delphi setup`. +"; + +fn run(args: &[String]) -> anyhow::Result<()> { + let group = args.first().map(String::as_str).unwrap_or(""); + let rest = args.get(1..).unwrap_or(&[]); + match group { + "" | "-h" | "--help" | "help" => { + print!("{USAGE}"); + return Ok(()); + } + "setup" => return setup::main(rest), + "layout" | "workspace" | "ws" | "block" | "check" => {} + _ => die!("unknown command '{group}' (see: delphi help)"), + } + crate::core::YES.store(std::env::var("DELPHI_YES").as_deref() == Ok("1"), Ordering::Relaxed); + crate::core::OFFLINE.store(std::env::var("DELPHI_OFFLINE").as_deref() == Ok("1"), Ordering::Relaxed); + crate::core::resolve_root()?; + match group { + "layout" => layout::main(rest), + "block" => block::main(rest), + "check" => check::main(rest), + _ => workspace::main(rest), + } +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let code = match run(&args) { + Ok(()) => 0, + Err(e) => { + if let Some(Exit(c)) = e.downcast_ref::() { + *c + } else if let Some(Raw(m)) = e.downcast_ref::() { + eprintln!("{m}"); + 1 + } else { + eprintln!("delphi: {e:#}"); + 1 + } + } + }; + crate::core::run_deferred(); + std::process::exit(code); +} diff --git a/src/parse.rs b/src/parse.rs new file mode 100644 index 0000000..353f405 --- /dev/null +++ b/src/parse.rs @@ -0,0 +1,139 @@ +//! Strict YAML-subset parser and record accessors (port of lib/parse.sh). +//! +//! Supported: full-line and trailing ` #` comments; top-level `key: value`; top-level `key:` +//! followed by two-space-indented `- item` lines (list) or `sub: value` lines (one-level map). +//! Anything else is a parse error reported as `file:line: msg`. + +use crate::core::{awk_lines, Raw}; +use anyhow::Result; +use std::collections::HashSet; +use std::path::Path; + +/// One record: scalar/list item (`sub` = None) or map entry. +pub struct Rec { + pub key: String, + pub sub: Option, + pub val: String, +} + +pub struct Yaml(pub Vec); + +fn is_key(s: &str) -> Option { + let n = s.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-').count(); + (n > 0 && s.as_bytes().get(n) == Some(&b':')).then_some(n) +} + +fn unquote(s: &str) -> String { + if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { + s[1..s.len() - 1].to_string() + } else { + s.to_string() + } +} + +fn nocomment(s: &str) -> Result { + if s.trim_start_matches(' ').starts_with('"') { + let q1 = s.find('"').unwrap_or(0); + let Some(q2) = s[q1 + 1..].find('"') else { return Err("unterminated quote".into()) }; + let i = q1 + 1 + q2; + let rest = &s[i + 1..]; + if rest.bytes().all(|b| b == b' ') || (rest.starts_with(' ') && rest.trim_start_matches(' ').starts_with('#')) { + return Ok(s[..=i].to_string()); + } + return Err("text after closing quote".into()); + } + Ok(match s.find(" #") { + Some(i) => s[..i].to_string(), + None => s.to_string(), + }) +} + +fn value(raw: &str) -> Result { + let v = nocomment(raw)?.trim_matches(' ').to_string(); + if v.starts_with(['[', '{', '&', '*', '|', '>', '!']) { + return Err(format!("unsupported YAML syntax: {v}")); + } + Ok(v) +} + +/// Parse a file; errors are `Raw(":: ")`. +pub fn parse_yaml(path: &Path) -> Result { + if !path.is_file() { + crate::die!("no such file: {}", path.display()); + } + let text = String::from_utf8_lossy(&std::fs::read(path)?).into_owned(); + let mut recs = Vec::new(); + let mut seen = HashSet::new(); + let mut cur = String::new(); + for (i, line) in awk_lines(&text).into_iter().enumerate() { + let fail = |m: String| -> anyhow::Error { Raw(format!("{}:{}: {m}", path.display(), i + 1)).into() }; + if line.contains('\t') { + return Err(fail("tabs are not allowed".into())); + } + let t = line.trim_start_matches(' '); + if t.starts_with('#') || t.is_empty() { + continue; + } + if let Some(n) = is_key(line) { + let k = &line[..n]; + if !seen.insert(k.to_string()) { + return Err(fail(format!("duplicate key: {k}"))); + } + let v = value(&line[n + 1..]).map_err(fail)?; + if v.is_empty() { + cur = k.to_string(); + } else { + recs.push(Rec { key: k.into(), sub: None, val: unquote(&v) }); + cur.clear(); + } + } else if let Some(item) = line.strip_prefix(" - ") { + if cur.is_empty() { + return Err(fail("list item without a parent key".into())); + } + let v = value(item).map_err(fail)?; + recs.push(Rec { key: cur.clone(), sub: None, val: unquote(&v) }); + } else if let Some(n) = line.strip_prefix(" ").and_then(is_key) { + if cur.is_empty() { + return Err(fail("map entry without a parent key".into())); + } + let s = &line[2..]; + let v = value(&s[n + 1..]).map_err(fail)?; + if v.is_empty() { + return Err(fail("nested maps are not supported".into())); + } + recs.push(Rec { key: cur.clone(), sub: Some(s[..n].into()), val: unquote(&v) }); + } else { + return Err(fail("unsupported indentation or syntax".into())); + } + } + Ok(Yaml(recs)) +} + +impl Yaml { + /// First scalar value of a key ("" if none). + pub fn get(&self, k: &str) -> String { + self.list(k).into_iter().next().unwrap_or_default() + } + /// All scalar/list values of a key. + pub fn list(&self, k: &str) -> Vec { + self.0.iter().filter(|r| r.sub.is_none() && r.key == k).map(|r| r.val.clone()).collect() + } + /// Map entries of a key. + pub fn map(&self, k: &str) -> Vec<(String, String)> { + self.0 + .iter() + .filter_map(|r| r.sub.as_ref().filter(|_| r.key == k).map(|s| (s.clone(), r.val.clone()))) + .collect() + } + /// Sorted distinct keys. + pub fn keys(&self) -> Vec { + let mut v: Vec = self.0.iter().map(|r| r.key.clone()).collect(); + v.sort(); + v.dedup(); + v + } + /// Every scalar/list value (any key). + pub fn values(&self) -> Vec { + self.0.iter().filter(|r| r.sub.is_none()).map(|r| r.val.clone()).collect() + } +} diff --git a/src/pr.rs b/src/pr.rs new file mode 100644 index 0000000..be48a9a --- /dev/null +++ b/src/pr.rs @@ -0,0 +1,113 @@ +//! The single path for writing to the Delphi monorepo (port of lib/pr.sh). +//! +//! `begin` makes a temp worktree on a branch (reset to a start commit); the caller edits files +//! under `wt`; `commit` stages everything and commits with provenance trailers; `finish` shows, +//! confirms, pushes, and opens or updates the PR. With a lease (a sha, or empty = the branch must +//! be absent) it force-pushes. The user's own Delphi checkout is never touched. + +use crate::core::{confirm, defer, dgit, git_c, is_tty, make_tmp, ok, ok_q, out, out_q, out_stdin, root, yes}; +use crate::provenance::Prov; +use crate::{die, info}; +use anyhow::Result; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub struct Pr { + pub branch: String, + pub wt: PathBuf, + start: String, + pub pushed: bool, + pub prov: Prov, +} + +pub fn begin(branch: &str, start: &str, prov: Prov) -> Result { + if !yes() && !is_tty() { + die!("non-interactive session: re-run with --yes (or DELPHI_YES=1)"); + } + let wt = make_tmp()?.join("wt"); + ok(&mut dgit(["worktree", "prune"])); + if !ok_q(dgit(["worktree", "add", "--quiet", "-B", branch]).arg(&wt).arg(start)) { + die!("cannot create branch {branch} (is it checked out elsewhere?)"); + } + let w = wt.clone(); + defer(move || { + ok_q(git_c(root()).args(["worktree", "remove", "--force"]).arg(&w)); + }); + let start = out(git_c(&wt).args(["rev-parse", "HEAD"])).unwrap_or_default(); + Ok(Pr { branch: branch.into(), wt, start, pushed: false, prov }) +} + +fn gh(wt: &PathBuf) -> Command { + let mut c = Command::new("gh"); + c.current_dir(wt); + c +} + +impl Pr { + /// Stage everything and commit. False (nothing committed) when nothing is staged. + pub fn commit(&self, subject: &str, body: &str) -> Result { + if !ok(git_c(&self.wt).args(["add", "-A"])) { + die!("git add failed"); + } + if ok(git_c(&self.wt).args(["diff", "--cached", "--quiet"])) { + return Ok(false); + } + let body = if body.is_empty() { String::new() } else { format!("\n{body}\n") }; + let msg = format!("{subject}\n{body}\n{}\n", self.prov.trailers()); + if out_stdin(git_c(&self.wt).args(["commit", "--quiet", "-F", "-"]), msg.as_bytes()).is_none() { + die!("git commit failed"); + } + Ok(true) + } + + pub fn has_commits(&self) -> bool { + out(git_c(&self.wt).args(["rev-parse", "HEAD"])).unwrap_or_default() != self.start + } + + pub fn finish(&mut self, title: &str, body: &str, lease: Option<&str>) -> Result<()> { + let b = &self.branch; + let body = format!("{body}\n\n{}", self.prov.table()); + info!("---- {title}"); + info!("{body}"); + info!("----"); + if !confirm(&format!("Push {b} and open/update its PR?"))? { + info!("Not pushed. Branch {b} is committed locally in {}.", root().display()); + return Ok(()); + } + let dst = format!("HEAD:refs/heads/{b}"); + if let Some(lease) = lease { + let Some(me) = out_q(Command::new("gh").args(["api", "user", "--jq", ".login"])) else { + die!("gh is not authenticated (run: gh auth login)") + }; + let q = ".[0].author.login // empty"; + let author = + out_q(gh(&self.wt).args(["pr", "list", "--head", b, "--state", "open", "--json", "author", "--jq", q])) + .unwrap_or_default(); + if !author.is_empty() && author != me { + die!("open PR on {b} belongs to {author}; refusing to overwrite"); + } + let l = format!("--force-with-lease=refs/heads/{b}:{lease}"); + if !ok(git_c(&self.wt).args(["push", "--quiet", &l, "origin", &dst])) { + die!("the propose branch changed on GitHub (someone pushed to it); review the PR, then re-run"); + } + } else if !ok(git_c(&self.wt).args(["push", "--quiet", "origin", &dst])) { + die!("push failed"); + } + self.pushed = true; + let q = ".[0].number // empty"; + let num = + out_q(gh(&self.wt).args(["pr", "list", "--head", b, "--state", "open", "--json", "number", "--jq", q])) + .unwrap_or_default(); + if !num.is_empty() { + if !ok(gh(&self.wt).args(["pr", "edit", &num, "--title", title, "--body", &body]).stdout(Stdio::null())) { + die!("gh pr edit failed"); + } + info!("Updated PR #{num}"); + } else if !ok( + gh(&self.wt).args(["pr", "create", "--head", b, "--base", "main", "--title", title, "--body", &body]) + ) { + die!("gh pr create failed"); + } + Ok(()) + } +} diff --git a/src/provenance.rs b/src/provenance.rs new file mode 100644 index 0000000..b14cf28 --- /dev/null +++ b/src/provenance.rs @@ -0,0 +1,87 @@ +//! Which harness, model, and effort produced a change (port of lib/provenance.sh). +//! +//! Resolution per field: CLI flag -> DELPHI_* env (set by `workspace open`) -> adapter fallback +//! -> interactive prompt (accepts "none") -> error. Never records "unknown". `extra` holds extra +//! trailer lines; `rows` extra table rows ("harness|model|effort" lines) from other sessions. + +use crate::core::{ask, env_nonempty, is_tty}; +use crate::harness; +use anyhow::Result; + +#[derive(Clone, Default)] +pub struct Prov { + pub harness: String, + pub model: String, + pub effort: String, + pub extra: String, + pub rows: String, +} + +fn prov_ask(field: &str, var: &str, flag: &str) -> Result { + if !is_tty() { + let pass = if flag.is_empty() { String::new() } else { format!("pass {flag} or ") }; + crate::die!("provenance: {field} unknown — {pass}set {var}"); + } + let a = ask(&format!("Which {field} made this change? ('none' if no AI was used)"), "")?; + if a.is_empty() { + crate::die!("provenance: {field} is required"); + } + Ok(a) +} + +pub fn resolve(flag_model: &str, flag_effort: &str, adapter: &str) -> Result { + let h = harness::load(adapter)?; + let [fh, fm, fe] = (h.provenance)(); + let pick = |flag: &str, var: &str, fb: String| { + if !flag.is_empty() { + flag.to_string() + } else { + env_nonempty(var).unwrap_or(fb) + } + }; + let mut p = Prov { + harness: pick("", "DELPHI_HARNESS", fh), + model: pick(flag_model, "DELPHI_MODEL", fm), + effort: pick(flag_effort, "DELPHI_EFFORT", fe), + ..Default::default() + }; + if p.harness.is_empty() { + p.harness = prov_ask("harness", "DELPHI_HARNESS", "")?; + } + if p.model.is_empty() { + p.model = prov_ask("model", "DELPHI_MODEL", "--model")?; + } + if p.effort.is_empty() { + p.effort = prov_ask("effort", "DELPHI_EFFORT", "--effort")?; + } + Ok(p) +} + +impl Prov { + /// Commit trailers (no trailing newline). + pub fn trailers(&self) -> String { + let mut s = + format!("Delphi-Harness: {}\nDelphi-Model: {}\nDelphi-Effort: {}", self.harness, self.model, self.effort); + if !self.extra.is_empty() { + s.push('\n'); + s.push_str(&self.extra); + } + s.trim_end_matches('\n').to_string() + } + + /// Markdown table of this session plus `rows` (no trailing newline). + pub fn table(&self) -> String { + let mut s = format!( + "| Harness | Model | Effort |\n|---|---|---|\n| {} | {} | {} |", + self.harness, self.model, self.effort + ); + if !self.rows.is_empty() { + for l in crate::core::awk_lines(&format!("{}\n", self.rows)) { + let mut f = l.splitn(3, '|'); + let (h, m, e) = (f.next().unwrap_or(""), f.next().unwrap_or(""), f.next().unwrap_or("")); + s.push_str(&format!("\n| {h} | {m} | {e} |")); + } + } + s + } +} diff --git a/src/route.rs b/src/route.rs new file mode 100644 index 0000000..910a405 --- /dev/null +++ b/src/route.rs @@ -0,0 +1,753 @@ +//! A workspace's pending diff -> edits against the Delphi tree (spec §8). Port of lib/route.sh +//! and lib/route.awk. +//! +//! `plan` routes `generated-merged..HEAD` of a workspace. Plan rows: +//! manifest +//! patch +//! key +//! new [ ] +//! fragment (a new instruction section) +//! noop +//! unresolved +//! `unresolved` holds one markdown section per unresolved item (for the PR body). +//! `apply` applies the plan inside the PR worktree, one commit per step (manifest, edits, new +//! files); rows that applied go to `applied`, failures to the unresolved items. + +use crate::core::{ + awk_lines, basename, dgit, git_c, make_tmp, num, ok_q, out, path_ok, safe_path, scope_of_layout, substr, + write_replace, +}; +use crate::die; +use crate::harness::{self, Harness}; +use crate::parse::parse_yaml; +use crate::pr::Pr; +use crate::workspace::Ws; +use anyhow::Result; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct Route { + dir: PathBuf, + pub plan: Vec>, + pub unresolved: String, + pub applied: Vec>, + lock: Vec>, + entries: Vec, + h: &'static Harness, +} + +fn row(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() +} + +fn field(r: &[String], i: usize) -> &str { + r.get(i).map_or("", String::as_str) +} + +/// An entry names the path, a parent dir, or a `*/` glob over it. +pub fn covered(p: &str, entries: &[String]) -> bool { + entries.iter().any(|e| { + if e == p || p.starts_with(&format!("{e}/")) { + return true; + } + match e.strip_suffix('*') { + Some(dir) if e.ends_with("/*") => p.starts_with(dir) && !p[dir.len()..].contains('/'), + _ => false, + } + }) +} + +fn upstream_has(commit: &str, path: &str) -> bool { + ok_q(&mut dgit(["cat-file", "-e", &format!("{commit}:context/{path}")])) +} + +impl Route { + fn row(&mut self, v: &[&str]) { + self.plan.push(row(v)); + } + + /// File-level unresolved item, with the file's diff. + fn unres(&mut self, ws: &Ws, o: &str, reason: &str) { + self.row(&["unresolved", o, reason]); + let d = out(ws.git().args([ + "diff", + "--no-renames", + "--no-ext-diff", + "--no-color", + "generated-merged", + "HEAD", + "--", + o, + ])) + .unwrap_or_default(); + let mut p = false; + let d: Vec<&str> = awk_lines(&d) + .into_iter() + .filter(|l| { + p |= l.starts_with("@@") || l.starts_with("Binary"); + p + }) + .collect(); + self.unresolved.push_str(&format!("#### `{o}`: {reason}\n\n```diff\n{}\n```\n\n", d.join("\n"))); + } + + /// Source of the compiled skill dir holding : a .skill spec (built), a native skill dir, + /// or empty when that skill dir is not compiled. + fn skill_src(&self, o: &str) -> String { + let sk = format!("{}/", self.h.skills_dir); + let rest = o.strip_prefix(&sk).unwrap_or(o); + let p = format!("{sk}{}/", rest.split('/').next().unwrap_or("")); + let (mut g, mut d) = (String::new(), String::new()); + for r in &self.lock { + let (out1, src) = (field(r, 0), field(r, 3)); + if !out1.starts_with(&p) { + continue; + } + if let Some(s) = src.strip_prefix("@gen:") { + g = s.to_string(); + } + if !src.starts_with('@') && d.is_empty() { + let n = |s: &str| s.chars().count() as i64; + d = substr(src, 1, Some(n(src) - n(out1) + n(&p) - 1)); + } + } + if g.is_empty() { + d + } else { + g + } + } + + fn in_skill_dir(&self, o: &str) -> bool { + o.strip_prefix(&format!("{}/", self.h.skills_dir)).is_some_and(|r| r.contains('/')) + } + + /// The deleted output's sources are gone from the workspace manifest. Anything in a built + /// skill's dir counts as dropped only when its .skill spec is. + fn dropped(&self, o: &str) -> bool { + if self.in_skill_dir(o) { + let s = self.skill_src(o); + if s.ends_with(".skill") { + return !covered(&s, &self.entries); + } + } + !self + .lock + .iter() + .any(|r| field(r, 0) == o && !field(r, 3).starts_with('@') && covered(field(r, 3), &self.entries)) + } + + /// A new file, unless the target exists upstream. + fn new_file(&mut self, ws: &Ws, o: &str, target: &str, extra: &[&str]) { + if upstream_has(&ws.meta("compile_commit"), target) { + self.unres(ws, o, "exists upstream; add it via the manifest instead"); + } else { + let mut v = vec!["new", o, target]; + v.extend_from_slice(extra); + self.row(&v); + } + } + + /// A new instruction fragment at the layout scope's harness/instructions/.md, suffixed + /// -2, -3, … past names taken upstream or in this plan. + fn fragment(&mut self, rc: &str, lp: &str, o: &str, slug: &str, file: &str, after: &str) { + let base = format!("{}harness/instructions/{slug}", scope_of_layout(lp)); + let mut t = format!("{base}.md"); + let mut n = 1; + while upstream_has(rc, &t) || self.plan.iter().any(|r| field(r, 0) == "fragment" && field(r, 2) == t) { + n += 1; + t = format!("{base}-{n}.md"); + } + self.row(&["fragment", o, &t, file, after]); + } +} + +pub fn plan(ws: &Ws) -> Result { + let dir = make_tmp()?; + fs::create_dir_all(dir.join("p"))?; + let rc = ws.meta("compile_commit"); + let lp = ws.meta("layout_path"); + let h = harness::load(&ws.meta("harness"))?; + let Some(lock) = out(ws.git().args(["show", "generated-merged:.delphi/lock.tsv"])) else { + die!("cannot read lock") + }; + let lock = awk_lines(&lock) + .into_iter() + .filter(|l| !l.starts_with('#')) + .map(|l| l.split('\t').map(String::from).collect()) + .collect(); + let (ok, m) = crate::core::out_raw(ws.git().args(["show", "HEAD:.delphi/manifest.yml"])); + let mpath = dir.join("manifest.yml"); + fs::write(&mpath, &m)?; + if !ok { + die!("cannot read .delphi/manifest.yml"); + } + let entries = parse_yaml(&mpath)?.values(); + let mut rt = Route { dir, plan: vec![], unresolved: String::new(), applied: vec![], lock, entries, h }; + let numstat = ws.pending(&["--numstat"]); + let files = ws.pending(&["--name-status"]); + let sk = h.skills_dir; + let mut n = 0; + for line in awk_lines(&files) { + let line = line.trim_matches('\t'); + let (st, o) = line.split_once('\t').map_or((line, ""), |(a, b)| (a, b.trim_start_matches('\t'))); + if st == "M" && o == ".delphi/manifest.yml" { + rt.row(&["manifest", o, &format!("{lp}/manifest.yml")]); + continue; + } + if o.starts_with(".delphi/") { + rt.unres(ws, o, "Delphi bookkeeping file; only .delphi/manifest.yml edits are proposed"); + continue; + } + let binary = awk_lines(&numstat).iter().any(|l| { + let f: Vec<&str> = l.split('\t').collect(); + f.get(2) == Some(&o) && f[0] == "-" + }); + if binary { + rt.unres(ws, o, "binary file"); + continue; + } + match st { + "D" => { + if rt.dropped(o) { + rt.row(&["noop", o, "deleted; its source was dropped from .delphi/manifest.yml"]); + } else { + rt.unres( + ws, + o, + "deleted, but still compiled by .delphi/manifest.yml (drop blocks by editing the manifest)", + ); + } + } + "M" => { + let seg: Vec> = rt.lock.iter().filter(|r| field(r, 0) == o).cloned().collect(); + if seg.is_empty() { + rt.unres(ws, o, "not a compiled file"); + continue; + } + n += 1; + let (_, diff) = crate::core::out_raw(ws.git().args([ + "diff", + "-U0", + "--no-renames", + "--no-ext-diff", + "--no-color", + "generated-merged", + "HEAD", + "--", + o, + ])); + let diff = String::from_utf8_lossy(&diff).into_owned(); + if !diff.lines().any(|l| l.starts_with("@@")) { + rt.unres(ws, o, "mode-only change"); + continue; + } + let rows = route_file(o, &seg, &diff, &rt.dir.join("p"), n, o == h.instructions, &mut rt.unresolved)?; + rt.plan.extend(rows.iter().filter(|r| r[0] != "fragment").cloned()); + for r in rows.iter().filter(|r| r[0] == "fragment") { + rt.fragment(&rc, &lp, &r[1], &r[2], &r[3], &r[4]); + } + } + "A" => { + if let Some(p) = o.strip_prefix("context/") { + let s = format!("/{p}"); + let scope = s.find("/blocks/").map(|i| s[..i].strip_prefix('/').unwrap_or(&s[..i]).to_string()); + let scope_yml = + |sc: &str| if sc.is_empty() { "scope.yml".to_string() } else { format!("{sc}/scope.yml") }; + match scope { + Some(sc) if path_ok(p) && upstream_has(&rc, &scope_yml(&sc)) => { + rt.new_file(ws, o, p, &["blocks", p]) + } + _ => rt.unres(ws, o, "new file: must be under context//blocks/"), + } + } else if o.len() > 5 && o.starts_with("docs/") { + let d = format!("{}/", &o[..o.rfind('/').unwrap_or(0)]); + let hit = rt.lock.iter().find(|r| { + let (o1, s) = (field(r, 0), field(r, 3)); + o1.starts_with(&d) && !o1[d.len()..].contains('/') && s.contains("/docs/") + }); + let p = match hit { + Some(r) => { + let s = field(r, 3); + format!("{}{}", &s[..s.rfind('/').map_or(0, |i| i + 1)], basename(o)) + } + None => format!("{}{o}", scope_of_layout(&lp)), + }; + if path_ok(&p) { + rt.new_file(ws, o, &p, &["docs", &p]); + } else { + rt.unres(ws, o, "unsafe path"); + } + } else if rt.in_skill_dir(o) { + let rest = &o[sk.len() + 1..]; + let name = rest.split('/').next().unwrap_or(""); + let rest = &rest[name.len() + 1..]; + let src = rt.skill_src(o); + if src.is_empty() { + let src = format!("{}harness/skills/{name}", scope_of_layout(&lp)); + rt.new_file(ws, o, &format!("{src}/{rest}"), &["skills", &src]); + } else if src.ends_with(".skill") { + rt.unres( + ws, + o, + "new file in a built (.skill) skill: add it as a block and list it in the spec", + ); + } else { + rt.new_file(ws, o, &format!("{src}/{rest}"), &[]); + } + } else { + rt.unres( + ws, + o, + &format!("new file: must be under context//blocks/, docs/, or {sk}//"), + ); + } + } + _ => rt.unres(ws, o, &format!("unsupported change type '{st}'")), + } + } + Ok(rt) +} + +/// Add ` - entry` to a top-level list, creating the key; after item (- = first), else +/// at the end. +fn list_add(file: &Path, key: &str, v: &str, after: &str) -> Result<()> { + let text = String::from_utf8_lossy(&fs::read(file)?).into_owned(); + let mut res = String::new(); + let (mut ins, mut done) = (false, false); + let put = |res: &mut String, done: &mut bool| { + if !*done { + res.push_str(&format!(" - {v}\n")); + } + *done = true; + }; + for l in awk_lines(&text) { + if ins && !l.starts_with(" - ") { + put(&mut res, &mut done); + ins = false; + } + res.push_str(l); + res.push('\n'); + if ins && !after.is_empty() && l == format!(" - {after}") { + put(&mut res, &mut done); + } + let is_key = l.strip_prefix(key).and_then(|r| r.strip_prefix(':')).is_some_and(|r| { + let r = r.trim_start_matches(' '); + r.is_empty() || r.starts_with('#') + }); + if is_key { + ins = true; + if after == "-" { + put(&mut res, &mut done); + } + } + } + if ins { + put(&mut res, &mut done); + } + if !done { + res.push_str(&format!("{key}:\n - {v}\n")); + } + if write_replace(file, res.as_bytes()).is_err() { + die!("cannot update {}", file.display()); + } + Ok(()) +} + +pub fn apply(rt: &mut Route, ws: &Ws, pr: &Pr) -> Result<()> { + let lay = ws.meta("layout"); + let ctx = pr.wt.join("context"); + let mf = safe_path(&ctx, &format!("{}/manifest.yml", ws.meta("layout_path")))?; + + let manifest: Vec> = rt.plan.iter().filter(|r| r[0] == "manifest").cloned().collect(); + if !manifest.is_empty() { + rt.applied.extend(manifest); + if fs::copy(ws.dir.join(".delphi/manifest.yml"), &mf).is_err() { + die!("cannot copy manifest"); + } + pr.commit(&format!("delphi: update layout {lay} from workspace {}", ws.name), "")?; + } + + let mut patched: Vec = vec![]; + for r in rt.plan.clone() { + let (k, o, tgt, a, b) = (field(&r, 0), field(&r, 1), field(&r, 2), field(&r, 3), field(&r, 4)); + match k { + "patch" => { + if patched.iter().any(|p| p == tgt) || !ok_q(git_c(&pr.wt).args(["apply", "--unidiff-zero", a])) { + rt.row(&["unresolved", o, &format!("patch for {tgt} not applied")]); + let body = fs::read_to_string(a).unwrap_or_default(); + let body: Vec<&str> = awk_lines(&body).into_iter().skip(2).collect(); + rt.unresolved.push_str(&format!( + "#### `{o}`: patch for `{tgt}` not applied (one patch per block per propose; edit it in one place)\n\n```diff\n{}\n```\n\n", + body.join("\n") + )); + continue; + } + patched.push(tgt.to_string()); + } + "key" => { + if b.contains('"') { + rt.unres(ws, o, &format!("new {a} value contains a double quote (not supported)")); + continue; + } + let b = if b.contains(" #") || b.starts_with(['[', '{', '&', '*', '|', '>', '!', '#']) { + format!("\"{b}\"") + } else { + b.to_string() + }; + let dst = safe_path(&ctx, tgt)?; + let text = fs::read_to_string(&dst).unwrap_or_default(); + let mut d = false; + let mut res = String::new(); + for l in awk_lines(&text) { + if !d && l.starts_with(&format!("{a}:")) { + res.push_str(&format!("{a}: {b}\n")); + d = true; + } else { + res.push_str(l); + res.push('\n'); + } + } + if write_replace(&dst, res.as_bytes()).is_err() { + die!("cannot update {tgt}"); + } + } + _ => continue, + } + rt.applied.push(row(&[k, o, tgt])); + } + pr.commit(&format!("delphi: route block edits from workspace {}", ws.name), "")?; + + let (mut fa, mut ft) = (String::new(), String::new()); + for r in rt.plan.clone() { + let (k, o, tgt, a) = (field(&r, 0), field(&r, 1), field(&r, 2), field(&r, 3)); + let mut b = field(&r, 4).to_string(); + let src = match k { + "new" => ws.dir.join(o), + "fragment" => PathBuf::from(a), + _ => continue, + }; + let dst = safe_path(&ctx, tgt)?; + let copied = dst.parent().is_some_and(|p| fs::create_dir_all(p).is_ok()) && fs::copy(&src, &dst).is_ok(); + if !copied { + die!("cannot add {tgt}"); + } + rt.applied.push(row(&[k, o, tgt])); + if k == "fragment" { + // fragments after the same entry keep their file order + if b == fa { + b = ft.clone(); + } else { + fa = b.clone(); + } + list_add(&mf, "instructions", tgt, &b)?; + ft = tgt.to_string(); + continue; + } + if a.is_empty() { + continue; + } + let entries = parse_yaml(&mf)?.list(a); + if !covered(&b, &entries) { + list_add(&mf, a, &b, "")?; + } + } + pr.commit(&format!("delphi: add new files from workspace {}", ws.name), "")?; + Ok(()) +} + +// ---- route.awk: map one output file's `git diff -U0` hunks onto its lock segments ---- + +struct Hunk { + pos: i64, + n: i64, + m: i64, + body: String, +} + +struct Awk<'a> { + out: &'a str, + pdir: &'a Path, + pid: usize, + newok: bool, + s: Vec, + e: Vec, + src: Vec, + a: i64, + n: i64, + m: i64, + body: Vec, + inh: bool, + hunks: BTreeMap>, + rows: Vec>, + umd: &'a mut String, +} + +/// Rows for one output file: `patch` (one combined patch per block), `key` (edited name:/ +/// description: of a built skill), `fragment` (a new section; only when `newok`), `unresolved` +/// (also appended to `umd` as markdown). +fn route_file( + out: &str, + seg: &[Vec], + diff: &str, + pdir: &Path, + pid: usize, + newok: bool, + umd: &mut String, +) -> Result>> { + let mut w = Awk { + out, + pdir, + pid, + newok, + s: vec![0], + e: vec![0], + src: vec![String::new()], + a: 0, + n: 0, + m: 0, + body: vec![], + inh: false, + hunks: BTreeMap::new(), + rows: vec![], + umd, + }; + for r in seg { + w.s.push(num(field(r, 1))); + w.e.push(num(field(r, 2))); + w.src.push(field(r, 3).to_string()); + } + for l in awk_lines(diff) { + if l.starts_with("@@ ") { + w.flush()?; + w.header(l); + w.inh = true; + w.body.clear(); + } else if w.inh && l.starts_with(['-', '+', ' ']) { + // "\ No newline" lines are dropped: compiles end in one + w.body.push(l.to_string()); + } + } + w.flush()?; + w.emit()?; + Ok(w.rows) +} + +impl Awk<'_> { + fn ns(&self) -> usize { + self.s.len() - 1 + } + + fn header(&mut self, s: &str) { + let p: Vec<&str> = s.split_whitespace().collect(); + let q = substr(p.get(1).unwrap_or(&""), 2, None); + match q.split_once(',') { + Some((a, n)) => (self.a, self.n) = (num(a), num(n)), + None => (self.a, self.n) = (num(&q), 1), + } + let q = substr(p.get(2).unwrap_or(&""), 2, None); + self.m = q.split_once(',').map_or(1, |(_, m)| num(m)); + } + + fn isblock(&self, i: usize) -> bool { + i > 0 && !self.src[i].starts_with('@') + } + + fn seg(&self, l: i64) -> usize { + (1..=self.ns()).find(|&i| self.s[i] <= l && l <= self.e[i]).unwrap_or(0) + } + + fn rng(&self, lo: usize, hi: usize) -> String { + (lo..=hi).map(|k| format!("{}\n", self.body[k - 1])).collect() + } + + fn all(&self) -> String { + self.rng(1, self.body.len()) + } + + fn unresolved(&mut self, reason: &str) { + self.rows.push(row(&["unresolved", self.out, reason])); + let s = format!("#### `{}` (line {}): {reason}\n\n```diff\n{}```\n\n", self.out, self.a, self.all()); + self.umd.push_str(&s); + } + + fn hunk(&mut self, t: usize, pos: i64, n: i64, m: i64, body: String) { + self.hunks.entry(t).or_default().push(Hunk { pos, n, m, body }); + } + + fn blank(&self, k: usize) -> bool { + self.body[k - 1].strip_prefix('+').is_some_and(|r| r.chars().all(|c| c == ' ' || c == '\t')) + } + + fn flush(&mut self) -> Result<()> { + if !self.inh { + return Ok(()); + } + self.inh = false; + let (a, n, m) = (self.a, self.n, self.m); + let (t, pos); + if n > 0 { + let (i, j) = (self.seg(a), self.seg(a + n - 1)); + if i > 0 && i == j && self.isblock(i) && m == 0 && a == self.s[i] && a + n - 1 == self.e[i] { + self.unresolved("deletes a whole block; to drop a block, remove it from the manifest"); + return Ok(()); + } + if i > 0 && i == j && self.isblock(i) { + (t, pos) = (i, a - self.s[i] + 1); + } else if i > 0 && i == j && self.src[i].starts_with("@gen:") && self.src[i].ends_with(".skill") { + self.skillkeys(i); + return Ok(()); + } else { + self.unresolved("change spans segments or touches generated/separator lines"); + return Ok(()); + } + } else if self.newok && self.fragment()? { + return Ok(()); + } else if a == 0 { + let i = self.seg(1); + if self.isblock(i) { + (t, pos) = (i, 0); + } else { + self.unresolved("insertion at the top of the file, before generated lines"); + return Ok(()); + } + } else { + let (i, j) = (self.seg(a), self.seg(a + 1)); + if self.isblock(i) && a < self.e[i] { + (t, pos) = (i, a - self.s[i] + 1); // inside a block + } else if self.isblock(i) && !self.isblock(j) { + (t, pos) = (i, self.e[i] - self.s[i] + 1); // append to block + } else if !self.isblock(i) && self.isblock(j) { + (t, pos) = (j, 0); // prepend to next block + } else { + let r = if self.isblock(i) { + "insertion between two blocks" + } else { + "insertion inside generated/separator lines" + }; + self.unresolved(r); + return Ok(()); + } + } + let b = self.all(); + self.hunk(t, pos, n, m, b); + Ok(()) + } + + /// An insertion at a segment boundary is a new instruction fragment, except the lines touching + /// a neighbouring block with no blank line between, which extend that block. Writes the + /// fragment to a file and adds its row. + fn fragment(&mut self) -> Result { + let a = self.a; + let (i, j) = (self.seg(a), self.seg(a + 1)); + if a > 0 && a != self.e[i] { + return Ok(false); + } + let nb = self.body.len(); + let (mut lo, mut hi) = (1usize, nb); + if self.isblock(i) { + while lo <= nb && !self.blank(lo) { + lo += 1; + } + } + if self.isblock(j) { + while hi >= 1 && !self.blank(hi) { + hi -= 1; + } + } + let (ea, z) = (lo - 1, hi + 1); // 1..ea extends block i, z..nb extends block j + while lo <= hi && self.blank(lo) { + lo += 1; + } + while hi >= lo && self.blank(hi) { + hi -= 1; + } + if lo > hi { + return Ok(false); + } + if ea > 0 { + let b = self.rng(1, ea); + self.hunk(i, self.e[i] - self.s[i] + 1, 0, ea as i64, b); + } + if z <= nb { + let b = self.rng(z, nb); + self.hunk(j, 0, 0, (nb - z + 1) as i64, b); + } + let t = (1..=self.ns()).rev().find(|&t| self.isblock(t) && self.e[t] <= a).unwrap_or(0); + let mut s = String::new(); + let mut dash = false; + let first = self.body[lo - 1][1..].to_ascii_lowercase(); + let first = first.trim_start_matches([' ', '\t']); + let first = if first.starts_with('#') { + first.trim_start_matches('#') + } else { + &self.body[lo - 1][1..].to_ascii_lowercase() + }; + for c in first.chars() { + if c.is_ascii_lowercase() || c.is_ascii_digit() { + s.push(c); + dash = false; + } else if !dash { + s.push('-'); + dash = true; + } + } + let s = s.trim_matches('-'); + let s = substr(s, 1, Some(40)); + let s = s.trim_end_matches('-'); + let s = if s.is_empty() { "section" } else { s }; + let f = self.pdir.join(format!("{}.f{a}.md", self.pid)); + fs::write(&f, self.body[lo - 1..hi].iter().map(|l| format!("{}\n", &l[1..])).collect::())?; + let after = if t > 0 { self.src[t].clone() } else { "-".into() }; + self.rows.push(row(&["fragment", self.out, s, &f.to_string_lossy(), &after])); + Ok(true) + } + + fn skillkeys(&mut self, i: usize) { + let ok = self.n == self.m + && self.body.iter().all(|l| { + let r = &l[1..]; + (l.starts_with('-') || l.starts_with('+')) + && (r.starts_with("name: ") || r.starts_with("description: ")) + }); + if !ok { + self.unresolved("only name: and description: of a built skill's frontmatter can be edited"); + return; + } + let spec = substr(&self.src[i], 6, None); + for l in self.body.clone() { + let Some(l) = l.strip_prefix('+') else { continue }; + let key = l.split(':').next().unwrap_or(""); + let val = match l.find(':') { + Some(c) if l[c..].starts_with(": ") => &l[c + 2..], + _ => l, + }; + self.rows.push(row(&["key", self.out, &spec, key, val])); + } + } + + fn emit(&mut self) -> Result<()> { + for t in 1..=self.ns() { + let Some(hs) = self.hunks.get(&t) else { continue }; + let src = &self.src[t]; + let f = self.pdir.join(format!("{}.{t}.patch", self.pid)); + let mut p = format!("--- a/context/{src}\n+++ b/context/{src}\n"); + let mut off = 0; + for h in hs { + let c = if h.n == 0 { + h.pos + off + 1 + } else if h.m == 0 { + h.pos + off - 1 + } else { + h.pos + off + }; + p.push_str(&format!("@@ -{},{} +{c},{} @@\n{}", h.pos, h.n, h.m, h.body)); + off += h.m - h.n; + } + fs::write(&f, p)?; + self.rows.push(row(&["patch", self.out, src, &f.to_string_lossy()])); + } + Ok(()) + } +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 index 0000000..1037faa --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,41 @@ +//! `delphi setup [dir]`: record the Delphi checkout (default: the one containing the current +//! directory, or DELPHI_ROOT) in `~/.config/delphi/root`, so the installed binary finds it from +//! workspaces and anywhere else. Replaces lib/setup.sh's PATH wrapper: `cargo install` puts the +//! binary on PATH. + +use crate::core::{cwd, env_nonempty, have, is_delphi_repo, root_config_file}; +use crate::{die, info, warn}; +use anyhow::Result; +use std::fs; +use std::path::PathBuf; + +pub fn main(args: &[String]) -> Result<()> { + if args.len() > 1 { + die!("usage: delphi setup [dir]"); + } + let dir = match args.first() { + Some(d) => PathBuf::from(d), + None => match env_nonempty("DELPHI_ROOT") { + Some(r) => PathBuf::from(r), + None => match cwd().ancestors().find(|d| is_delphi_repo(d)) { + Some(d) => d.to_path_buf(), + None => die!("not inside a Delphi checkout; run 'delphi setup '"), + }, + }, + }; + let dir = match fs::canonicalize(&dir) { + Ok(d) if is_delphi_repo(&d) => d, + _ => die!("not a Delphi checkout (needs delphi.conf and context/): {}", dir.display()), + }; + let f = root_config_file(); + let written = f.parent().is_some_and(|p| fs::create_dir_all(p).is_ok()) + && fs::write(&f, format!("{}\n", dir.display())).is_ok(); + if !written { + die!("cannot write {}", f.display()); + } + info!("recorded Delphi checkout {} in {}", dir.display(), f.display()); + if !have("delphi") { + warn!("delphi is not on PATH; install it with: cargo install --path {}", dir.display()); + } + Ok(()) +} diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..9293023 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,678 @@ +//! `delphi workspace new|open|refresh|propose|status` (alias: ws). Port of lib/workspace.sh. +//! +//! A workspace is its own git repo outside Delphi. Refs: branch `generated` (compiles only), tag +//! `generated-merged` (latest compile merged into `working`), branch `working` (the user's). +//! Bookkeeping lives in .git/delphi/meta (key=value); meta.compile_commit is authoritative. + +use crate::check::check_tree; +use crate::compile::compile; +use crate::core::{ + ask, awk_lines, conf_get, cwd, delphi_commit, delphi_fetch, delphi_worktree_at, dgit, find_layout, git_c, git_in, + glob_dir, is_tty, make_tmp, move_path, moves_since, now, ok, ok_q, out, out_q, out_raw, out_stdin, parse_args, + rewrite_moves, root, run_deferred, workspace_root, write_replace, Exit, Opts, +}; +use crate::harness::{self, Harness}; +use crate::parse::parse_yaml; +use crate::provenance::{self}; +use crate::route; +use crate::{die, info, warn}; +use anyhow::Result; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub fn main(args: &[String]) -> Result<()> { + let verb = args.first().map(String::as_str).unwrap_or(""); + let flags = match verb { + "new" => "--as --ref", + "open" => "--model --effort --shell", + "refresh" => "--ref", + "propose" => "--model --effort --yes --dry-run", + "status" => "--offline", + _ => die!("usage: delphi workspace new|open|refresh|propose|status (see: delphi help)"), + }; + let (o, pos) = parse_args(flags, &args[1..])?; + if pos.len() > 1 { + die!("too many arguments (see: delphi help)"); + } + let name = pos.first().map(String::as_str).unwrap_or(""); + match verb { + "new" => { + if pos.len() != 1 { + die!("usage: delphi workspace new [--as ] [--ref ]"); + } + ws_new(name, &o) + } + "open" => ws_open(&resolve(name)?, &o), + "refresh" => { + let ws = resolve(name)?; + delphi_fetch(); + refresh(&ws, &o.ref_) + } + "propose" => propose(&resolve(name)?, &o), + _ => { + if !pos.is_empty() { + die!("usage: delphi workspace status [--offline]"); + } + status() + } + } +} + +// ---- helpers ---- +pub struct Ws { + pub dir: PathBuf, + pub name: String, +} + +impl Ws { + fn at(name: &str) -> Ws { + Ws { dir: workspace_root().join(name), name: name.into() } + } + + pub fn git(&self) -> Command { + git_in(&self.dir) + } + + fn meta_file(&self) -> PathBuf { + self.dir.join(".git/delphi/meta") + } + + pub fn meta(&self, k: &str) -> String { + let text = fs::read_to_string(self.meta_file()).unwrap_or_default(); + awk_lines(&text) + .into_iter() + .map(|l| l.split_once('=').unwrap_or((l, l))) + .find(|(key, _)| *key == k) + .map(|(_, v)| v.to_string()) + .unwrap_or_default() + } + + pub fn meta_set(&self, k: &str, v: &str) -> Result<()> { + let f = self.meta_file(); + let text = fs::read_to_string(&f).unwrap_or_default(); + let mut d = false; + let mut res = String::new(); + for l in awk_lines(&text) { + if l.split_once('=').map_or(l, |x| x.0) == k { + res.push_str(&format!("{k}={v}\n")); + d = true; + } else { + res.push_str(l); + res.push('\n'); + } + } + if !d { + res.push_str(&format!("{k}={v}\n")); + } + if write_replace(&f, res.as_bytes()).is_err() { + die!("cannot write {}", f.display()); + } + Ok(()) + } + + /// The pending diff (everything changed since the latest merged compile). + pub fn pending(&self, opts: &[&str]) -> String { + let mut c = self.git(); + c.args(["diff", "--no-renames", "--no-ext-diff", "--no-color"]).args(opts); + c.args(["generated-merged", "HEAD", "--", ".", ":(exclude).delphi/lock.tsv"]); + String::from_utf8_lossy(&out_raw(&mut c).1).into_owned() + } + + fn hash(&self) -> String { + let p = self.pending(&["-U0"]); + let kept: String = awk_lines(&p) + .into_iter() + .filter(|l| !l.starts_with("@@") && !l.starts_with("index ")) + .map(|l| format!("{l}\n")) + .collect(); + out_stdin(Command::new("git").args(["hash-object", "--stdin"]), kept.as_bytes()).unwrap_or_default() + } + + fn clean(&self) -> bool { + out(self.git().args(["status", "--porcelain"])).unwrap_or_default().is_empty() + } + + fn merging(&self) -> bool { + self.dir.join(".git/MERGE_HEAD").is_file() + } + + fn rev(&self, r: &str) -> String { + out(self.git().args(["rev-parse", r])).unwrap_or_default() + } +} + +fn ws_names() -> Vec { + let r = workspace_root(); + glob_dir(&r).into_iter().filter(|d| r.join(d).join(".git/delphi/meta").is_file()).collect() +} + +fn ws_name_ok(n: &str) -> Result<()> { + let bad = + n.is_empty() || n.starts_with('.') || !n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)); + if bad { + die!("invalid workspace name: '{n}'"); + } + Ok(()) +} + +/// The workspace from a name, the current directory, or a picker. +fn resolve(name: &str) -> Result { + let dir = if !name.is_empty() { + ws_name_ok(name)?; + workspace_root().join(name) + } else if let Some(d) = cwd().ancestors().find(|d| d.join(".git/delphi/meta").is_file()) { + d.to_path_buf() + } else { + let list = ws_names(); + if list.is_empty() { + die!("no workspaces in {} (create one: delphi workspace new )", workspace_root().display()); + } + if !is_tty() { + die!("not inside a workspace; name one (see: delphi workspace status)"); + } + for (i, n) in list.iter().enumerate() { + info!(" {}) {n}", i + 1); + } + let n = ask("Workspace number?", "")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + die!("not a number: '{n}'"); + } + match n.parse::().ok().filter(|&i| i >= 1).and_then(|i| list.get(i - 1)) { + Some(d) => workspace_root().join(d), + None => die!("no workspace #{n}"), + } + }; + if !dir.join(".git/delphi/meta").is_file() { + die!("not a Delphi workspace: {}", dir.display()); + } + let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok(Ws { dir, name }) +} + +// ---- compiling into the workspace ---- +/// Compile into a temp dir; returns (dir, harness). +fn ws_compile(src: &Path, lp: &str) -> Result<(PathBuf, &'static Harness)> { + let d = make_tmp()?.join("out"); + if fs::create_dir(&d).is_err() { + die!("mkdir failed"); + } + let h = compile(src, lp, &d)?; + Ok((d, h)) +} + +/// Commit outdir as the next `generated` commit (plumbing, so no worktree or hooks). False when +/// the content equals the current `generated`. +fn commit_compile(ws: &Ws, outdir: &Path, c: &str) -> Result { + let idx = make_tmp()?.join("index"); + let gd = format!("--git-dir={}", ws.dir.join(".git").display()); + if !ok(git_c(outdir).env("GIT_INDEX_FILE", &idx).args([gd.as_str(), "--work-tree=.", "add", "-A", "-f"])) { + die!("cannot stage compile"); + } + let Some(tree) = out(Command::new("git").env("GIT_INDEX_FILE", &idx).args([gd.as_str(), "write-tree"])) else { + die!("cannot write compile tree") + }; + let mut ct = ws.git(); + ct.args(["commit-tree"]); + if let Some(parent) = out(ws.git().args(["rev-parse", "-q", "--verify", "generated^{commit}"])) { + if tree == ws.rev("generated^{tree}") { + return Ok(false); + } + ct.args(["-p", &parent]); + } + let msg = format!("delphi: compile {}@{}\n\nDelphi-Compile: {c}\n", ws.meta("layout"), &c[..c.len().min(7)]); + let Some(commit) = out_stdin(ct.arg(&tree), msg.as_bytes()) else { die!("cannot commit compile") }; + if !ok(ws.git().args(["update-ref", "refs/heads/generated", &commit])) { + die!("cannot update generated"); + } + Ok(true) +} + +const HOOK: &str = r#"#!/bin/sh +# Delphi: append provenance trailers from DELPHI_* env vars when set and not already present. +for kv in "Delphi-Harness=$DELPHI_HARNESS" "Delphi-Model=$DELPHI_MODEL" "Delphi-Effort=$DELPHI_EFFORT"; do + k=${kv%%=*} v=${kv#*=} + if [ -n "$v" ] && ! grep -q "^$k:" "$1"; then git interpret-trailers --in-place --trailer "$k: $v" "$1"; fi +done +exit 0 +"#; + +fn ws_new(layout: &str, o: &Opts) -> Result<()> { + let r#ref = if o.ref_.is_empty() { "main" } else { &o.ref_ }; + let name = if o.as_.is_empty() { layout } else { &o.as_ }; + ws_name_ok(name)?; + let ws = Ws::at(name); + if ws.dir.exists() { + die!("workspace already exists: {}", ws.dir.display()); + } + delphi_fetch(); + let c = delphi_commit(r#ref)?; + let src = delphi_worktree_at(&c)?; + let lp = find_layout(&src, layout)?; + let recs = parse_yaml(&src.join("context").join(&lp).join("manifest.yml"))?; + let (outdir, h) = ws_compile(&src, &lp)?; + + if fs::create_dir_all(&ws.dir).is_err() || !ok(Command::new("git").args(["init", "-q"]).arg(&ws.dir)) { + die!("git init failed: {}", ws.dir.display()); + } + let g = ws.dir.join(".git"); + for d in [g.join("delphi"), g.join("info"), ws.dir.join("repos"), ws.dir.join("worktrees")] { + fs::create_dir_all(d)?; + } + let exclude = g.join("info/exclude"); + let mut ex = fs::read(&exclude).unwrap_or_default(); + ex.extend_from_slice(format!("repos/\nworktrees/\n{}\n", h.ignore).as_bytes()); + fs::write(&exclude, ex)?; + let hook = g.join("hooks/commit-msg"); + fs::create_dir_all(g.join("hooks"))?; + fs::write(&hook, HOOK)?; + fs::set_permissions(&hook, fs::Permissions::from_mode(0o755))?; + fs::write( + g.join("delphi/meta"), + format!( + "layout={layout}\nlayout_path={lp}\nref={}\nharness={}\ncreated={}\nlast_proposed=\nlast_proposed_hash=\npending_since=\ncompile_commit={c}\n", + r#ref, + h.name, + now() + ), + )?; + if !commit_compile(&ws, &outdir, &c)? { + die!("empty compile"); + } + if !(ok(ws.git().args(["tag", "generated-merged", "generated"])) + && ok(ws.git().args(["checkout", "-q", "-B", "working", "generated"]))) + { + die!("cannot create working branch"); + } + + let mut failed = String::new(); + for (n, url) in recs.map("repos") { + if n.is_empty() { + continue; + } + if n.starts_with('.') || !n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) { + warn!("skipping repo with invalid name '{n}'"); + continue; + } + info!("cloning {n}…"); + if !ok(Command::new("git").args(["clone", "-q", &url]).arg(ws.dir.join("repos").join(&n))) { + warn!("clone failed: {n} ({url})"); + failed.push_str(&format!(" {n}")); + } + } + info!("workspace ready: {}", ws.dir.display()); + if !failed.is_empty() { + warn!("repos not cloned:{failed} (clone them into repos/ yourself)"); + } + info!("next: delphi workspace open {}", ws.name); + Ok(()) +} + +// ---- refresh ---- +/// Compile origin/ onto `generated`. True if it changed. +fn compile_ref(ws: &Ws) -> Result { + let r#ref = ws.meta("ref"); + let Some(c) = out(&mut dgit(["rev-parse", "-q", "--verify", &format!("origin/{}^{{commit}}", r#ref)])) else { + die!("branch '{}' is gone — run: delphi workspace refresh --ref main", r#ref) + }; + let src = delphi_worktree_at(&c)?; + let rows = moves_since(&ws.meta("compile_commit"), &c); + let lp = move_path(&rows, &ws.meta("layout_path")); + ws.meta_set("layout_path", &lp)?; + let (outdir, h) = ws_compile(&src, &lp)?; + ws.meta_set("harness", h.name)?; + if commit_compile(ws, &outdir, &c)? { + Ok(true) + } else { + apply_moves(ws, &c)?; + Ok(false) + } +} + +fn merge(ws: &Ws) -> Result<()> { + if ok_q(ws.git().args(["merge", "-q", "--no-verify", "--no-edit", "generated"])) { + return Ok(()); + } + if !ws.merging() { + die!("merge of generated failed in {}", ws.dir.display()); + } + info!("conflicts in {}:", ws.name); + let u = out(ws.git().args(["diff", "--name-only", "--diff-filter=U"])).unwrap_or_default(); + for l in awk_lines(&u) { + info!(" {l}"); + } + info!("resolve them, commit, then re-run: delphi workspace refresh {}", ws.name); + Err(Exit(2).into()) +} + +/// Rewrite paths moved since compile_commit in .delphi/manifest.yml; the workspace's compile now +/// corresponds to . +fn apply_moves(ws: &Ws, c: &str) -> Result<()> { + let rows = moves_since(&ws.meta("compile_commit"), c); + ws.meta_set("compile_commit", c)?; + if rewrite_moves(&rows, &ws.dir.join(".delphi/manifest.yml")).unwrap_or(false) + && !ok(ws.git().args(["commit", "-q", "--no-verify", "-am", "delphi: apply moves"])) + { + die!("cannot commit moved paths"); + } + Ok(()) +} + +fn finalize(ws: &Ws) -> Result<()> { + if !ok(ws.git().args(["tag", "-f", "generated-merged", "generated"]).stdout(Stdio::null())) { + die!("cannot move generated-merged"); + } + let t = out(ws.git().args(["log", "-1", "--format=%(trailers:key=Delphi-Compile,valueonly)", "generated"])) + .unwrap_or_default(); + let c: Vec<&str> = awk_lines(&t).into_iter().filter(|l| !l.is_empty()).collect(); + apply_moves(ws, &c.join("\n"))?; + let cc = ws.meta("compile_commit"); + info!("{}: merged compile of origin/{} ({})", ws.name, ws.meta("ref"), &cc[..cc.len().min(7)]); + Ok(()) +} + +/// Merge a pending compile into HEAD (exits 2 on conflicts), then finalize it. +fn catch_up(ws: &Ws) -> Result<()> { + if !ok(ws.git().args(["merge-base", "--is-ancestor", "generated", "HEAD"])) { + merge(ws)?; + } + if ws.rev("generated") != ws.rev("generated-merged^{commit}") { + finalize(ws)?; + } + Ok(()) +} + +/// Idempotent. Ok when up to date or finalized; exit 2 on conflicts. +fn refresh(ws: &Ws, new_ref: &str) -> Result<()> { + ok(ws.git().args(["worktree", "prune"])); + if ws.merging() { + die!( + "merge in progress in {}: resolve conflicts, commit, then re-run 'delphi workspace refresh'", + ws.dir.display() + ); + } + if !ws.clean() { + die!("{} has uncommitted changes; commit or stash them first", ws.dir.display()); + } + if !new_ref.is_empty() { + ws.meta_set("ref", new_ref)?; + } + catch_up(ws)?; + if compile_ref(ws)? { + catch_up(ws)?; + } else { + info!("{}: up to date with origin/{}", ws.name, ws.meta("ref")); + } + if ws.pending(&["--name-only"]).is_empty() { + ws.meta_set("pending_since", &ws.rev("HEAD"))?; + } + Ok(()) +} + +// ---- status ---- +/// "yes" if origin/ has commits since compile_commit touching this workspace's sources. +fn behind(ws: &Ws) -> &'static str { + let rc = ws.meta("compile_commit"); + let Some(tip) = out(&mut dgit(["rev-parse", "-q", "--verify", &format!("origin/{}^{{commit}}", ws.meta("ref"))])) + else { + return "gone"; + }; + if tip == rc { + return "no"; + } + let mut paths: Vec = vec![]; + let lock = out(ws.git().args(["show", "generated-merged:.delphi/lock.tsv"])).unwrap_or_default(); + for l in awk_lines(&lock).into_iter().filter(|l| !l.starts_with('#')) { + let mut s = l.split('\t').nth(3).unwrap_or(""); + if let Some(g) = s.strip_prefix("@gen:").filter(|g| g.contains('/')) { + s = g; + } + if !s.starts_with('@') { + paths.push(format!("context/{s}")); + } + } + paths.push(format!("context/{}/manifest.yml", ws.meta("layout_path"))); + let mf = out(ws.git().args(["show", "generated-merged:.delphi/manifest.yml"])).unwrap_or_default(); + for l in awk_lines(&mf) { + if let Some(d) = l.strip_prefix(" - ").and_then(|v| v.strip_suffix("/*")) { + paths.push(format!("context/{d}")); + } + } + paths.sort(); + paths.dedup(); + let range = format!("{rc}..{tip}"); + match out_q(dgit(["log", "-1", "--format=x", &range, "--"]).args(&paths)) { + Some(s) if s.is_empty() => "no", + _ => "yes", + } +} + +struct State { + dirty: bool, + state: &'static str, + age: String, + stale: bool, +} + +fn stale_days() -> i64 { + conf_get("stale_days", "14").trim().parse().unwrap_or(14) +} + +fn state(ws: &Ws) -> State { + let dirty = !ws.clean(); + let state = if ws.pending(&["--name-only"]).is_empty() { + "clean" + } else if ws.hash() == ws.meta("last_proposed_hash") { + "proposed" + } else { + "unproposed" + }; + let (mut age, mut stale) = ("-".to_string(), false); + if state == "unproposed" { + let mut base = ws.meta("last_proposed"); + if base.is_empty() { + base = ws.meta("created"); + } + let days = (now() as i64 - base.trim().parse::().unwrap_or(0)) / 86400; + age = format!("{days}d"); + stale = days > stale_days(); + } + State { dirty, state, age, stale } +} + +fn status() -> Result<()> { + delphi_fetch(); + let row = |c: [&str; 8]| { + println!("{:<22} {:<16} {:<8} {:<5} {:<10} {:<5} {:<6} {}", c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]) + }; + row(["WORKSPACE", "LAYOUT", "REF", "DIRTY", "STATE", "AGE", "BEHIND", "NEXT"]); + let (mut n, mut c, mut p, mut u, mut s) = (0, 0, 0, 0, 0); + for name in ws_names() { + let ws = Ws::at(&name); + let mut st = state(&ws); + let b = behind(&ws); + let next = if ws.merging() { + format!("resolve conflicts, commit, then: delphi ws refresh {name}") + } else if st.dirty { + format!("commit your changes in {}", ws.dir.display()) + } else if b == "gone" { + format!("delphi ws refresh {name} --ref main") + } else if b == "yes" { + format!("delphi ws refresh {name}") + } else if st.state == "unproposed" { + format!("delphi ws propose {name}") + } else { + format!("delphi ws open {name}") + }; + if st.stale { + st.age.push('!'); + s += 1; + } + let dirty = if st.dirty { "yes" } else { "no" }; + row([&name, &ws.meta("layout"), &ws.meta("ref"), dirty, st.state, &st.age, b, &next]); + n += 1; + match st.state { + "clean" => c += 1, + "proposed" => p += 1, + _ => u += 1, + } + } + println!( + "{n} workspace(s): {c} clean, {p} proposed, {u} unproposed ({s} stale: unproposed > {} days)", + conf_get("stale_days", "14") + ); + Ok(()) +} + +// ---- open ---- +fn ws_open(me: &Ws, o: &Opts) -> Result<()> { + delphi_fetch(); + for name in ws_names() { + let st = state(&Ws::at(&name)); + if st.stale { + warn!("{name} has been unproposed for {}; run: delphi ws propose {name}", st.age); + } + } + let ws = Ws::at(&me.name); + if behind(&ws) != "no" { + warn!( + "{} is behind origin/{} (or its branch is gone); run: delphi ws refresh {}", + ws.name, + ws.meta("ref"), + ws.name + ); + } + let h = harness::load(&ws.meta("harness"))?; + let [dh, _, _] = (h.provenance)(); + let pick = + |flag: &str, var: &str| if flag.is_empty() { std::env::var(var).unwrap_or_default() } else { flag.to_string() }; + let (model, effort) = (pick(&o.model, "DELPHI_MODEL"), pick(&o.effort, "DELPHI_EFFORT")); + let mut cmd = if o.shell { + Command::new(crate::core::env_nonempty("SHELL").unwrap_or_else(|| "/bin/sh".into())) + } else { + (h.launch)(&model, &effort) + }; + cmd.current_dir(&ws.dir) + .env("PWD", &ws.dir) + .env("DELPHI_HARNESS", dh) + .env("DELPHI_MODEL", &model) + .env("DELPHI_EFFORT", &effort); + run_deferred(); + let e = cmd.exec(); + die!("cannot start {:?}: {e}", cmd.get_program()) +} + +// ---- propose ---- +fn print_plan(rt: &route::Route) { + for r in rt.plan.iter().filter(|r| r[0] != "unresolved") { + info!(" {:<9} {} -> {}", r[0], r.get(1).map_or("", |s| s), r.get(2).map_or("", |s| s)); + } + if !rt.unresolved.is_empty() { + info!("Unresolved:"); + eprint!("{}", rt.unresolved); + } +} + +/// Distinct harness|model|effort trailers from workspace commits since pending_since. +fn prov_rows(ws: &Ws, me: &str) -> String { + let f = "%(trailers:key=Delphi-Harness,valueonly,separator=)|%(trailers:key=Delphi-Model,valueonly,separator=)|%(trailers:key=Delphi-Effort,valueonly,separator=)"; + let since = ws.meta("pending_since"); + let range = if since.is_empty() { "HEAD".to_string() } else { format!("{since}..HEAD") }; + let log = out(ws.git().args(["log", "--no-merges", &format!("--format={f}"), &range])).unwrap_or_default(); + let mut seen: Vec<&str> = vec![]; + for l in awk_lines(&log) { + if l != "||" && l != me && !seen.contains(&l) { + seen.push(l); + } + } + seen.join("\n") +} + +/// After an earlier push, warn if that PR is still open. +fn warn_open_pr(ws: &Ws, branch: &str) { + if ws.meta("last_pushed").is_empty() { + return; + } + let q = ".[0].number // empty"; + let mut c = Command::new("gh"); + c.current_dir(root()).args(["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--jq", q]); + let n = out_q(&mut c).unwrap_or_default(); + if !n.is_empty() { + warn!("PR #{n} still contains earlier changes; close it with: gh pr close {n}"); + } +} + +fn propose(ws: &Ws, o: &Opts) -> Result<()> { + if o.dry { + delphi_fetch(); + if behind(ws) != "no" { + warn!("{} is behind origin/{}; planning against the last compile", ws.name, ws.meta("ref")); + } + print_plan(&route::plan(ws)?); + return Ok(()); + } + if ws.meta("ref") != "main" { + die!("layout not on main yet — merge its PR, then run: delphi workspace refresh --ref main"); + } + delphi_fetch(); + refresh(ws, "")?; + let mut rt = route::plan(ws)?; + let mut user = String::new(); + if out_q(&mut dgit(["remote", "get-url", "origin"])).unwrap_or_default().contains("github.com") { + user = out_q(Command::new("gh").args(["api", "user", "--jq", ".login"])).unwrap_or_default(); + } + if user.is_empty() { + user = crate::core::env_nonempty("USER").unwrap_or_else(|| "me".into()); + } + let branch = format!("delphi/propose/{user}/{}", ws.name); + if rt.plan.iter().all(|r| r[0] == "noop") { + info!("nothing to propose"); + warn_open_pr(ws, &branch); + return Ok(()); + } + // lease: the branch must be absent or exactly what we last pushed + let Some(ls) = out(&mut dgit(["ls-remote", "--heads", "origin", &format!("refs/heads/{branch}")])) else { + die!("cannot reach origin") + }; + let remote = awk_lines(&ls).iter().map(|l| l.split('\t').next().unwrap_or("")).collect::>().join("\n"); + if !remote.is_empty() && remote != ws.meta("last_pushed") { + ws.meta_set("last_pushed", &remote)?; + die!("the propose branch changed on GitHub (someone pushed to it); review the PR, then re-run to overwrite it"); + } + let mut prov = provenance::resolve(&o.model, &o.effort, &ws.meta("harness"))?; + let cc = ws.meta("compile_commit"); + prov.extra = format!( + "Delphi-Layout: {}\nDelphi-Workspace: {}\nDelphi-Base: {}", + ws.meta("layout_path"), + ws.name, + &cc[..cc.len().min(7)] + ); + prov.rows = prov_rows(ws, &format!("{}|{}|{}", prov.harness, prov.model, prov.effort)); + let mut pr = crate::pr::begin(&branch, &cc, prov)?; + route::apply(&mut rt, ws, &pr)?; + if !pr.has_commits() { + print_plan(&rt); + info!("nothing routable to propose; resolve the items above in the workspace"); + warn_open_pr(ws, &branch); + return Ok(()); + } + if !check_tree(&pr.wt)? { + die!("check failed on the proposed tree; not pushed (branch {} kept locally)", pr.branch); + } + let mut body = String::from("## Routed changes\n\n"); + for r in &rt.applied { + body.push_str(&format!("- {}: `{}` (from workspace `{}`)\n", r[0], r[2], r[1])); + } + body.push_str("\n## Unresolved\n\n"); + body.push_str(if rt.unresolved.is_empty() { "None.\n" } else { &rt.unresolved }); + let title = format!("delphi: changes from workspace {} ({})", ws.name, ws.meta("layout")); + pr.finish(&title, body.trim_end_matches('\n'), Some(&remote))?; + if pr.pushed { + ws.meta_set("last_proposed", &now().to_string())?; + ws.meta_set("last_proposed_hash", &ws.hash())?; + ws.meta_set("last_pushed", &out(git_c(&pr.wt).args(["rev-parse", "HEAD"])).unwrap_or_default())?; + } + Ok(()) +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..8f9f656 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,632 @@ +//! End-to-end tests of the Rust CLI in a sandbox, asserting parity with the bash CLI where it is +//! available (same sandbox, same commands, identical output). + +mod common; + +use common::{same, Out, Sb, BLOCKS, LAYOUT}; +use std::fs; +use std::os::unix::fs::PermissionsExt; + +/// Create a workspace with the Rust CLI (and, when possible, a sibling one with bash). +fn new_pair(sb: &Sb) -> (Out, Option) { + let r = sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-rust"]); + assert_eq!(r.code, 0, "{r:#?}"); + let b = sb.bash(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-bash"]); + (r, b) +} + +fn norm(o: &Option) -> Option { + o.as_ref().map(|o| o.norm("ws-bash", "ws-rust")) +} + +/// The same edits in a workspace, committed. +fn edit(sb: &Sb, ws: &str, script: &str) { + sb.sh(&sb.ws(ws), &format!("{script}\ngit add -A\ngit commit -qm edits")); +} + +const EDITS: &str = r#" +A=context/software/application-software/argos/blocks +sed -i.bak 's/Line two./Line two, edited./' $A/overview.md && rm $A/overview.md.bak +printf '# New block\n\nFresh.\n' > $A/new.md +mkdir -p docs && printf '# Guide\n' > docs/guide.md +printf 'notes\n' > .claude/skills/run-tests/notes.md +mkdir -p .claude/skills/lint && printf -- '---\nname: lint\ndescription: Lint.\n---\n' > .claude/skills/lint/SKILL.md +printf 'Appended line.\n\n## Deploying\n\nUse the pipeline.\n' >> CLAUDE.md +"#; + +#[test] +fn usage_and_argument_errors_match_bash() { + let sb = Sb::new("args"); + // parity checks only skip when the bash CLI has been removed from the repo + let bash_present = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("lib/core.sh").exists(); + assert_eq!(sb.has_bash(), bash_present && std::path::Path::new("/bin/bash").exists()); + let r = sb.rust(&sb.dir, &[]); + assert_eq!(r.code, 0); + assert!(r.stdout.starts_with("usage: delphi [args]")); + for args in [ + &["bogus"][..], + &["ws", "frob"], + &["ws", "new"], + &["ws", "new", "a", "b"], + &["ws", "status", "--bogus"], + &["ws", "status", "extra"], + &["ws", "new", "argos-dev", "--as"], + &["ws", "new", "Bad_Name!"], + &["ws", "new", "nope"], + &["ws", "refresh", "missing"], + &["layout"], + &["layout", "list", "--yes"], + &["layout", "new", "software", "Bad"], + &["block", "mv", "a"], + &["block", "mv", "a/x", "b/y", "--yes"], + &["check", "extra"], + ] { + let r = sb.rust(&sb.dir, args); + assert_ne!(r.code, 0, "{args:?}"); + same(&r, &sb.bash(&sb.dir, args)); + } + sb.assert_cleaned_up(); +} + +#[test] +fn workspace_new_compiles_like_bash() { + let sb = Sb::new("new"); + let (r, b) = new_pair(&sb); + let ws = sb.ws("ws-rust"); + assert_eq!( + r.stderr, + format!("cloning argos…\nworkspace ready: {}\nnext: delphi workspace open ws-rust\n", ws.display()) + ); + same(&r, &norm(&b)); + + // compile output + let claude = fs::read_to_string(ws.join("CLAUDE.md")).unwrap(); + assert_eq!( + claude, + "\n\ + \n\n\ + # Software conventions\n\n- Use conventional commits.\n- Open PRs against develop.\n\n\ + # Argos\n\nArgos is the telemetry dashboard.\nIt has an Angular client and a Rust server.\n" + ); + let mf_sha = sb.git(&sb.root(), &["hash-object", &format!("context/{LAYOUT}/manifest.yml")]); + let lock = fs::read_to_string(ws.join(".delphi/lock.tsv")).unwrap(); + let expect = format!( + "# output\tstart\tend\tsource\tsha +CLAUDE.md\t1\t2\t@gen:delphi\t- +CLAUDE.md\t3\t3\t@glue\t- +CLAUDE.md\t4\t7\tsoftware/harness/instructions/base.md\te15e9d20225a14a4537a670898ae51ad85f164c2 +CLAUDE.md\t8\t8\t@glue\t- +CLAUDE.md\t9\t12\tsoftware/application-software/argos/harness/instructions/argos.md\t1e293bae4c5fc02f774bacc6dccf4196f5f62e5f +context/software/application-software/argos/blocks/overview.md\t1\t5\tsoftware/application-software/argos/blocks/overview.md\te9c980c2f329f7398d66b9895521c1f2abcbb0f1 +context/software/application-software/argos/blocks/testing.md\t1\t4\tsoftware/application-software/argos/blocks/testing.md\tc95bcf63822f0a48402ba742eff5a7603edeb698 +.claude/skills/run-tests/SKILL.md\t1\t6\tsoftware/application-software/argos/harness/skills/run-tests/SKILL.md\ta5bd6b06d122106ef62bda5994102db63ef017b0 +.claude/skills/run-tests/scripts/run.sh\t1\t2\tsoftware/application-software/argos/harness/skills/run-tests/scripts/run.sh\t329015816566c2f78499d85ca721636166cc0c2a +.claude/skills/triage/SKILL.md\t1\t4\t@gen:software/application-software/argos/harness/skills/triage.skill\t- +.claude/skills/triage/SKILL.md\t5\t5\t@glue\t- +.claude/skills/triage/SKILL.md\t6\t9\tsoftware/application-software/argos/blocks/testing.md\tc95bcf63822f0a48402ba742eff5a7603edeb698 +.mcp.json\t1\t1\t@gen:delphi\t- +.mcp.json\t2\t5\tsoftware/harness/mcp/github.json\t2457b9f5b11fc333819bd215d2031dc9391f6219 +.mcp.json\t6\t6\t@gen:delphi\t- +.delphi/manifest.yml\t1\t14\t{LAYOUT}/manifest.yml\t{mf_sha} +" + ); + assert_eq!(lock, expect); + let tree = sb.git(&ws, &["ls-tree", "-r", "generated", ".claude/skills/run-tests/scripts/run.sh"]); + assert!(tree.starts_with("100755 "), "{tree}"); + + // refs, meta, git plumbing + let head = sb.git(&ws, &["rev-parse", "--abbrev-ref", "HEAD"]); + assert_eq!(head, "working"); + assert_eq!(sb.git(&ws, &["rev-parse", "generated"]), sb.git(&ws, &["rev-parse", "generated-merged^{commit}"])); + let msg = sb.git(&ws, &["log", "-1", "--format=%B", "generated"]); + let c = sb.git(&sb.root(), &["rev-parse", "origin/main"]); + assert_eq!(msg, format!("delphi: compile argos-dev@{}\n\nDelphi-Compile: {c}", &c[..7])); + let meta = fs::read_to_string(ws.join(".git/delphi/meta")).unwrap(); + let meta: Vec<&str> = meta.lines().filter(|l| !l.starts_with("created=")).collect(); + assert_eq!( + meta, + [ + "layout=argos-dev".to_string(), + format!("layout_path={LAYOUT}"), + "ref=main".into(), + "harness=claude-code".into(), + "last_proposed=".into(), + "last_proposed_hash=".into(), + "pending_since=".into(), + format!("compile_commit={c}"), + ] + ); + let exclude = fs::read_to_string(ws.join(".git/info/exclude")).unwrap(); + assert!(exclude.ends_with("repos/\nworktrees/\n.claude/settings.local.json\n")); + let hook = fs::metadata(ws.join(".git/hooks/commit-msg")).unwrap(); + assert_eq!(hook.permissions().mode() & 0o111, 0o111); + assert!(ws.join("repos/argos/README.md").is_file()); + + // byte-for-byte parity with the bash compile (same Delphi commit -> same tree hash) + if b.is_some() { + let wb = sb.ws("ws-bash"); + assert_eq!(sb.git(&ws, &["rev-parse", "generated^{tree}"]), sb.git(&wb, &["rev-parse", "generated^{tree}"])); + assert_eq!( + fs::read(ws.join(".git/hooks/commit-msg")).unwrap(), + fs::read(wb.join(".git/hooks/commit-msg")).unwrap() + ); + assert_eq!(exclude, fs::read_to_string(wb.join(".git/info/exclude")).unwrap()); + } + + let again = sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-rust"]); + assert_eq!(again.code, 1); + assert_eq!(again.stderr, format!("delphi: workspace already exists: {}\n", ws.display())); + sb.assert_cleaned_up(); +} + +#[test] +fn refresh_merges_conflicts_and_finalizes() { + let sb = Sb::new("refresh"); + let (_, b) = new_pair(&sb); + let names: &[&str] = if b.is_some() { &["ws-rust", "ws-bash"] } else { &["ws-rust"] }; + let step = |args: &[&str]| -> (Out, Option) { + let r = sb.rust(&sb.dir, &[args, &["ws-rust"]].concat()); + let b = sb.bash(&sb.dir, &[args, &["ws-bash"]].concat()); + (r, norm(&b)) + }; + + // up to date + let (r, b) = step(&["workspace", "refresh"]); + assert_eq!((r.code, r.stderr.as_str()), (0, "ws-rust: up to date with origin/main\n")); + same(&r, &b); + + // upstream change merges cleanly + sb.upstream("printf -- '- Squash on merge.\\n' >> context/software/harness/instructions/base.md"); + let (r, b) = step(&["workspace", "refresh"]); + let c = sb.git(&sb.root(), &["rev-parse", "--short=7", "origin/main"]); + assert_eq!((r.code, r.stderr.clone()), (0, format!("ws-rust: merged compile of origin/main ({c})\n"))); + same(&r, &b); + assert!(fs::read_to_string(sb.ws("ws-rust").join("CLAUDE.md")).unwrap().contains("- Squash on merge.\n")); + + // conflicting edits: exit 2, then "merge in progress", then resolve and re-run + for n in names { + edit(&sb, n, "sed -i.bak 's/^- Use conventional commits.$/- Use conventional commits (ws)./' CLAUDE.md && rm CLAUDE.md.bak"); + } + sb.upstream("sed -i.bak 's/^- Use conventional commits.$/- Use gitmoji./' context/software/harness/instructions/base.md && rm context/software/harness/instructions/base.md.bak"); + let (r, b) = step(&["workspace", "refresh"]); + assert_eq!(r.code, 2, "{r:#?}"); + assert_eq!( + r.stderr, + "conflicts in ws-rust:\n CLAUDE.md\nresolve them, commit, then re-run: delphi workspace refresh ws-rust\n" + ); + same(&r, &b); + let (r, b) = step(&["workspace", "refresh"]); + assert_eq!(r.code, 1); + assert!(r.stderr.contains("merge in progress in"), "{}", r.stderr); + same(&r, &b); + for n in names { + sb.sh(&sb.ws(n), "git checkout --ours CLAUDE.md && git add CLAUDE.md && git commit -q --no-edit"); + } + // the re-run finalizes the merge, then finds the compile current + let (r, b) = step(&["workspace", "refresh"]); + let c = sb.git(&sb.root(), &["rev-parse", "--short=7", "origin/main"]); + let want = format!("ws-rust: merged compile of origin/main ({c})\nws-rust: up to date with origin/main\n"); + assert_eq!((r.code, r.stderr.clone()), (0, want)); + same(&r, &b); + let ws = sb.ws("ws-rust"); + assert_eq!(sb.git(&ws, &["rev-parse", "generated"]), sb.git(&ws, &["rev-parse", "generated-merged^{commit}"])); + let (r, b) = step(&["workspace", "refresh"]); + assert_eq!((r.code, r.stderr.as_str()), (0, "ws-rust: up to date with origin/main\n")); + same(&r, &b); + + // status agrees with bash (both workspaces listed) + let r = sb.rust(&sb.dir, &["ws", "status"]); + assert!(r.stdout.contains("ws-rust argos-dev main no unproposed 0d no delphi ws propose ws-rust\n"), "{}", r.stdout); + same(&r, &sb.bash(&sb.dir, &["ws", "status"])); + sb.assert_cleaned_up(); +} + +#[test] +fn propose_dry_run_plans_every_kind_of_edit() { + let sb = Sb::new("dry"); + sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-rust"]); + edit(&sb, "ws-rust", &format!( + "{EDITS}\ngit rm -q {BLOCKS}/testing.md\n\ + sed -i.bak 's/^description: Triage a failing Argos test./description: Triage failing tests./' .claude/skills/triage/SKILL.md && rm .claude/skills/triage/SKILL.md.bak" + )); + let ws = sb.ws("ws-rust"); + let r = sb.rust(&ws, &["ws", "propose", "--dry-run"]); + let a = "software/application-software/argos"; + let expect = format!( + " new .claude/skills/lint/SKILL.md -> {a}/harness/skills/lint/SKILL.md + new .claude/skills/run-tests/notes.md -> {a}/harness/skills/run-tests/notes.md + key .claude/skills/triage/SKILL.md -> {a}/harness/skills/triage.skill + patch CLAUDE.md -> {a}/harness/instructions/argos.md + fragment CLAUDE.md -> {a}/harness/instructions/deploying.md + new context/{a}/blocks/new.md -> {a}/blocks/new.md + patch context/{a}/blocks/overview.md -> {a}/blocks/overview.md + new docs/guide.md -> {a}/docs/guide.md +Unresolved: +#### `context/{a}/blocks/testing.md`: deleted, but still compiled by .delphi/manifest.yml (drop blocks by editing the manifest) + +```diff +@@ -1,4 +0,0 @@ +-# Testing +- +-Run `npm test` in angular-client. +-Run `cargo test` in the server. +``` + +" + ); + assert_eq!((r.code, r.stdout.as_str(), r.stderr.as_str()), (0, "", expect.as_str())); + same(&r, &sb.bash(&ws, &["ws", "propose", "--dry-run"])); + + // manifest edit + deletions whose source was dropped -> manifest + noop rows; unresolved edits + sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-two"]); + edit( + &sb, + "ws-two", + &format!( + "sed -i.bak '/run-tests$/d' .delphi/manifest.yml && rm .delphi/manifest.yml.bak\n\ + git rm -q -r .claude/skills/run-tests\n\ + printf 'x\\n' >> .delphi/lock.tsv\n\ + printf 'stray\\n' > notes.txt\n\ + printf '\\0\\1' > {BLOCKS}/blob.bin\n\ + sed -i.bak '1s/.*//' CLAUDE.md && rm CLAUDE.md.bak\n\ + printf 'Line zero.\\n' | cat - {BLOCKS}/overview.md > o && mv o {BLOCKS}/overview.md" + ), + ); + let ws2 = sb.ws("ws-two"); + let r = sb.rust(&ws2, &["ws", "propose", "--dry-run"]); + assert_eq!(r.code, 0); + for want in [ + format!(" manifest .delphi/manifest.yml -> {LAYOUT}/manifest.yml\n"), + " noop .claude/skills/run-tests/SKILL.md -> deleted; its source was dropped from .delphi/manifest.yml\n" + .into(), + " noop .claude/skills/run-tests/scripts/run.sh -> deleted; its source was dropped".into(), + format!(" patch context/{a}/blocks/overview.md -> {a}/blocks/overview.md\n"), + "#### `CLAUDE.md` (line 1): change spans segments or touches generated/separator lines".into(), + format!("#### `context/{a}/blocks/blob.bin`: binary file"), + "#### `notes.txt`: new file: must be under context//blocks/, docs/, or .claude/skills//".into(), + ] { + assert!(r.stderr.contains(&want), "missing {want:?} in:\n{}", r.stderr); + } + same(&r, &sb.bash(&ws2, &["ws", "propose", "--dry-run"])); + sb.assert_cleaned_up(); +} + +#[test] +fn propose_pushes_then_merge_and_refresh_is_clean() { + let sb = Sb::new("propose"); + let (_, b) = new_pair(&sb); + edit(&sb, "ws-rust", EDITS); + if b.is_some() { + edit(&sb, "ws-bash", EDITS); + } + let r = sb.rust(&sb.dir, &["ws", "propose", "ws-rust", "--yes"]); + assert_eq!(r.code, 0, "{r:#?}"); + assert!(r.stderr.starts_with("ws-rust: up to date with origin/main\n---- delphi: changes from workspace ws-rust (argos-dev)\n## Routed changes\n")); + assert!(r.stderr.contains("\n## Unresolved\n\nNone.\n\n| Harness | Model | Effort |\n|---|---|---|\n| sandbox | sandbox-model | low |\n----\n"), "{}", r.stderr); + assert_eq!(r.stdout, "https://github.invalid/pr/1\n"); + let root = sb.root(); + let br = "origin/delphi/propose/tester/ws-rust"; + let c = sb.git(&root, &["rev-parse", "--short=7", "origin/main"]); + let msg = sb.git(&root, &["log", "-1", "--format=%B", br]); + assert_eq!( + msg, + format!( + "delphi: add new files from workspace ws-rust\n\nDelphi-Harness: sandbox\nDelphi-Model: sandbox-model\nDelphi-Effort: low\n\ + Delphi-Layout: {LAYOUT}\nDelphi-Workspace: ws-rust\nDelphi-Base: {c}" + ) + ); + let subjects = sb.git(&root, &["log", "--format=%s", &format!("origin/main..{br}")]); + assert_eq!( + subjects, + "delphi: add new files from workspace ws-rust\ndelphi: route block edits from workspace ws-rust" + ); + let a = "context/software/application-software/argos"; + let frag = sb.git(&root, &["show", &format!("{br}:{a}/harness/instructions/deploying.md")]); + assert_eq!(frag, "## Deploying\n\nUse the pipeline."); + let manifest = sb.git(&root, &["show", &format!("{br}:{a}/layouts/argos-dev/manifest.yml")]); + assert!(manifest.contains(" - software/application-software/argos/harness/instructions/argos.md\n - software/application-software/argos/harness/instructions/deploying.md\n")); + assert!(manifest.contains(" - software/application-software/argos/harness/skills/lint\n")); + assert!(manifest.contains("docs:\n - software/application-software/argos/docs/guide.md")); + let gh = sb.gh_log(); + assert!(gh.contains("gh pr create --head delphi/propose/tester/ws-rust --base main --title delphi: changes from workspace ws-rust (argos-dev) --body ## Routed changes")); + + if b.is_some() { + let bo = sb.bash(&sb.dir, &["ws", "propose", "ws-bash", "--yes"]).unwrap(); + assert_eq!(bo.code, 0, "{bo:#?}"); + let bo = bo.norm("ws-bash", "ws-rust"); + assert_eq!(r.stderr, bo.stderr); + assert_eq!(bo.stdout, "https://github.invalid/pr/2\n"); + let bb = "origin/delphi/propose/tester/ws-bash"; + assert_eq!( + sb.git(&root, &["rev-parse", &format!("{br}^{{tree}}")]), + sb.git(&root, &["rev-parse", &format!("{bb}^{{tree}}")]) + ); + let bmsgs = sb.git(&root, &["log", "--format=%B", &format!("origin/main..{bb}")]).replace("ws-bash", "ws-rust"); + assert_eq!(sb.git(&root, &["log", "--format=%B", &format!("origin/main..{br}")]), bmsgs); + let log = sb.gh_log().replace("ws-bash", "ws-rust"); + let (x, y) = log.split_at(log.len() / 2); + assert_eq!(x, y, "gh calls differ"); + let meta = |w: &str| { + let m = fs::read_to_string(sb.ws(w).join(".git/delphi/meta")).unwrap(); + m.lines().find(|l| l.starts_with("last_proposed_hash=")).unwrap().to_string() + }; + assert_eq!(meta("ws-rust"), meta("ws-bash")); + } + + let r = sb.rust(&sb.dir, &["ws", "status"]); + assert!( + r.stdout.contains( + "ws-rust argos-dev main no proposed - no delphi ws open ws-rust\n" + ), + "{}", + r.stdout + ); + same(&r, &sb.bash(&sb.dir, &["ws", "status"])); + + // proposing again (not merged yet) force-pushes the same branch and updates the PR + let r = sb.rust(&sb.dir, &["ws", "propose", "ws-rust", "--yes"]); + assert_eq!(r.code, 0, "{r:#?}"); + assert!(r.stderr.ends_with("----\nUpdated PR #1\n"), "{}", r.stderr); + + // merge the PR, refresh: clean + sb.upstream("git merge -q --no-edit origin/delphi/propose/tester/ws-rust"); + let r = sb.rust(&sb.dir, &["ws", "refresh", "ws-rust"]); + assert_eq!(r.code, 0, "{r:#?}"); + let r = sb.rust(&sb.dir, &["ws", "status"]); + assert!( + r.stdout.contains( + "ws-rust argos-dev main no clean - no delphi ws open ws-rust\n" + ), + "{}", + r.stdout + ); + same(&r, &sb.bash(&sb.dir, &["ws", "status"])); + let r = sb.rust(&sb.dir, &["ws", "status", "--offline"]); + assert_eq!(r.code, 0); + + // nothing left to propose: says so and warns about the still-open PR + let r = sb.rust(&sb.dir, &["ws", "propose", "ws-rust", "--yes"]); + assert_eq!(r.code, 0); + assert_eq!( + r.stderr, + "ws-rust: up to date with origin/main\nnothing to propose\ndelphi: warning: PR #1 still contains earlier changes; close it with: gh pr close 1\n" + ); + sb.assert_cleaned_up(); +} + +#[test] +fn layout_new_from_file_and_list() { + let sb = Sb::new("layout"); + let r = sb.rust(&sb.dir, &["layout", "list"]); + assert_eq!((r.code, r.stdout.as_str()), (0, "argos-dev\tsoftware/application-software/argos\tclaude-code\n")); + same(&r, &sb.bash(&sb.dir, &["layout", "list"])); + + let manifest = |name: &str| { + format!( + "name: {name}\nharness: claude-code\ninstructions:\n - software/harness/instructions/base.md\nblocks:\n - software/application-software/argos/blocks/overview.md\n" + ) + }; + fs::write(sb.dir.join("rust.yml"), manifest("rust-dev")).unwrap(); + fs::write(sb.dir.join("bash.yml"), manifest("bash-dev")).unwrap(); + let scope = "software/application-software/argos/"; + let r = sb.rust(&sb.dir, &["layout", "new", scope, "rust-dev", "--from", "rust.yml", "--yes"]); + assert_eq!(r.code, 0, "{r:#?}"); + assert_eq!(r.stdout, "https://github.invalid/pr/1\ndelphi/layout/rust-dev\n"); + assert!(r.stderr.starts_with("---- delphi: add layout rust-dev\nAdds layout `rust-dev` in scope `software/application-software/argos`:\n\n```yaml\nname: rust-dev\n"), "{}", r.stderr); + let root = sb.root(); + let f = sb.git( + &root, + &[ + "show", + "origin/delphi/layout/rust-dev:context/software/application-software/argos/layouts/rust-dev/manifest.yml", + ], + ); + assert_eq!(format!("{f}\n"), manifest("rust-dev")); + let msg = sb.git(&root, &["log", "-1", "--format=%B", "origin/delphi/layout/rust-dev"]); + assert_eq!( + msg, + "delphi: add layout rust-dev\n\nDelphi-Harness: sandbox\nDelphi-Model: sandbox-model\nDelphi-Effort: low" + ); + if let Some(b) = sb.bash(&sb.dir, &["layout", "new", scope, "bash-dev", "--from", "bash.yml", "--yes"]) { + let b = b.norm("bash-dev", "rust-dev").norm("pr/2", "pr/1"); + same(&r, &Some(b)); + let t = |n: &str| { + sb.git(&root, &["log", "-1", "--format=%s%n%b", &format!("origin/delphi/layout/{n}")]).replace(n, "X") + }; + assert_eq!(t("rust-dev"), t("bash-dev")); + } + + // errors + for (args, err) in [ + ( + &["layout", "new", scope, "other", "--from", "rust.yml", "--yes"][..], + "delphi: manifest name must be 'other'\n", + ), + ( + &["layout", "new", scope, "argos-dev", "--from", "rust.yml", "--yes"], + "delphi: layout name already used: argos-dev\n", + ), + ( + &["layout", "new", "software/nope", "x", "--from", "rust.yml", "--yes"], + "delphi: not a scope on origin/main: software/nope\n", + ), + (&["layout", "new", scope, "x", "--from", "missing.yml"], "delphi: no such file: missing.yml\n"), + ( + &["layout", "new", scope, "x"], + "delphi: non-interactive session: pass a drafted manifest with --from \n", + ), + ] { + let r = sb.rust(&sb.dir, args); + assert_eq!((r.code, r.stderr.as_str()), (1, err), "{args:?}"); + same(&r, &sb.bash(&sb.dir, args)); + } + fs::write(sb.dir.join("x.yml"), manifest("x")).unwrap(); + let r = sb.rust(&sb.dir, &["layout", "new", scope, "x", "--from", "x.yml"]); + assert_eq!(r.stderr, "delphi: non-interactive session: re-run with --yes (or DELPHI_YES=1)\n"); + sb.assert_cleaned_up(); +} + +#[test] +fn block_mv_matches_bash_and_workspaces_follow() { + let sb = Sb::new("mv"); + sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-rust"]); + let root = sb.root(); + let old = "software/application-software/argos/blocks/overview.md"; + let new = "software/application-software/argos/blocks/intro.md"; + let day = sb.sh(&sb.dir, "date +%Y%m%d").trim().to_string(); + let br = format!("delphi/mv/overview.md-{day}"); + let args = ["block", "mv", old, new, "--yes"]; + + let bash = sb.bash(&sb.dir, &args); + let mut bash_tree = None; + if let Some(b) = &bash { + assert_eq!(b.code, 0, "{b:#?}"); + bash_tree = Some(sb.git(&root, &["rev-parse", &format!("origin/{br}^{{tree}}")])); + sb.git(&root, &["push", "-q", "origin", "--delete", &br]); + sb.git(&root, &["branch", "-q", "-D", &br]); + fs::write(sb.dir.join("gh.prs"), "").unwrap(); + } + let r = sb.rust(&sb.dir, &args); + assert_eq!(r.code, 0, "{r:#?}"); + assert_eq!(r.stdout, format!("https://github.invalid/pr/1\n{br}\n")); + same(&r, &bash); + let tree = sb.git(&root, &["rev-parse", &format!("origin/{br}^{{tree}}")]); + if let Some(t) = bash_tree { + assert_eq!(tree, t); + } + let moves = sb.git(&root, &["show", &format!("origin/{br}:moves.tsv")]); + let date = sb.sh(&sb.dir, "date +%Y-%m-%d").trim().to_string(); + assert_eq!(moves, format!("# old\tnew\tdate\n{old}\t{new}\t{date}")); + let scope = sb.git(&root, &["show", &format!("origin/{br}:context/software/application-software/argos/scope.yml")]); + assert!(scope.contains(&format!(" - {new}\n")), "{scope}"); + + let r = sb.rust(&sb.dir, &["block", "mv", "software/nope/blocks/a.md", "software/nope/blocks/b.md", "--yes"]); + assert_eq!(r.code, 1); + assert!(r.stderr.ends_with("delphi: no such block on origin/main: software/nope/blocks/a.md\n")); + + // after the move lands, the workspace follows on refresh + sb.upstream(&format!("git merge -q --no-edit origin/{br}")); + let r = sb.rust(&sb.dir, &["ws", "refresh", "ws-rust"]); + assert_eq!(r.code, 0, "{r:#?}"); + let ws = sb.ws("ws-rust"); + assert!(ws.join(format!("context/{new}")).is_file()); + assert!(!ws.join(format!("context/{old}")).exists()); + let meta = fs::read_to_string(ws.join(".git/delphi/meta")).unwrap(); + assert!(meta.contains(&format!("compile_commit={}\n", sb.git(&root, &["rev-parse", "origin/main"])))); + sb.assert_cleaned_up(); +} + +#[test] +fn check_passes_then_reports_every_failure_like_bash() { + let sb = Sb::new("check"); + let r = sb.rust(&sb.dir, &["check"]); + assert_eq!((r.code, r.stderr.as_str()), (0, "check: ok\n")); + same(&r, &sb.bash(&sb.dir, &["check"])); + + let c = sb.root().join("context"); + let a = c.join("software/application-software/argos"); + fs::write(c.join("software/notes.txt"), "stray\n").unwrap(); + fs::write(a.join("blocks/empty.md"), "").unwrap(); + fs::write(a.join("blocks/nonl.md"), "no newline").unwrap(); + fs::write(a.join("blocks/CLAUDE.md"), "x\n").unwrap(); + fs::create_dir_all(a.join("layouts/bad")).unwrap(); + fs::write( + a.join("layouts/bad/manifest.yml"), + "name: other\nharness: claude-code\ninstructions:\n - software/application-software/argos/blocks/overview.md\nblocks:\n - software/missing/blocks/*\nextra: 1\n", + ) + .unwrap(); + fs::write(a.join("harness/skills/bad.skill"), "name: bad\nwhat: 1\nbody:\n - software/nope.md\n").unwrap(); + fs::write(c.join("software/harness/tabbed.yml"), "a:\tb\n").unwrap(); + fs::write(c.join("software/harness/mcp/broken.json"), "\"x\": {\n").unwrap(); + fs::create_dir_all(c.join("orphan")).unwrap(); + fs::write(sb.root().join("moves.tsv"), "# old\tnew\tdate\na\tb\n").unwrap(); + let r = sb.rust(&sb.dir, &["check"]); + assert_eq!(r.code, 1); + for want in [ + "check: context/orphan: scope directory has no scope.yml\n", + "check: context/software/notes.txt: stray file", + "check: context/software/application-software/argos/blocks/empty.md: empty file\n", + "check: context/software/application-software/argos/blocks/nonl.md: missing trailing newline\n", + "check: context/software/application-software/argos/blocks/CLAUDE.md: harness instruction file names are not allowed under context/\n", + "/context/software/harness/tabbed.yml:1: tabs are not allowed\n", + "check: context/software/application-software/argos/harness/skills/bad.skill: missing description\n", + "check: context/software/application-software/argos/harness/skills/bad.skill: unknown key 'what'\n", + "check: context/software/application-software/argos/harness/skills/bad.skill: body: 'software/nope.md' is not under blocks/*\n", + "check: context/software/application-software/argos/harness/skills/bad.skill: body: missing 'software/nope.md'\n", + "check: context/software/application-software/argos/layouts/bad/manifest.yml: name 'other' must equal its directory 'bad'\n", + "check: context/software/application-software/argos/layouts/bad/manifest.yml: compile: no such directory context/software/missing/blocks\n", + "check: context/software/application-software/argos/layouts/bad/manifest.yml: unknown key 'extra'\n", + "check: context/software/application-software/argos/layouts/bad/manifest.yml: instructions: 'software/application-software/argos/blocks/overview.md' is not under harness/instructions/*\n", + "check: moves.tsv:2: expected oldnewdate\n", + "check: context/software/harness/mcp/broken.json: not a valid mcpServers member\n", + ] { + assert!(r.stderr.contains(want), "missing {want:?} in:\n{}", r.stderr); + } + same(&r, &sb.bash(&sb.dir, &["check"])); + sb.assert_cleaned_up(); +} + +#[test] +fn repo_root_discovery_and_setup() { + let sb = Sb::new("root"); + sb.rust(&sb.dir, &["workspace", "new", "argos-dev", "--as", "ws-rust"]); + let ws = sb.ws("ws-rust"); + + // walk up from inside the checkout + let r = sb.rust_noroot(&sb.root().join("context/software"), &["ws", "status", "--offline"]); + assert_eq!(r.code, 0, "{r:#?}"); + assert!(r.stdout.contains("ws-rust ")); + + // outside any checkout, nothing recorded + let r = sb.rust_noroot(&ws, &["ws", "status"]); + assert_eq!(r.code, 1); + assert!(r.stderr.starts_with("delphi: cannot find the Delphi repo"), "{}", r.stderr); + + // setup records the checkout; then it works from the workspace + let r = sb.rust_noroot(&sb.root(), &["setup"]); + assert_eq!(r.code, 0, "{r:#?}"); + let rec = fs::read_to_string(sb.dir.join("home/.config/delphi/root")).unwrap(); + assert_eq!(rec, format!("{}\n", sb.root().display())); + let r = sb.rust_noroot(&ws, &["ws", "propose", "--dry-run"]); + assert_eq!((r.code, r.stderr.as_str()), (0, "")); + let r = sb.rust_noroot(&ws, &["ws", "status", "--offline"]); + assert_eq!(r.code, 0); + + let r = sb.rust_noroot(&sb.dir, &["setup", "/nonexistent"]); + assert_eq!(r.code, 1); + sb.assert_cleaned_up(); +} + +#[test] +fn open_shell_gets_provenance_env_like_bash() { + let sb = Sb::new("open"); + let (_, b) = new_pair(&sb); + let shell = sb.dir.join("bin/fake-shell"); + fs::write(&shell, "#!/bin/sh\necho \"$(pwd) h=$DELPHI_HARNESS m=$DELPHI_MODEL e=$DELPHI_EFFORT\"\n").unwrap(); + fs::set_permissions(&shell, fs::Permissions::from_mode(0o755)).unwrap(); + let run = |ws: &str, bash: bool| { + let args = ["ws", "open", ws, "--shell", "--model", "m1"]; + let mut c = std::process::Command::new(if bash { "/bin/bash" } else { env!("CARGO_BIN_EXE_delphi") }); + if bash { + c.arg(sb.root().join("bin/delphi")); + } + c.args(args); + sb.env(&mut c); + c.env("SHELL", &shell).env("DELPHI_ROOT", sb.root()).current_dir(&sb.dir); + let o = c.output().unwrap(); + ( + o.status.code(), + String::from_utf8_lossy(&o.stdout).into_owned(), + String::from_utf8_lossy(&o.stderr).into_owned(), + ) + }; + let (code, out, err) = run("ws-rust", false); + assert_eq!((code, err.as_str()), (Some(0), "")); + assert!(out.starts_with(&format!("{} h=claude-code", sb.ws("ws-rust").display())), "{out}"); + assert!(out.ends_with(" m=m1 e=low\n"), "{out}"); + if b.is_some() { + let (bc, bout, berr) = run("ws-bash", true); + assert_eq!((code, out, err), (bc, bout.replace("ws-bash", "ws-rust"), berr)); + } + sb.assert_cleaned_up(); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..430a8b2 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,286 @@ +//! Test sandbox, like dev/sandbox.sh: a temp dir with a bare Delphi origin, a Delphi clone with +//! sample content, a bare code repo, a stub `gh` on PATH (logs to gh.log, remembers PRs in +//! gh.prs), and a workspace root. Runs the Rust binary and, when available, the bash CLI +//! (`/bin/bash /Delphi/bin/delphi`) against the same sandbox. +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BUILD: &str = r#" +set -euo pipefail +SB=$1 here=$2 +git init -q --bare -b main "$SB/origin.git" +git clone -q "$SB/origin.git" "$SB/Delphi" 2>/dev/null +if [ -f "$here/lib/core.sh" ]; then cp -R "$here/bin" "$here/lib" "$SB/Delphi/"; fi +cp "$here/delphi.conf" "$here/moves.tsv" "$SB/Delphi/" + +C="$SB/Delphi/context" +A="$C/software/application-software/argos" +mkdir -p "$C/software/harness/instructions" "$C/software/harness/mcp" \ + "$A/blocks" "$A/harness/instructions" "$A/harness/skills/run-tests" "$A/layouts/argos-dev" + +printf 'name: NER\n' > "$C/scope.yml" +printf 'name: Software\nrecommend:\n - software/harness/instructions/base.md\n' > "$C/software/scope.yml" +printf 'name: Application Software\n' > "$C/software/application-software/scope.yml" +cat > "$A/scope.yml" <<'EOF' +name: Argos +recommend: + - software/application-software/argos/blocks/overview.md + - software/application-software/argos/harness/skills/run-tests +EOF +printf '# Software conventions\n\n- Use conventional commits.\n- Open PRs against develop.\n' \ + > "$C/software/harness/instructions/base.md" +printf '"github": {\n "command": "gh-mcp",\n "args": []\n}\n' > "$C/software/harness/mcp/github.json" +printf '# Argos\n\nArgos is the telemetry dashboard.\nIt has an Angular client and a Rust server.\n' \ + > "$A/harness/instructions/argos.md" +printf '# Argos overview\n\nLine one.\nLine two.\nLine three.\n' > "$A/blocks/overview.md" +printf '# Testing\n\nRun `npm test` in angular-client.\nRun `cargo test` in the server.\n' > "$A/blocks/testing.md" +printf -- '---\nname: run-tests\ndescription: Run the Argos test suites.\n---\n\nRun both suites and summarize failures.\n' \ + > "$A/harness/skills/run-tests/SKILL.md" +mkdir -p "$A/harness/skills/run-tests/scripts" +printf '#!/bin/sh\necho running tests\n' > "$A/harness/skills/run-tests/scripts/run.sh" +chmod +x "$A/harness/skills/run-tests/scripts/run.sh" +cat > "$A/harness/skills/triage.skill" <<'EOF' +name: triage +description: Triage a failing Argos test. +body: + - software/application-software/argos/blocks/testing.md +EOF + +git init -q -b main "$SB/argos-src" +printf '# Argos code\n' > "$SB/argos-src/README.md" +git -C "$SB/argos-src" add -A && git -C "$SB/argos-src" commit -qm init +git clone -q --bare "$SB/argos-src" "$SB/argos.git" + +cat > "$A/layouts/argos-dev/manifest.yml" </dev/null +git -C "$SB/Delphi" branch -q -u origin/main 2>/dev/null || true + +mkdir -p "$SB/bin"; : > "$SB/gh.prs" +{ printf '#!/bin/sh\nSB=%q\n' "$SB"; cat <<'EOF'; } > "$SB/bin/gh" +# stub gh for the Delphi sandbox: never contacts GitHub +echo "gh $*" >> "$SB/gh.log" +n=$(awk -v b="$4" '$1 == b { print $2 }' "$SB/gh.prs") # args: pr list|create --head … +case "$1 $2" in + "api user") echo sandbox-user ;; + "pr list") [ -z "$n" ] || { [ "$8" = author ] && echo sandbox-user || echo "$n"; } ;; + "pr create") n=$(($(wc -l < "$SB/gh.prs") + 1)); echo "$4 $n" >> "$SB/gh.prs"; echo "https://github.invalid/pr/$n" ;; +esac +exit 0 +EOF +chmod +x "$SB/bin/gh" +"#; + +pub const LAYOUT: &str = "software/application-software/argos/layouts/argos-dev"; +pub const BLOCKS: &str = "context/software/application-software/argos/blocks"; + +#[derive(Debug, Clone)] +pub struct Out { + pub code: i32, + pub stdout: String, + pub stderr: String, +} + +impl Out { + /// Replace a workspace (or other) name so bash and Rust runs on sibling workspaces compare. + pub fn norm(&self, from: &str, to: &str) -> Out { + Out { code: self.code, stdout: self.stdout.replace(from, to), stderr: self.stderr.replace(from, to) } + } +} + +pub struct Sb { + pub dir: PathBuf, +} + +static N: AtomicUsize = AtomicUsize::new(0); + +fn repo() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +impl Sb { + pub fn new(tag: &str) -> Sb { + // outside this repo, so walking up from a workspace never finds the real checkout + let base = std::env::temp_dir(); + let dir = base.join(format!("sb-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst))); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("home")).unwrap(); + fs::create_dir_all(dir.join("tmp")).unwrap(); + let dir = fs::canonicalize(dir).unwrap(); + fs::write( + dir.join("home/.gitconfig"), + "[user]\n\tname = Sandbox\n\temail = sb@example.invalid\n[init]\n\tdefaultBranch = main\n[advice]\n\tdetachedHead = false\n", + ) + .unwrap(); + let sb = Sb { dir }; + let mut c = Command::new("bash"); + c.arg("-c").arg(BUILD).arg("build").arg(&sb.dir).arg(repo()); + sb.env(&mut c); + let o = c.output().unwrap(); + assert!(o.status.success(), "sandbox build failed: {}", String::from_utf8_lossy(&o.stderr)); + sb + } + + pub fn root(&self) -> PathBuf { + self.dir.join("Delphi") + } + + pub fn ws(&self, name: &str) -> PathBuf { + self.dir.join("Delphi-workspaces").join(name) + } + + pub fn env(&self, c: &mut Command) { + for k in [ + "DELPHI_ROOT", + "DELPHI_WORKSPACE_ROOT", + "DELPHI_YES", + "DELPHI_OFFLINE", + "ANTHROPIC_MODEL", + "CLAUDE_CODE_EFFORT_LEVEL", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_CONFIG_GLOBAL", + "XDG_CONFIG_HOME", + "PWD", + ] { + c.env_remove(k); + } + let path = format!("{}:{}", self.dir.join("bin").display(), std::env::var("PATH").unwrap_or_default()); + c.env("PATH", path) + .env("HOME", self.dir.join("home")) + .env("TMPDIR", self.dir.join("tmp")) + .env("USER", "tester") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("DELPHI_MODEL", "sandbox-model") + .env("DELPHI_EFFORT", "low") + .env("DELPHI_HARNESS", "sandbox") + .stdin(Stdio::null()); + } + + fn run(&self, mut c: Command, cwd: &Path) -> Out { + self.env(&mut c); + c.current_dir(cwd); + let o = c.output().unwrap(); + Out { + code: o.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&o.stdout).into_owned(), + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + } + } + + /// The Rust CLI with DELPHI_ROOT set to the sandbox's Delphi. + pub fn rust(&self, cwd: &Path, args: &[&str]) -> Out { + let mut c = self.rust_cmd(args); + self.env(&mut c); + c.env("DELPHI_ROOT", self.root()); + c.current_dir(cwd); + let o = c.output().unwrap(); + Out { + code: o.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&o.stdout).into_owned(), + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + } + } + + /// The Rust CLI without DELPHI_ROOT (root discovery). + pub fn rust_noroot(&self, cwd: &Path, args: &[&str]) -> Out { + self.run(self.rust_cmd(args), cwd) + } + + fn rust_cmd(&self, args: &[&str]) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_delphi")); + c.args(args); + c + } + + pub fn has_bash(&self) -> bool { + Path::new("/bin/bash").exists() && self.root().join("lib/core.sh").exists() + } + + /// The bash CLI, or None when it (or /bin/bash) is unavailable. + pub fn bash(&self, cwd: &Path, args: &[&str]) -> Option { + if !self.has_bash() { + return None; + } + let mut c = Command::new("/bin/bash"); + c.arg(self.root().join("bin/delphi")).args(args); + Some(self.run(c, cwd)) + } + + /// Run a shell script in a directory; must succeed. Returns stdout. + pub fn sh(&self, cwd: &Path, script: &str) -> String { + let mut c = Command::new("bash"); + c.arg("-c").arg(format!("set -euo pipefail\n{script}")); + let o = self.run(c, cwd); + assert_eq!(o.code, 0, "script failed: {script}\n{}", o.stderr); + o.stdout + } + + pub fn git(&self, cwd: &Path, args: &[&str]) -> String { + let mut c = Command::new("git"); + c.args(args); + let o = self.run(c, cwd); + assert_eq!(o.code, 0, "git {args:?} failed: {}", o.stderr); + o.stdout.trim_end().to_string() + } + + /// Commit a change to origin/main from a separate clone. + pub fn upstream(&self, script: &str) { + let up = self.dir.join("up"); + if !up.exists() { + self.git(&self.dir, &["clone", "-q", "origin.git", "up"]); + } + self.sh(&up, &format!("git pull -q --no-rebase origin main\n{script}\ngit add -A\ngit diff --cached --quiet || git commit -qm upstream\ngit push -q origin HEAD:main")); + } + + pub fn gh_log(&self) -> String { + fs::read_to_string(self.dir.join("gh.log")).unwrap_or_default() + } + + /// No temp dirs or Delphi worktrees left behind by the CLI. + pub fn assert_cleaned_up(&self) { + let left: Vec<_> = fs::read_dir(self.dir.join("tmp")).unwrap().flatten().map(|e| e.path()).collect(); + assert!(left.is_empty(), "temp dirs left behind: {left:?}"); + let wt = self.git(&self.root(), &["worktree", "list"]); + assert_eq!(wt.lines().count(), 1, "worktrees left behind:\n{wt}"); + } +} + +impl Drop for Sb { + fn drop(&mut self) { + if !std::thread::panicking() { + let _ = fs::remove_dir_all(&self.dir); + } + } +} + +/// Assert two runs are identical (exit code, stdout, stderr). +pub fn same(r: &Out, b: &Option) { + if let Some(b) = b { + assert_eq!(r.code, b.code, "exit codes differ\nrust: {r:#?}\nbash: {b:#?}"); + assert_eq!(r.stdout, b.stdout, "stdout differs"); + assert_eq!(r.stderr, b.stderr, "stderr differs"); + } +} From 457e9f6fc4dea6ac613081a160fe623ed194cd1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:59:16 +0000 Subject: [PATCH 04/17] Add v2 design (mapped workspaces) and align goals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaADA545v1Fw4im5hHFoTq --- docs/design.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/goals.md | 15 +++--- 2 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 docs/design.md diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..280e3b5 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,132 @@ +# Delphi — Design (v2: mapped workspaces) + +Supersedes the v1 spec (line-level compile and routing). Goals: `docs/goals.md`. + +## 1. Model in one paragraph + +Context lives once in Delphi under `context/`, organized by scope (org chart). A **layout** +lists which context files a workspace gets and where they go. A **workspace** is its own git repo +where every file sits at a normal harness location (`CLAUDE.md`, `.claude/skills/…`, `docs/…`). +Every compiled file is a **1:1 copy of one source file** (the one exception is the optional +assembled instruction file). Each file is either **synced** (linked to its source: refresh updates +it, propose sends edits back) or **copied** (copied in once, then yours). Nothing is synced unless +the layout says so. + +## 2. Layout + +`context//layouts//`: + +``` +manifest.yml +files/ # optional: the layout's own files, placed at the workspace root, synced + CLAUDE.md + .claude/skills/argos-notes/SKILL.md +``` + +```yaml +name: argos-dev # = directory name, unique +harness: claude-code # adapter: launch + provenance +instructions: # optional: assembled into the harness instruction file + - software/harness/instructions/base.md +sync: # linked to their source + - software/application-software/argos/harness/skills/run-local + - software/application-software/argos/blocks/pr-body.md -> .claude/skills/open-pr/pr-body.md +copy: # copied once, then yours + - software/harness/skills/new-worktree +repos: + argos: https://github.com/Northeastern-Electric-Racing/Argos.git +``` + +- Entry = ` [-> ]`. Source is a file or directory under `context/`; a directory maps + every file under it (recursive, 1:1, executable bit kept). +- Default dest (when `->` is omitted), by source path: + `…/harness/skills/[/…]` → `/[/…]`; `…/docs/` → `docs/`; + anything else → `context/`. +- `instructions` builds the harness instruction file (e.g. `CLAUDE.md`) from parts, blank line + between. It is **generated**: edits to it are not proposed (sync a part to edit it). A layout + uses either `instructions` or its own `files/`, not both. +- YAML subset as before (scalars, lists, one-level maps). + +## 3. Workspace + +Git repo at `/`. Refs as in v1: `generated` (compiles only), +tag `generated-merged` (last compile merged into `working`), `working` (the user's branch). +Bookkeeping in `.git/delphi/` (never in the working tree). + +**Compile** = synced files + layout `files/` + assembled instruction file + `.delphi/manifest.yml` +(editable copy of the layout manifest) + `.delphi/lock.tsv`: + +``` +# dest source kind +CLAUDE.md - assembled +.claude/skills/run-local/SKILL.md software/…/harness/skills/run-local/SKILL.md sync +CLAUDE.md software/…/layouts/argos-dev/files/CLAUDE.md layout (when not assembled) +.delphi/manifest.yml software/…/layouts/argos-dev/manifest.yml layout +``` + +Copied files are **not** part of the compile. They are committed to `working` directly +(`delphi: copy …`) when first listed, and recorded in `.git/delphi/copied` +(`destsourcedelphi-commit`). They are never overwritten or proposed. + +## 4. Commands + +| Command | Does | +|---|---| +| `workspace new [--as ws] [--ref b]` | compile at `origin/`, init repo, add copies, clone repos | +| `workspace open [ws] [--shell]` | warn if behind; launch the harness (or a shell) in it | +| `workspace refresh [ws] [--ref b]` | compile → commit on `generated` → `git merge` into `working` (exit 2 on conflict; re-run to finish); add newly listed copies; print which synced files changed upstream and who changed them | +| `workspace diff [ws] [--upstream]` | what you changed vs. what came from Delphi (below); `--upstream`: sources changed on `origin/` since your compile, incl. copies whose source moved on | +| `workspace propose [ws] [--dry-run]` | refresh, route (below), one PR per workspace (force-updated) | +| `workspace status` | one row per workspace: dirty, clean/proposed/unproposed, behind | +| `layout new --from ` / `layout list` | layout PR / list layouts | +| `block mv ` | move a source, log it in `moves.tsv`, rewrite manifests; workspaces follow on refresh | +| `check` | validate the repo (below) | +| `setup [dir]` | remember where the Delphi checkout is | + +`propose --dry-run` prints the same listing as `workspace diff`. + +## 5. What changed, and where it goes (routing) + +Pending diff = `generated-merged..HEAD` (committed changes only). Per changed file: + +| Change | Result | +|---|---| +| synced or layout file, modified (incl. binary, mode) | copy the file over its source | +| synced or layout file, deleted | no-op if its entry was removed from `.delphi/manifest.yml`, else unresolved | +| new file inside a synced **directory** | new file in that source directory | +| new `sync` entry in `.delphi/manifest.yml` whose source doesn't exist yet | source created from the workspace file(s) | +| `.delphi/manifest.yml` modified | replaces the layout manifest | +| assembled instruction file | unresolved ("generated; sync a part to edit it") | +| anything else (copies, new files elsewhere) | **local**: listed, never proposed | + +One source synced to several dests: identical edits route once; differing edits are unresolved. +Unresolved items are listed in the PR body; they never block it. + +**Shared awareness.** `diff`, `propose`, and `refresh` tag a synced file **shared** when other +layouts on `origin/main` also use its source (`shared: argos-dev, nero-dev`), so you know an edit +reaches other teams. Refresh lists synced files updated from Delphi with the last author and +commit subject. + +## 6. Unchanged from v1 + +PR path (temp worktree from `compile_commit`, provenance trailers, `--yes`, lease on the +propose branch, `gh` only for PRs), provenance resolution and the workspace commit hook, +`moves.tsv`, `delphi.conf`, harness adapters (names + launch + provenance), path safety +(`safe_path` on every path from config, flags, lock, manifests, `moves.tsv`), exit codes +(0 ok, 1 error, 2 refresh conflict), offline tolerance. + +## 7. Removed + +Line-level lock and hunk routing, instruction-fragment routing, built `.skill` specs, +MCP fragment assembly (MCP/settings are ordinary synced or copied files), manifest keys +`blocks`/`docs`/`skills`/`mcp`/`settings`, the interactive `layout new` picker (use `--from` or the +`delphi-new-layout` skill), and the bash CLI. v1 workspaces must be recreated +(`delphi` detects the old lock and says so). + +## 8. check + +Scopes have `scope.yml`; every `.yml` parses; manifests: `name` = directory and unique, adapter +exists, only known keys, entries well-formed, sources exist, dests safe and unique (no dest inside +another entry's dest), not both `instructions` and a `files/` instruction file; no file named after +an instruction file under `context/` except inside `layouts/*/files/`; `moves.tsv` rows +well-formed; no symlinks, no empty files. diff --git a/docs/goals.md b/docs/goals.md index 37ca4e7..7e0cb38 100644 --- a/docs/goals.md +++ b/docs/goals.md @@ -12,8 +12,8 @@ and send improvements back — with or without an AI harness. ## Goals **G1. Compile.** A layout (a manifest picking context + a harness) compiles into a workspace's -files. Same Delphi commit + same layout = identical output. Every output line is traceable to its -source file (the lock). +files at normal harness locations. Same Delphi commit + same layout = identical output. Every +compiled file is traceable to its source file (the lock). **G2. Workspaces are independent.** Each workspace is its own git repo outside Delphi, where the user works freely. Many workspaces can come from one layout. Code repos listed in the layout are @@ -27,8 +27,12 @@ Renamed/moved blocks are followed. and whether those changes have been proposed yet. **G5. Propose.** Send the workspace's changes back as one pull request per workspace. Each edit -lands in the source file it came from; new files and new instruction sections become new blocks. -Anything Delphi can't place is listed in the PR, never guessed. +to a synced file lands in the source file it came from; new shared files are declared in the +manifest. Copied and other local files stay local. Anything Delphi can't place is listed in the +PR, never guessed. + +**G5a. Sync is opt-in.** A layout chooses per file: synced (linked to its source) or copied +(yours after the first copy). Editing a file other layouts also sync is flagged as shared. **G6. Provenance.** Every change written to Delphi records which harness, model, and effort made it. @@ -52,5 +56,4 @@ adapter: file names plus how to launch it. - Should "proposed" come from GitHub's PR state instead of a local hash? - Should a rejected change stop reappearing without reverting it? -- Keep built `.skill` specs, or only native skill folders? -- Keep the strict YAML subset, or accept real YAML? +- Resolved: built `.skill` specs dropped (native skill folders only); strict YAML subset kept. From c88abc96d60b472ff5490b8a09f4791a7e8a1c8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 01:15:45 +0000 Subject: [PATCH 05/17] Implement v2 mapped workspaces in the Rust CLI Every compiled file is a 1:1 copy of one source; layouts choose per entry between sync (linked: refresh updates, propose sends back) and copy (yours after the first copy), plus an optional assembled instruction file and the layout's own files/. Adds 'workspace diff [--upstream]' and shared-source tags. Removes line-level routing, .skill specs, MCP assembly, the interactive layout picker, and the bash CLI. Migrates argos-dev. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaADA545v1Fw4im5hHFoTq --- .claude/skills/delphi-new-layout/SKILL.md | 22 +- .claude/skills/delphi-propose/SKILL.md | 42 +- CLAUDE.md | 50 +- README.md | 57 +- bin/delphi | 48 - context/docs/delphi-design.md | 473 --------- context/harness/instructions/workspace.md | 9 +- .../argos/harness/skills/open-pr.skill | 5 - .../skills/open-pr/SKILL.md} | 7 + .../argos/harness/skills/update-pr.skill | 5 - .../skills/update-pr/SKILL.md} | 7 + .../argos/layouts/argos-dev/manifest.yml | 9 +- .../application-software/argos/scope.yml | 2 +- dev/sandbox.sh | 105 -- docs/design.md | 45 +- docs/goals.md | 2 +- lib/block.sh | 39 - lib/check.sh | 148 --- lib/compile.sh | 182 ---- lib/core.sh | 194 ---- lib/harness/claude-code.sh | 22 - lib/layout.sh | 108 -- lib/parse.sh | 58 -- lib/pr.sh | 66 -- lib/provenance.sh | 49 - lib/route.awk | 116 --- lib/route.sh | 206 ---- lib/setup.sh | 12 - lib/workspace.sh | 337 ------ src/block.rs | 17 +- src/check.rs | 266 ++--- src/compile.rs | 332 +++--- src/core.rs | 102 +- src/harness.rs | 8 +- src/layout.rs | 147 +-- src/main.rs | 9 +- src/parse.rs | 20 +- src/pr.rs | 2 +- src/provenance.rs | 2 +- src/route.rs | 872 +++------------- src/setup.rs | 3 +- src/workspace.rs | 394 +++++-- tests/cli.rs | 967 +++++++++--------- tests/common/mod.rs | 178 ++-- 44 files changed, 1509 insertions(+), 4235 deletions(-) delete mode 100755 bin/delphi delete mode 100644 context/docs/delphi-design.md delete mode 100644 context/software/application-software/argos/harness/skills/open-pr.skill rename context/software/application-software/argos/{blocks/open-pr.md => harness/skills/open-pr/SKILL.md} (63%) delete mode 100644 context/software/application-software/argos/harness/skills/update-pr.skill rename context/software/application-software/argos/{blocks/update-pr.md => harness/skills/update-pr/SKILL.md} (56%) delete mode 100755 dev/sandbox.sh delete mode 100644 lib/block.sh delete mode 100644 lib/check.sh delete mode 100644 lib/compile.sh delete mode 100644 lib/core.sh delete mode 100644 lib/harness/claude-code.sh delete mode 100644 lib/layout.sh delete mode 100644 lib/parse.sh delete mode 100644 lib/pr.sh delete mode 100644 lib/provenance.sh delete mode 100644 lib/route.awk delete mode 100644 lib/route.sh delete mode 100644 lib/setup.sh delete mode 100644 lib/workspace.sh diff --git a/.claude/skills/delphi-new-layout/SKILL.md b/.claude/skills/delphi-new-layout/SKILL.md index 538975b..955ea5d 100644 --- a/.claude/skills/delphi-new-layout/SKILL.md +++ b/.claude/skills/delphi-new-layout/SKILL.md @@ -12,12 +12,22 @@ Drive the CLI. Don't write to `context/` yourself. 2. **Gather candidates.** Read `recommend:` in the scope's `scope.yml` and in each ancestor's, closest scope first. Browse the scopes' `blocks/`, `docs/`, and `harness/` directories. Run `delphi layout list` to reuse ideas from existing layouts and avoid name clashes. -3. **Draft** a manifest in a temp file. Use the format in spec §4.3. All paths are relative to - `context/`, and each entry goes under the key that matches its location: - `harness/instructions/*` → `instructions`, `harness/skills/*` → `skills`, - `harness/mcp/*.json` → `mcp`, `harness/settings/*` → `settings` (at most one), - `blocks/*` → `blocks`, `docs/*` → `docs` (a trailing `/*` glob is allowed for both). `name` must equal the layout name - (`[a-z0-9-]+`). `harness: claude-code`. Add `repos:` as `name: git-url`. +3. **Draft** a manifest in a temp file (format: `docs/design.md` §2). Paths are relative to + `context/`; a directory source maps every file under it. + - `name`: the layout name (`[a-z0-9-]+`). `harness: claude-code`. + - `instructions:` (optional): parts assembled into `CLAUDE.md`, usually + `harness/instructions/workspace.md` first. The assembled file is generated: users edit a part + by syncing it. + - `sync:` files the team keeps in step with Delphi (refresh updates them, propose sends edits + back). `copy:` starting points the user then owns (never updated or proposed). + - Entry = ` [-> ]`. Default dests: `…/harness/skills/` → `.claude/skills/`, + `…/docs/` → `docs/`, anything else → `context/`. Use `->` for anything + else, e.g. `…/blocks/pr-body.md -> .claude/skills/open-pr/pr-body.md`. + - Dests must not overlap: no dest inside another entry's dest (sync a skill's `SKILL.md` as a + file if a block also goes into that skill folder). + - `repos:` as `name: git-url`. + Tell the user which sources other layouts also sync (`delphi layout list`, then read their + manifests): edits to those reach other teams. 4. **Show the draft** and get explicit approval. 5. **Create it:** `delphi layout new --from --model --effort --yes` diff --git a/.claude/skills/delphi-propose/SKILL.md b/.claude/skills/delphi-propose/SKILL.md index 40879d0..6c6f499 100644 --- a/.claude/skills/delphi-propose/SKILL.md +++ b/.claude/skills/delphi-propose/SKILL.md @@ -7,23 +7,33 @@ description: Propose a Delphi workspace's changes back to the monorepo. Dry-run Run these inside the workspace. Drive the CLI. Don't edit the Delphi repo directly. -1. Make sure the work is committed (`git status` is clean). -2. Run `delphi workspace propose --dry-run`. It prints the plan (where each edit routes) and the - **Unresolved** items, each with its path, reason, and diff. +1. Make sure the work is committed (`git status` is clean). Only committed changes are proposed. +2. Run `delphi workspace diff`. Each changed file gets one line: + - `update` / `new` / `manifest`: goes to that Delphi path. `(shared: …)` means other layouts use + that source too, so the edit reaches their teams. Point this out to the user. + - `noop`: nothing to send (a deleted file whose entry was removed, or the same edit already + routed from another copy of that source). + - `local`: stays in the workspace, never proposed (copied files, files outside synced folders). + - **Unresolved** items, each with a reason. + `delphi workspace diff --upstream` shows what changed in Delphi since the last refresh. 3. For each unresolved item, suggest a fix *in the workspace* and apply it only after the user approves: - - **Edit touches generated/separator lines or spans two blocks:** move the text wholly inside - one block's lines, or split it into two edits. In `CLAUDE.md`, a section set off by blank - lines between fragments routes as a new fragment (`fragment` in the plan). - - **New file outside a recognised place:** move it under `context//blocks/` (an - existing scope), `docs/` for a doc, or `.claude/skills//` for a skill. Otherwise leave it out of the PR - by deleting it or keeping it uncommitted. - - **Deleted file that is still compiled:** drop its entry from `.delphi/manifest.yml` instead. - To replace a block, swap the manifest entry, delete the old file, and add the new one under - `context//blocks/`. - - **Patch did not apply (same block edited in two places):** keep the edit in one place only. - - **Binary file:** Delphi doesn't route it. Remove it from the workspace commit. - Commit the fixes, then run `--dry-run` again until only acceptable items remain. Unresolved - items never block the PR. They are listed in its body. + - **generated; sync a part to edit it** (`CLAUDE.md` assembled from `instructions:`): revert the + edit in `CLAUDE.md`, add the part under `sync:` in `.delphi/manifest.yml`, and edit it there + after the next refresh. Or move the text into a synced file. + - **deleted, but still listed in .delphi/manifest.yml**: remove its entry from + `.delphi/manifest.yml` too (or restore the file). + - **deleted, but still one of the layout's files/**: restore it; removing a layout file needs a + Delphi PR by hand. + - **differing edits to one source**: the same source is synced to several dests with different + edits. Make the copies identical (or keep the edit in one of them). + - **its source already exists in Delphi**: a new sync entry points at an existing source. Delete + the workspace file; it arrives on refresh after the manifest change merges. + - **Delphi bookkeeping**: revert edits to `.delphi/lock.tsv`. + - **symlink or unsupported change type**: replace the symlink with a regular file or drop it. + To share a new file, put it in a synced folder, or add a `sync:` entry for it in + `.delphi/manifest.yml` (` -> `); propose creates the source. + Commit the fixes, then run `delphi workspace diff` again until only acceptable items remain. + Unresolved items never block the PR. They are listed in its body. 4. Run `delphi workspace propose --model --effort --yes`. It refreshes first. On exit code 2 (merge conflicts), help resolve them, commit, and run it again. 5. Report the branch and PR URL. Proposing again later replaces the same PR with the diff --git a/CLAUDE.md b/CLAUDE.md index 314804f..9baff71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,39 +1,33 @@ # Delphi — developing the CLI -Delphi stores NER's AI-harness context under `context/` and ships a small bash CLI that compiles +Delphi stores NER's AI-harness context under `context/` and ships a small Rust CLI that compiles layouts into workspaces, refreshes them, and proposes edits back as PRs. The design spec -(`context/docs/delphi-design.md`) is the source of truth. +(`docs/design.md`) is the source of truth; `docs/goals.md` lists what any change must keep. ## Layout -- `bin/delphi`: dispatcher. It resolves its own path and sources `lib/core.sh`, then the group's module. -- `lib/core.sh`: messages, `defer` cleanup, prompts, config, `safe_path`, Delphi git access, moves, `parse_args`. -- `lib/parse.sh`: YAML-subset parser (POSIX awk). -- `lib/compile.sh`: layout → files (instructions, blocks, docs, skills, MCP, settings) + `.delphi/lock.tsv`. -- `lib/route.sh` + `lib/route.awk`: pending diff + lock → plan → edits in a PR worktree. -- `lib/pr.sh`: the only write path to Delphi (temp worktree, commit with trailers, push, `gh`). -- `lib/provenance.sh`: harness/model/effort resolution. -- `lib/workspace.sh`, `lib/layout.sh`, `lib/block.sh`, `lib/check.sh`, `lib/setup.sh`: the command groups. -- `lib/harness/.sh`: harness adapters (names + `harness_provenance` + `harness_launch`). -- `dev/sandbox.sh`: throwaway end-to-end playground. It is the only test harness (no automated tests, no CI). +- `src/main.rs`: dispatcher. Resolves the Delphi repo, then runs the command group. +- `src/core.rs`: messages (`die!`, `warn!`, `info!`), deferred cleanup, prompts, config, `safe_path`, Delphi git access, moves, `parse_args`. +- `src/parse.rs`: strict YAML-subset parser. +- `src/compile.rs`: manifest entries (` [-> ]`, default dests) and layout → files + `.delphi/lock.tsv`. +- `src/route.rs`: pending diff + lock + manifest → per-file items; shared tags; applying items in a PR worktree. +- `src/pr.rs`: the only write path to Delphi (temp worktree, commit with trailers, push, `gh`). +- `src/provenance.rs`: harness/model/effort resolution. +- `src/workspace.rs`, `src/layout.rs`, `src/block.rs`, `src/check.rs`, `src/setup.rs`: the command groups. +- `src/harness.rs`: harness adapters (file names + provenance + launch). +- `tests/`: end-to-end tests. `tests/common` builds a throwaway sandbox (temp dir, bare origin, sample + context, stub `gh` on PATH); `tests/cli.rs` drives the binary against it. - `.claude/skills/`: LLM workflows that drive the CLI (`delphi-new-layout`, `delphi-propose`). ## Rules -- Must run under macOS `/bin/bash` 3.2. Test with `/bin/bash`, never zsh or a newer bash. -- Tools: POSIX awk (no gawk extensions), git, gh. jq is optional. +- `cargo build`, `cargo test`, and `cargo clippy --all-targets -- -D warnings` must be clean; run `cargo fmt`. - Keep it small: fewest lines that implement the spec, terse functions, a short header per file. -- Bash 3.2 pitfalls: - - Functions that `defer` cleanup (`make_tmp`, `delphi_worktree_at`, `pr_begin`) must not run - inside `$(...)`. They return results in `REPLY`. - - errexit is suspended in conditional contexts (`if f`, `f || x`), so critical commands need an explicit `|| die`. - - A dying function inside `$(...)` needs `|| exit 1` at the call site. - - Empty arrays error under `set -u`. Use newline-separated strings. - - A failing command substitution inside a heredoc does not propagate. Assign it to a variable first. - - Use `sed` rather than `grep -v`, which exits 1 on empty input and breaks under pipefail. - - `"$var…"` (a variable followed by a non-ASCII byte) is parsed as a longer name. Write `${var}…`. - An unbound-variable error under the EXIT trap exits with status **0**. -- Every path from config, flags, lock, or `moves.tsv` goes through `safe_path` before use. -- Never test against real GitHub repos. Use the sandbox: - `eval "$(/bin/bash dev/sandbox.sh /tmp/sb1)"`, then `d `. - The sandbox stubs `gh` (log in `$SB/gh.log`), so `--yes` pushes only to its bare origin. + Prefer deleting to adapting. +- Runtime tools: git and gh only. `gh` is used only to open or update PRs. +- Every path from config, flags, lock, manifests, or `moves.tsv` goes through `safe_path` (or + `path_ok` for paths only used inside git objects) before use. +- Never test against real GitHub repos. Add a test using the sandbox in `tests/common`; its `gh` + stub logs to `gh.log`, and `--yes` pushes only to the sandbox's bare origin. +- Try the CLI by hand the same way: point `DELPHI_ROOT` at a sandbox clone, never at this checkout's + real origin. diff --git a/README.md b/README.md index 31d1692..17e21ca 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,61 @@ # Delphi -NER's AI-harness context (knowledge, instructions, skills, MCP, settings), stored compressed -by org chart under `context/`, plus a small bash CLI: +NER's AI-harness context (instructions, docs, skills, settings), stored once by org chart under +`context/`, plus a small Rust CLI: -- **compile** a *layout* into a local, git-initialized *workspace* where Claude Code runs, -- **refresh** the workspace when `main` moves, -- **propose** workspace edits back as one PR, routed to the blocks they came from. +- **compile** a *layout* into a local, git-initialized *workspace* where every file sits at its + normal harness location (`CLAUDE.md`, `.claude/skills/…`, `docs/…`), +- **refresh** the workspace when `main` moves (synced files update; your edits are merged), +- **propose** workspace edits back as one PR: each edit to a synced file lands on its source. -Design: `context/docs/delphi-design.md`. +Each workspace file is either **synced** (linked to its source) or **copied** (yours after the +first copy). Design: `docs/design.md`. Goals: `docs/goals.md`. ## Quick start ```sh -bin/delphi setup # once: puts `delphi` on PATH (~/.local/bin, or pass a dir) +cargo install --path . # once: puts `delphi` on PATH +delphi setup # once, inside this checkout: remember where Delphi lives delphi ws new argos-dev # create the workspace delphi ws open argos-dev # start Claude Code in it +delphi ws diff # what you changed, and where each change would go delphi ws propose # send context edits back as a PR (run inside the workspace) delphi ws refresh # pull in Delphi updates ``` -Code changes go in `repos/argos` with its own PRs. Context changes (CLAUDE.md, skills, docs) are -committed in the workspace and sent back with `propose`. +Code changes go in `repos/argos` with its own PRs. Context changes (skills, docs, synced files) +are committed in the workspace and sent back with `propose`. + +## Layouts + +`context//layouts//manifest.yml` (plus an optional `files/` dir of the layout's own +files): + +```yaml +name: argos-dev +harness: claude-code +instructions: # optional: assembled into CLAUDE.md (generated) + - software/harness/instructions/base.md +sync: # linked to their source + - software/application-software/argos/harness/skills/run-local + - software/application-software/argos/blocks/pr-body.md -> .claude/skills/open-pr/pr-body.md +copy: # copied once, then yours + - software/harness/settings/claude.json -> .claude/settings.json +repos: + argos: https://github.com/Northeastern-Electric-Racing/Argos.git +``` ## Commands ``` -delphi layout new [--from ] create a layout (branch + PR) -delphi layout list list layouts on origin/main +delphi layout new --from create a layout (branch + PR) +delphi layout list list layouts on origin/main delphi workspace new [--as ] [--ref ] -delphi workspace open|refresh|propose|status (alias: ws) -delphi block mv move a block (branch + PR) -delphi check validate the repo -delphi setup [dir] put `delphi` on PATH +delphi workspace open|refresh|diff|propose|status (alias: ws) +delphi block mv move a source (branch + PR) +delphi check validate the repo +delphi setup [dir] remember this Delphi checkout ``` Commands that write to Delphi accept `--model`, `--effort` (provenance) and `--yes`. Without @@ -40,5 +63,5 @@ Commands that write to Delphi accept `--model`, `--effort` (provenance) and `--y ## Developing Delphi -Test CLI changes in the sandbox, never against GitHub (see `CLAUDE.md`): -`eval "$(/bin/bash dev/sandbox.sh /tmp/delphi-sb)"`, then `d `. +`cargo test` runs the end-to-end tests in throwaway sandboxes (stub `gh`, local bare origin); +never test against GitHub. See `CLAUDE.md`. diff --git a/bin/delphi b/bin/delphi deleted file mode 100755 index 2738a08..0000000 --- a/bin/delphi +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Delphi CLI entry point. Resolves its own location, then dispatches on the command group. -set -euo pipefail - -src=${BASH_SOURCE[0]} -while [ -L "$src" ]; do - dir=$(cd -P "$(dirname "$src")" && pwd) - src=$(readlink "$src") - case $src in /*) ;; *) src="$dir/$src" ;; esac -done -DELPHI_ROOT=$(cd -P "$(dirname "$src")/.." && pwd) -export DELPHI_ROOT - -. "$DELPHI_ROOT/lib/core.sh" - -usage() { - cat <<'EOF' -usage: delphi [args] - - layout new [--from ] create a layout (branch + PR) - layout list list layouts on origin/main - - workspace new [--as ] [--ref ] - workspace open [] [--model m] [--effort e] [--shell] - workspace refresh [] [--ref ] - workspace propose [] [--dry-run] - workspace status [--offline] (alias: ws) - - block mv move/rename a block - - check validate the repo - setup [dir] put `delphi` on PATH (default ~/.local/bin) - -Commands that write to Delphi accept --model, --effort, --yes. -EOF -} - -group=${1:-} -[ $# -gt 0 ] && shift -case $group in - layout) . "$DELPHI_ROOT/lib/layout.sh"; layout_main "$@" ;; - workspace|ws) . "$DELPHI_ROOT/lib/workspace.sh"; workspace_main "$@" ;; - block) . "$DELPHI_ROOT/lib/block.sh"; block_main "$@" ;; - check) . "$DELPHI_ROOT/lib/check.sh"; check_main "$@" ;; - setup) . "$DELPHI_ROOT/lib/setup.sh"; setup_main "$@" ;; - ""|-h|--help|help) usage ;; - *) die "unknown command '$group' (see: delphi help)" ;; -esac diff --git a/context/docs/delphi-design.md b/context/docs/delphi-design.md deleted file mode 100644 index 067876d..0000000 --- a/context/docs/delphi-design.md +++ /dev/null @@ -1,473 +0,0 @@ -# Delphi — Context Repository Design - -**Date:** 2026-09-23 -**Status:** Draft for review -**Owner:** Application Software (NER) - -## 1. Purpose - -Delphi is a monorepo that stores Northeastern Electric Racing's AI-harness context — knowledge, instructions, skills, MCP definitions, settings — in a **compressed** form organized by the org chart, plus a small bash CLI that: - -1. **Compiles** a chosen combination of that context (a *layout*) into an **uncompressed**, local, git-initialized *workspace* where a harness (Claude Code first) runs for real development. -2. **Refreshes** that workspace when Delphi's `main` moves. -3. **Proposes** changes made in the workspace back to the monorepo as a PR, routing each edit to the block it came from and recording the model, harness, and effort level used. - -High-level commands stay simple; complexity lives in `lib/`. - -## 2. Terminology - -| Term | Meaning | -|---|---| -| **Scope** | A directory under `context/` representing an org unit (club → area → subteam → team). Identified by its path. | -| **Block** | A tracked unit of context, identified by its path relative to `context/`. | -| **Basic block** | Text/markdown under a scope's `blocks/`. | -| **Doc** | Project documentation (glossary, ADRs) under a scope's `docs/`, compiled into the workspace's `docs/`. | -| **Harness block** | Under a scope's `harness/`: instruction fragments, skills, MCP fragments, settings. | -| **Layout** | A `manifest.yml` under a scope's `layouts//` selecting blocks, a harness, and code repos. Compressed side. | -| **Workspace** | A local git-initialized directory, outside the repo, compiled from a layout. Uncompressed side. Many workspaces may come from one layout. | -| **Compile** | The deterministic function `(delphi commit, layout) → files + lock`. | -| **Lock** | `.delphi/lock.tsv`: segment map from compiled output line ranges to their sources. | -| **Segment** | A contiguous line range of one output file that came from one source. | -| **Pending diff** | `git diff generated-merged HEAD` over routable paths: everything the workspace has changed relative to its latest compile. | - -## 3. Repository structure - -``` -Delphi/ -├── CLAUDE.md # for developing Delphi itself -├── .claude/skills/ -│ ├── delphi-new-layout/ # LLM workflow: build a layout from recommendations -│ └── delphi-propose/ # LLM workflow: resolve unresolved items, then propose -├── delphi.conf # key=value repo config -├── moves.tsv # block rename/move log -├── bin/delphi # dispatcher -├── lib/ -│ ├── core.sh # config, logging, prompts, safe_path, layout lookup, move resolution -│ ├── parse.sh # awk YAML-subset parser -│ ├── compile.sh # layout → files + lock -│ ├── route.sh # pending diff + lock → routing plan -│ ├── pr.sh # temp worktree, commit w/ trailers, push + gh PR -│ ├── provenance.sh # resolve harness/model/effort -│ ├── layout.sh # `delphi layout …` -│ ├── workspace.sh # `delphi workspace …` -│ ├── block.sh # `delphi block …` -│ ├── check.sh # `delphi check` -│ └── harness/ -│ └── claude-code.sh # harness adapter -└── context/ # root scope = NER club-wide - ├── scope.yml - ├── blocks/ docs/ harness/ layouts/ - └── software/ # child scope - ├── scope.yml - ├── blocks/ docs/ harness/ layouts/ - ├── finishline/ - ├── application-software/ - │ ├── argos/ - │ └── nero/ - └── firmware/ - └── … -``` - -### 3.1 Scope shape - -Every scope directory contains a required `scope.yml` and any of these **reserved** subdirectories: - -``` -/ -├── scope.yml -├── blocks/ # basic blocks: *.md, *.txt (subdirs allowed) -├── docs/ # project docs: CONTEXT.md, adr/*.md (subdirs allowed) -├── harness/ -│ ├── instructions/*.md # fragments stacked into the harness instruction file -│ ├── skills//SKILL.md (+ any files) # native skill -│ ├── skills/.skill # built skill spec -│ ├── mcp/.json # MCP server fragment -│ └── settings/.json # harness settings file -└── layouts//manifest.yml -``` - -Any other subdirectory is a child scope. Scope names follow the Fall 2026 roster structure (`Software → FinishLine | Software Product | Application Software → {Argos, NERO} | Firmware → {…}`); scopes are created as content arrives, not up front. - -**No file anywhere under `context/` may be named after a harness instruction file** (`CLAUDE.md`, `AGENTS.md`, …) — enforced by `check`. - -### 3.2 Workspace location - -Workspaces compile **outside** the repo so Delphi's own `CLAUDE.md` is never loaded as an ancestor file: - -``` -/ # default ../Delphi-workspaces (relative to repo root) -├── argos-dev/ -└── argos-dev--bug-612/ -``` - -## 4. File formats - -### 4.1 YAML subset (parsed by `lib/parse.sh`) - -All `.yml` and `.skill` files use this subset: - -- Full-line `#` comments and trailing ` #` comments. -- Top-level `key: value` (scalar; unquoted or `"double-quoted"`). -- Top-level `key:` followed by ` - item` lines (list; two-space indent). -- Top-level `key:` followed by ` subkey: value` lines (one-level map; two-space indent). -- **Not supported** (parse error with `file:line`): tabs, anchors/aliases, flow style (`[a, b]`, `{}`), multi-line strings, deeper nesting. - -Parser output (tab-separated, one record per line): - -| Construct | Output | -|---|---| -| scalar | `keyvalue` | -| list item | `keyvalue` (repeated) | -| map entry | `keysubkeyvalue` | - -Consumers know each key's type from the schemas below. Parser uses POSIX awk only (verified against BSD awk and mawk). - -### 4.2 `scope.yml` - -```yaml -name: Argos -recommend: # paths relative to context/; offered, never auto-included - - software/application-software/argos/blocks/overview.md - - software/application-software/argos/harness/skills/run-tests -``` - -### 4.3 `manifest.yml` (layout) - -```yaml -name: argos-dev # must equal the layout directory name; unique repo-wide -harness: claude-code # required; must match lib/harness/.sh -instructions: # stacked, in order, into $HARNESS_INSTRUCTIONS - - software/harness/instructions/base.md - - software/application-software/argos/harness/instructions/argos.md -blocks: # basic blocks; trailing * glob allowed (non-recursive, sorted) - - software/application-software/argos/blocks/* -docs: # project docs; same entry rules as blocks - - software/application-software/argos/docs/CONTEXT.md - - software/application-software/argos/docs/adr/* -skills: # native skill dir or .skill spec - - software/application-software/argos/harness/skills/run-tests -mcp: - - software/harness/mcp/github.json -settings: software/harness/settings/default.json # optional; at most one in v1 -repos: # name: git URL - argos: git@github.com:Northeastern-Electric-Racing/Argos.git -``` - -All paths are relative to `context/`. Only `name` and `harness` are required. Unknown keys are errors. - -### 4.4 `.skill` (built skill spec) - -```yaml -name: run-tests -description: Run and interpret Argos test suites. -body: # stacked into SKILL.md body - - software/application-software/argos/blocks/testing.md -references: # copied to references/ - - software/application-software/argos/blocks/ci.md -``` - -Lets one basic block serve as plain context in one layout and as a skill in another without duplication. - -### 4.5 MCP fragment - -Each `harness/mcp/.json` contains exactly one member of the `mcpServers` object, no trailing comma: - -```json -"github": { - "command": "gh-mcp", - "args": [] -} -``` - -### 4.6 `delphi.conf` - -``` -workspace_root=../Delphi-workspaces -stale_days=14 -``` - -Parsed line-by-line as `key=value` (never `source`d). Relative paths resolve against the repo root. `DELPHI_WORKSPACE_ROOT` env var overrides `workspace_root`. - -### 4.7 `moves.tsv` - -``` -# oldnewdate -software/blocks/git.md software/blocks/git-conventions.md 2026-10-02 -software/application-software/argos/blocks/old-dir software/application-software/argos/blocks/ops 2026-10-05 -``` - -Append-only. Rows are applied **in order, each once** (exact match, or directory-prefix match for directory entries), and only the rows that are new to the consumer: `block mv` applies the row it appends; a workspace applies the rows added since its `compile_commit`. Paths may therefore be reused, and a block can be moved back. - -### 4.8 `lock.tsv` - -``` -# output start end source sha -CLAUDE.md 1 3 @gen:delphi - -CLAUDE.md 4 4 @glue - -CLAUDE.md 5 44 software/harness/instructions/base.md a1b2c3… -CLAUDE.md 45 45 @glue - -CLAUDE.md 46 92 software/application-software/argos/harness/instructions/argos.md d4e5f6… -.claude/skills/run-tests/SKILL.md 1 4 @gen:software/application-software/argos/harness/skills/run-tests.skill - -.claude/skills/run-tests/SKILL.md 5 60 software/application-software/argos/blocks/testing.md f7a8b9… -.delphi/manifest.yml 1 20 software/application-software/argos/layouts/argos-dev/manifest.yml 0c1d2e… -``` - -`sha` is the git blob id of the source at the compiled commit. Every line of every compiled file except `lock.tsv` itself belongs to exactly one segment. Source kinds: a path under `context/`, `@glue` (separator lines), `@gen:` (generated lines). - -### 4.9 Workspace state - -| Location | In workspace git? | Contents | -|---|---|---| -| `.delphi/manifest.yml` | tracked (compiled) | Editable copy of the layout manifest; edits propose back to the layout. | -| `.delphi/lock.tsv` | tracked (compiled) | Segment map of the compile. Always read from `generated-merged`, never the working copy. | -| `.git/delphi/meta` | not tracked (inside `.git/`) | `key=value`: `layout`, `layout_path`, `ref` (Delphi branch the workspace tracks, default `main`), `created`, `last_proposed`, `last_proposed_hash`, `last_pushed`, `pending_since`, `compile_commit` (the Delphi commit `generated-merged`'s content corresponds to — authoritative; may be newer than that commit's trailer when later compiles were identical). | -| `.git/info/exclude` | not tracked | `repos/`, `worktrees/`, `$HARNESS_IGNORE`. | -| `.git/hooks/commit-msg` | not tracked | Provenance trailer hook (§9). | - -Keeping state inside `.git/` means Delphi bookkeeping never dirties the working tree and never shows up in the pending diff. - -**Refs in the workspace repo:** - -| Ref | Meaning | -|---|---| -| `generated` (branch) | History of compiles only. Each commit's message is `delphi: compile @` with trailer `Delphi-Compile: `. | -| `generated-merged` (tag) | The latest compile that has been **merged into `working`**. Its current Delphi commit is `meta.compile_commit`. | -| `working` | The user's branch, where all development happens. | - -`generated` and `generated-merged` differ only while a refresh is pending (compile committed, merge not yet completed). - -The workspace repo is created with `git init` and shares no history with Delphi; none of these refs are Delphi branches. `generated` holds only Delphi's compiled output (never user work); `working` starts from the first compile and merges each later one. The only links back to Delphi are the `Delphi-Compile` trailer text and `meta.compile_commit`. - -## 5. Compiling (`lib/compile.sh`) - -`compile `: deterministic. Reads sources from a temporary detached worktree of Delphi at ``, writes output files and `/.delphi/lock.tsv`. Each source file's content is emitted with a trailing newline added if missing, so segments never share a line. - -| Manifest key | Output | Segments | -|---|---|---| -| `instructions` | `$HARNESS_INSTRUCTIONS`: delphi header, then fragments in order, one blank line between each. Emitted even when the list is empty (header only). | `@gen:delphi` header, `@glue` blank lines, block per fragment | -| `blocks` | `context/` — mirrors the block's path exactly (e.g. `software/application-software/argos/blocks/ops/x.md` → `context/software/application-software/argos/blocks/ops/x.md`) | one segment per file | -| `docs` | `docs/` (e.g. `software/application-software/argos/docs/adr/0001-x.md` → `docs/adr/0001-x.md`) | one segment per file | -| `skills` (native) | `$HARNESS_SKILLS_DIR//…`, each file (subdirectories included, e.g. `scripts/`) copied 1:1, executable bit kept | one segment per file | -| `skills` (`.skill`) | `$HARNESS_SKILLS_DIR//SKILL.md` = generated frontmatter (`name`, `description`) + body blocks with `@glue` between; references copied to `references/` | `@gen:` frontmatter, block per body/reference | -| `mcp` | `$HARNESS_MCP_FILE`: `{"mcpServers": {` + fragments separated by a `,` line + `}}`. Omitted when the list is empty. | `@gen:delphi` wrapper, block per fragment, `@glue` commas | -| `settings` | `$HARNESS_SETTINGS_FILE`, copied 1:1 | one segment | -| (always) | `.delphi/manifest.yml` copy of the layout manifest | one segment | - -**Delphi header** (top of the instruction file, placed first so appends at the end of the file land in a real block): a short note that this workspace was compiled by Delphi from layout ``, that edits are expected, and that finished work should be committed so `delphi workspace propose` can send it upstream. - -Compile errors (missing path, empty glob, parse error, duplicate output path, unsafe path) abort with no partial output. - -## 6. Commands - -`bin/delphi [args]`. `ws` is an alias for `workspace`. Commands that take `[]` default to the workspace containing the current directory. - -**Common flags** for commands that write to Delphi (`layout new`, `block mv`, `workspace propose`): `--model `, `--effort ` (provenance, §9) and `--yes` (skip the push confirmation; also `DELPHI_YES=1`). Without `--yes`, a non-interactive run (no TTY — e.g. an agent's shell tool) fails fast with a message to pass `--yes` rather than hanging. - -### 6.1 `delphi layout new [--from ]` - -1. Validate: scope has `scope.yml`; `` matches `[a-z0-9-]+` and is unique repo-wide. -2. Without `--from` (basic script): collect `recommend` entries from the scope and each ancestor (closest first, deduped); ask y/n per item; ask harness (choices = `lib/harness/*.sh`); prompt for repos (`name url` lines until blank). Items are placed into manifest keys by path: `*/harness/instructions/*` → `instructions`, `*/harness/skills/*` → `skills`, `*/harness/mcp/*` → `mcp`, `*/harness/settings/*` → `settings` (more than one → re-ask), `*/blocks/*` → `blocks`, `*/docs/*` → `docs`. -3. With `--from`: use the given manifest verbatim (used by the `delphi-new-layout` skill). -4. Via `pr.sh` (§7): write `context//layouts//manifest.yml`, run `check`, commit, PR on branch `delphi/layout/`. Prints the branch name. - -### 6.2 `delphi layout list` - -Prints `namescopeharness` for every layout on `origin/main`. - -### 6.3 `delphi workspace new [--as ] [--ref ]` - -1. `git fetch`. Resolve the layout by name **in the tree of `origin/`** (default `main`), following `moves.tsv` at that commit. Workspace name defaults to the layout name; error if `/` exists. -2. Compile at `origin/` into a temp dir. -3. `git init` the workspace; write `.git/info/exclude`, `.git/delphi/meta` (with `ref`, `pending_since` empty), and the `commit-msg` hook. -4. Copy the compile in, commit on `generated`, tag `generated-merged`, create and check out `working` from it; set `meta.compile_commit`. The working tree is clean. -5. Clone each repo into `repos/`; create empty `worktrees/`. Clone failures warn and are summarized at the end; the workspace is still usable. - -`--ref` lets you try a layout before its PR merges (`workspace new argos-dev --ref delphi/layout/argos-dev`). Such a workspace refreshes from that branch; `propose` refuses until the layout exists on `main` (§6.6), and `refresh --ref main` switches it over once merged. - -### 6.4 `delphi workspace open [] [--model m] [--effort e] [--shell]` - -1. Resolve workspace (argument, current directory, or numbered picker over `status` output). -2. Warn if the compile is behind its ref (suggest `refresh`) or if any workspace is `unproposed` for more than `stale_days`. -3. Export `DELPHI_HARNESS`, `DELPHI_MODEL`, `DELPHI_EFFORT` and `exec harness_launch` in the workspace directory; with `--shell`, `exec $SHELL` there instead. - -### 6.5 `delphi workspace refresh [] [--ref ]` - -Idempotent; re-running always picks up where it left off. No `--continue`. - -1. Require a clean working tree and no merge in progress. `--ref` updates `meta.ref` first. -2. `git fetch` Delphi; resolve the layout path through `moves.tsv` at `origin/`; update `meta.layout_path`. -3. **Compile step** — skipped if `generated` is ahead of `generated-merged` (a pending refresh): compile at `origin/` commit `C` into a temporary worktree of the workspace repo checked out on `generated`, replacing its tracked contents. - - Content changed → commit with trailer `Delphi-Compile: C`. - - Content identical → set `meta.compile_commit = C` (no commits; keeps the recorded commit current, e.g. after a squash-merged layout PR or unrelated `main` activity), report "up to date", exit 0. - If `origin/` no longer exists, fail with "branch `` is gone — run `refresh --ref main`". -4. **Merge step** — skipped if `generated` is already an ancestor of `HEAD`: `git merge --no-edit generated`. On conflict, exit **2**: "resolve conflicts, commit, then re-run `delphi workspace refresh`." -5. **Finalize** (only once `generated` is an ancestor of `HEAD`): move tag `generated-merged` to `generated`; set `meta.compile_commit` from that commit's `Delphi-Compile` trailer. Rewrite any moved paths in `.delphi/manifest.yml`; if changed, commit `delphi: apply moves`. If the pending diff is now empty, set `meta.pending_since = HEAD`. Exit 0. - -All commits Delphi itself makes in a workspace (compiles, merges, `apply moves`) use `--no-verify` so the provenance hook never tags them. - -**Exit codes:** 0 = up to date or finalized; 2 = merge conflict awaiting resolution; 1 = error. - -If a refresh is pending when `--ref` changes, the pending compile is merged and finalized first, then the compile step runs once more against the new ref. Compile and merge commits only appear in workspace history when the compiled content actually changed. - -### 6.6 `delphi workspace propose [] [--dry-run]` - -**One live PR per workspace**, on branch `delphi/propose//` (`` from `gh api user --jq .login`; workspace names are local, so the user part prevents teammates' branches colliding). Each propose rebuilds that branch from scratch with the **entire** current pending diff and force-updates it, so the PR always equals "everything this workspace still differs from `main` by." Proposing twice never duplicates; a merged PR's changes drop out after the next refresh; a closed/rejected change keeps reappearing until it is reverted in the workspace (the documented escape hatch). - -1. `--dry-run`: skip refresh; route against the current `generated-merged` (warn if behind); print the plan and unresolved items; change nothing; stop. -2. Require a clean working tree and `meta.ref = main` (else: "layout not on main yet — merge its PR, then `refresh --ref main`"). Run `refresh`; continue only if it exits 0. -3. Compute the pending diff and route it (§8) into a plan. -4. If the plan has no routed changes and no unresolved items: print "nothing to propose", exit 0. -5. Via `pr.sh`, building from `meta.compile_commit` (so patches apply exactly), in order: - 1. **Layout manifest** — if `.delphi/manifest.yml` changed, replace the layout manifest with it (moved paths resolved). - 2. **Block edits** — apply routed hunks. - 3. **New blocks** — add files; append each to the layout manifest's `blocks:`/`docs:`/`skills:` unless an existing entry or glob already covers it. New instruction fragments (§8) are inserted into `instructions:` at their position. - 4. Run `check`; abort (no push) on failure, printing the violations. - Each step with changes is one commit with provenance trailers. -6. Push (force, with an explicit lease — §7); `gh pr create` if no open PR exists for the branch, otherwise `gh pr edit` to replace the body. Refuse if an open PR on the branch was authored by someone other than the current `gh` user. PR body: routed-change summary, provenance table (§9), **Unresolved** section (each item as a fenced diff with its workspace path and reason). -7. Set `meta.last_proposed` = today and `meta.last_proposed_hash` = pending diff hash. - -**Pending diff hash:** `git hash-object` of the `-U0` pending diff with `@@` hunk headers and `index` lines stripped, so upstream changes that only shift line numbers don't flip a workspace back to `unproposed`. - -### 6.7 `delphi workspace status [--offline]` - -For each directory in `workspace_root` containing `.git/delphi/meta`, print: workspace, layout, ref, dirty flag, **state**, age, behind flag. - -| State | Condition | -|---|---| -| `clean` | pending diff empty | -| `proposed` | pending diff hash = `last_proposed_hash` | -| `unproposed` | otherwise | - -Age = days since `last_proposed` (or `created`) for `unproposed`. Behind = `origin/` has commits since `meta.compile_commit` touching any lock source, the layout manifest, or a directory covered by a manifest glob. `--offline` skips `git fetch`. Status cannot see whether a PR was closed; a rejected workspace stays `proposed` until its change is reverted or re-proposed. - -### 6.8 `delphi block mv ` - -Only paths under a scope's `blocks/`, `docs/`, or `harness/` (layouts cannot be moved in v1). Via `pr.sh`: validate `old` exists and `new` does not, both inside `context/`; `git mv`; append to `moves.tsv`; rewrite exact and directory-prefix references in every `manifest.yml`, `scope.yml`, and `.skill`; run `check`; commit; PR on branch `delphi/mv/-`. Existing workspaces pick up the move on their next `refresh`/`propose`. - -### 6.9 `delphi check` - -Exits non-zero listing every violation: - -- Every scope directory has `scope.yml`; every `.yml`/`.skill` parses. -- Manifests: required keys present, no unknown keys, `name` equals directory, harness adapter exists, at most one `settings`, layout names unique. -- Every referenced path exists (each glob matches ≥1 file) and resolves inside `context/`. -- Entries are under the key matching their location (`skills` entries are a dir with `SKILL.md` or a `.skill` file, etc.). -- No file under `context/` is named any adapter's `$HARNESS_INSTRUCTIONS`. -- `moves.tsv` rows well-formed. -- MCP fragments form valid JSON when wrapped — only if `jq` is installed; otherwise skipped with a note. - -### 6.10 `delphi setup [dir]` - -Writes a two-line `delphi` wrapper (`exec /bin/delphi "$@"`) into `dir` (default `~/.local/bin`) and warns if `dir` is not on `PATH`. A wrapper rather than a symlink, because Git Bash copies on `ln -s` by default. - -## 7. Writing to Delphi (`lib/pr.sh`) - -All commands that modify the monorepo use one path: - -1. `git fetch --prune`; create a temporary worktree of Delphi on the command's branch, starting from a given commit (default `origin/main`). The user's own checkout is never touched. -2. Caller applies changes inside it. -3. Commit with provenance trailers (§9). -4. Confirm `Push and open/update PR? [y/N]` unless `--yes`; then push and `gh pr create` / `gh pr edit` with the caller's body plus the provenance table. Propose branches are force-pushed with an explicit lease: `--force-with-lease=:` where `` is `meta.last_pushed`, the commit this workspace last pushed (empty when the branch is absent on the remote, e.g. auto-deleted after a merge). If the remote branch exists but differs from `last_pushed`, someone else pushed to it: propose stops ("review the PR, then re-run"), records the remote sha, and the next run overwrites it. -5. Remove the temp worktree via `trap` on success or failure. - -## 8. Change routing (`lib/route.sh`) - -Input: pending diff (`git diff --no-renames generated-merged HEAD`), excluding `repos/`, `worktrees/`, `.delphi/lock.tsv`. Lock read from `generated-merged`. Output: plan rows `kindworkspace-pathtarget[reason]`. Checked in this order per file: - -| # | Change | Result | -|---|---|---| -| 1 | `.delphi/manifest.yml` modified | layout manifest replace (whole file) | -| 2 | Binary | unresolved | -| 3 | Deleted | no-op if its source is no longer referenced by the workspace's `.delphi/manifest.yml` (dropped via the manifest); otherwise unresolved — blocks are dropped by editing `.delphi/manifest.yml` | -| 4 | Modified, in lock | per-hunk routing (below) | -| 5 | Added at `context/

` where `

` is `/blocks/…` and `` is an existing scope at `meta.compile_commit` | new block at `

` | -| 5a | Added at `docs//` | new doc beside the compiled docs already in `docs//` (their source directory), else at `/docs//` | -| 6 | Added under `$HARNESS_SKILLS_DIR//` where `` is a native skill in the lock | new file in that skill's source directory | -| 7 | Added under `$HARNESS_SKILLS_DIR//` where `` is not in the lock | new native skill at `/harness/skills//` | -| 8 | Anything else | unresolved | - -**Hunk routing** (from `git diff -U0`; base-side range `a,n`; patches applied with `git apply --unidiff-zero`): - -| Hunk | Result | -|---|---| -| Insertion (`n = 0`) in `$HARNESS_INSTRUCTIONS` at a segment boundary (`a = 0` or `a = seg.end`) | **new fragment**, except the leading/trailing lines touching a neighbouring block with no blank line between, which extend that block (rows below). The rest, trimmed of blank lines, goes to `/harness/instructions/.md` (slug from its first line; `-2`, `-3`, … when taken) and is listed in the layout's `instructions:` after the preceding fragment (first when none) | -| All base lines `a..a+n-1` inside one block segment | patch that block (`block_line = base_line − seg.start + 1`) | -| Insertion (`n = 0`) after line `a`, where `seg.start ≤ a < seg.end` of a block segment | patch that block | -| Insertion after the last line of a block segment (`a = seg.end`), when the next line is end-of-file or a non-block segment | append to that block | -| Insertion at file start (`a = 0`), when the first segment is a block | prepend to that block | -| Insertion right after a `@glue`/`@gen` segment (`a = seg.end`), when the next segment is a block | prepend to that next block | -| Inside `@gen:.skill`, touching only `name:`/`description:` lines | rewrite those keys in the `.skill` spec | -| Anything else: in `@glue`/other `@gen`, spanning segments, or `git apply` fails (e.g. the same block edited in two outputs) | unresolved | - -**Replacing a block** needs no special case: swap the entry in `.delphi/manifest.yml` (old path → new path), delete the old compiled file, and add the new one under `context//blocks/`. The deletion is a no-op (rule 3), the manifest edit routes by rule 1, and the new file by rule 5. - -**Placement is strict by design:** a file not in a recognised location stays unresolved; Delphi never guesses where it belongs. - -All routed hunks for one (output file, block) pair are combined into a single patch so line offsets stay consistent. - -Unresolved items never block the PR; they are listed in it. They are resolved by changing the **workspace** so the change becomes routable (move text into a block's region, move a new file under `context//blocks/`, edit `.delphi/manifest.yml`, or revert it), then proposing again. - -## 9. Provenance - -Every commit Delphi writes to the monorepo carries trailers; every PR body repeats them as a table. - -``` -Delphi-Harness: claude-code 2.4.1 -Delphi-Model: claude-opus-5-5 -Delphi-Effort: high -Delphi-Layout: software/application-software/argos/layouts/argos-dev -Delphi-Workspace: argos-dev--bug-612 # propose only -Delphi-Base: 0cdd375 # propose only: meta.compile_commit -``` - -**Resolution order** (`lib/provenance.sh`), per field: CLI flag → `DELPHI_*` env (set by `workspace open`) → adapter's `harness_provenance` fallback. If still missing: prompt when interactive (accepting `none` for runs without an LLM); error when non-interactive. Never written as "unknown". - -**Workspace commit hook:** `.git/hooks/commit-msg` appends `Delphi-Harness/Model/Effort` trailers from `DELPHI_*` env vars when set and not already present. - -**Propose aggregation:** the PR table lists the proposing session's values plus each distinct trailer combination from non-merge workspace commits in `pending_since..HEAD` (all of `working` if `pending_since` is empty). - -**Known gap:** if the model changes mid-session (e.g. `/model`), the env vars from `workspace open` go stale; flags on `propose` override. - -## 10. Harness adapter contract - -`lib/harness/.sh` is sourced and must define: - -| Name | Kind | claude-code value | -|---|---|---| -| `HARNESS_INSTRUCTIONS` | var | `CLAUDE.md` | -| `HARNESS_SKILLS_DIR` | var | `.claude/skills` | -| `HARNESS_MCP_FILE` | var | `.mcp.json` | -| `HARNESS_SETTINGS_FILE` | var | `.claude/settings.json` | -| `HARNESS_IGNORE` | var | `.claude/settings.local.json` | -| `harness_provenance` | fn | prints 3 lines: `claude-code `, model, effort (from `CLAUDE_CODE_EFFORT_LEVEL` when set) | -| `harness_launch ` | fn | `exec claude` with `--model` when given and `CLAUDE_CODE_EFFORT_LEVEL` set | - -The generic compiler does all file work; adapters only supply names and two functions. New harnesses = new adapter file. - -## 11. LLM workflows (Delphi repo skills) - -- **`delphi-new-layout`** (primary way to create layouts): interviews the user about the work, reads `scope.yml` recommendations up the scope chain and browses available blocks, drafts a manifest, shows it for approval, then runs `delphi layout new --from --model … --effort … --yes`. Optionally follows with `delphi workspace new --ref delphi/layout/` to try it. -- **`delphi-propose`**: runs `delphi workspace propose --dry-run`, walks the user through each unresolved item with a suggested fix in the workspace (per §8's resolution list), applies and commits approved fixes, then runs `delphi workspace propose --model … --effort … --yes`. - -Both are optional conveniences; every operation is available through the scripts alone. - -## 12. Cross-cutting rules - -- **Locating Delphi:** `bin/delphi` resolves its own real path (a `readlink` loop, no `readlink -f`) and uses its parent as the repo root, both in the Delphi checkout and inside workspaces. Install = put `Delphi/bin` on `PATH` or symlink `bin/delphi`. -- **Bash 3.2 compatible** (macOS default): no associative arrays, `mapfile`, `${x,,}`, etc. `set -euo pipefail` in every entry point. -- **Dependencies:** `git`, `awk` (POSIX features only), `sed`, `gh` (only for opening PRs). `jq` optional (check only). Blob ids and diff hashes via `git hash-object`. -- **Path safety:** every path from config, flags, lock, or `moves.tsv` goes through `safe_path ` (rejects absolute paths, `..` escapes, and symlinks resolving outside ``) before any read, write, or delete. Deletes happen only via `git` or inside `mktemp -d` dirs. (Prior prototypes all failed this.) -- **Errors:** actionable messages naming the file/line or command to run next; temp worktrees cleaned via `trap`; multi-step operations are idempotent on re-run, never half-applied silently. - -## 13. Out of scope for v1 - -- Automated tests and CI (deferred by decision until the working version has been used). -- Seed content (Argos scope and layout come next, separately). -- Harness adapters other than claude-code. -- Merging multiple settings files; recursive globs; automatic routing of deletions; moving/renaming layouts. -- Managing `worktrees/` beyond creating the directory (use `git worktree` directly). -- Mechanical, Electrical, and Business scopes. - -## 14. Resolved decisions - -- Default `workspace_root` is `../Delphi-workspaces` (sibling of the Delphi clone); configurable via `delphi.conf` or `DELPHI_WORKSPACE_ROOT`. diff --git a/context/harness/instructions/workspace.md b/context/harness/instructions/workspace.md index d2d5306..be4c07e 100644 --- a/context/harness/instructions/workspace.md +++ b/context/harness/instructions/workspace.md @@ -4,10 +4,13 @@ You are in a Delphi workspace: a folder that pairs team context with the code re | Path | What it is | Changes go | |---|---|---| -| `CLAUDE.md`, `.claude/`, `context/`, `docs/` | Team context and docs pulled from Delphi | Workspace git (`working` branch); `delphi workspace propose` opens the Delphi PR | +| `CLAUDE.md`, `.claude/`, `docs/`, `context/` | Team context and docs from Delphi | Workspace git (`working` branch); `delphi workspace propose` opens the Delphi PR | | `repos//` | A normal clone of a code repo, with its own remote | That repo's git, branches, and PRs, per its conventions | | `worktrees/` | Empty; for extra checkouts of a repo (`git -C repos/ worktree add ../../worktrees/ `) | Same as the repo it came from | - Run a repo's git, `gh`, build, and test commands from inside that repo (`cd repos/`), never from the workspace root: at the root, `git` is the workspace repo. -- Code changes never go in the workspace git (`repos/` and `worktrees/` are git-ignored there). Context changes (instructions, skills, blocks) never go in a code repo. -- `.delphi/` is Delphi bookkeeping. Don't edit it by hand. +- Code changes never go in the workspace git (`repos/` and `worktrees/` are git-ignored there). Context changes (instructions, skills, docs) never go in a code repo. +- Each context file is a copy of one file in Delphi. **Synced** files are updated by `delphi workspace refresh`, and committed edits to them are proposed back. **Copied** files are yours after the first copy and are never proposed. Files you add stay local unless they sit in a synced folder. +- If `CLAUDE.md` is assembled from parts, it is generated: edit the synced part instead (or add the part under `sync:` in `.delphi/manifest.yml`). +- `delphi workspace diff` shows what you changed and where each change would go. `--upstream` shows what changed in Delphi since the last refresh. +- `.delphi/manifest.yml` is the layout: edit it to add or drop synced files (the edit is proposed too). Don't edit `.delphi/lock.tsv`. diff --git a/context/software/application-software/argos/harness/skills/open-pr.skill b/context/software/application-software/argos/harness/skills/open-pr.skill deleted file mode 100644 index d293975..0000000 --- a/context/software/application-software/argos/harness/skills/open-pr.skill +++ /dev/null @@ -1,5 +0,0 @@ -name: open-pr -description: Run pre-PR checks, push the branch, and open a draft pull request -body: - - software/application-software/argos/blocks/open-pr.md - - software/application-software/argos/blocks/pr-body.md diff --git a/context/software/application-software/argos/blocks/open-pr.md b/context/software/application-software/argos/harness/skills/open-pr/SKILL.md similarity index 63% rename from context/software/application-software/argos/blocks/open-pr.md rename to context/software/application-software/argos/harness/skills/open-pr/SKILL.md index 42f8a98..eca0ff8 100644 --- a/context/software/application-software/argos/blocks/open-pr.md +++ b/context/software/application-software/argos/harness/skills/open-pr/SKILL.md @@ -1 +1,8 @@ +--- +name: open-pr +description: Run pre-PR checks, push the branch, and open a draft pull request +--- + Stop if the working tree is dirty. Check the commits since `develop` follow the commit format, lint the frontend if it changed, and check that `origin/develop` merges without conflicts. Then push and run `gh pr create --draft --base develop --head --title "#{ticket} title" --body-file /tmp/-pr-body.md --assignee @me`, and report the URL. + +Write the PR body as described in `pr-body.md` (next to this file) before running `gh pr create`. diff --git a/context/software/application-software/argos/harness/skills/update-pr.skill b/context/software/application-software/argos/harness/skills/update-pr.skill deleted file mode 100644 index 94c7dbd..0000000 --- a/context/software/application-software/argos/harness/skills/update-pr.skill +++ /dev/null @@ -1,5 +0,0 @@ -name: update-pr -description: Update the current branch's PR description to reflect the latest changes -body: - - software/application-software/argos/blocks/update-pr.md - - software/application-software/argos/blocks/pr-body.md diff --git a/context/software/application-software/argos/blocks/update-pr.md b/context/software/application-software/argos/harness/skills/update-pr/SKILL.md similarity index 56% rename from context/software/application-software/argos/blocks/update-pr.md rename to context/software/application-software/argos/harness/skills/update-pr/SKILL.md index 22ae612..9c6d715 100644 --- a/context/software/application-software/argos/blocks/update-pr.md +++ b/context/software/application-software/argos/harness/skills/update-pr/SKILL.md @@ -1 +1,8 @@ +--- +name: update-pr +description: Update the current branch's PR description to reflect the latest changes +--- + Rewrite the current branch's PR body (`gh pr view`) to match `git diff develop...HEAD`. Keep human-written text that's still accurate, `user-attachments` screenshots, and `Closes`/`Fixes` refs. Apply it with `gh pr edit --body-file`. + +Write the new body as described in `pr-body.md` (next to this file). diff --git a/context/software/application-software/argos/layouts/argos-dev/manifest.yml b/context/software/application-software/argos/layouts/argos-dev/manifest.yml index e4857c5..e0182fb 100644 --- a/context/software/application-software/argos/layouts/argos-dev/manifest.yml +++ b/context/software/application-software/argos/layouts/argos-dev/manifest.yml @@ -4,17 +4,18 @@ instructions: - harness/instructions/workspace.md - software/harness/instructions/base.md - software/application-software/argos/harness/instructions/argos.md -docs: +sync: - software/application-software/argos/docs/CONTEXT.md -skills: - software/application-software/argos/harness/skills/grill-with-docs - software/application-software/argos/harness/skills/to-spec - software/application-software/argos/harness/skills/to-tickets - software/application-software/argos/harness/skills/implement - software/application-software/argos/harness/skills/code-review - software/application-software/argos/harness/skills/commit - - software/application-software/argos/harness/skills/open-pr.skill - - software/application-software/argos/harness/skills/update-pr.skill + - software/application-software/argos/harness/skills/open-pr/SKILL.md + - software/application-software/argos/blocks/pr-body.md -> .claude/skills/open-pr/pr-body.md + - software/application-software/argos/harness/skills/update-pr/SKILL.md + - software/application-software/argos/blocks/pr-body.md -> .claude/skills/update-pr/pr-body.md - software/application-software/argos/harness/skills/run-local - software/application-software/argos/harness/skills/new-worktree repos: diff --git a/context/software/application-software/argos/scope.yml b/context/software/application-software/argos/scope.yml index df18e9a..57829b1 100644 --- a/context/software/application-software/argos/scope.yml +++ b/context/software/application-software/argos/scope.yml @@ -5,5 +5,5 @@ recommend: - software/application-software/argos/harness/skills/to-tickets - software/application-software/argos/harness/skills/implement - software/application-software/argos/harness/skills/commit - - software/application-software/argos/harness/skills/open-pr.skill + - software/application-software/argos/harness/skills/open-pr - software/application-software/argos/harness/skills/run-local diff --git a/dev/sandbox.sh b/dev/sandbox.sh deleted file mode 100755 index b970a7f..0000000 --- a/dev/sandbox.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env bash -# dev/sandbox.sh — throwaway playground for trying Delphi end to end without GitHub. -# -# eval "$(dev/sandbox.sh)" # prints exports; then use: d … -# -# Creates, in a fresh temp dir ($SB): -# origin.git bare "remote" for Delphi (stands in for GitHub) -# bin/gh stub gh: logs to gh.log, remembers PRs in gh.prs (so --yes works end to end) -# Delphi/ clone with this checkout's bin/ lib/ delphi.conf moves.tsv + sample content -# argos.git bare code repo referenced by the sample layout -# Delphi-workspaces/ where workspaces land (the default ../Delphi-workspaces) -# `d` runs the sandbox CLI under /bin/bash (macOS bash 3.2) to catch bash-4-isms. -set -euo pipefail - -here=$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -SB=${1:-$(mktemp -d "${TMPDIR:-/tmp}/delphi-sb.XXXXXX")} -mkdir -p "$SB" -SB=$(cd -P "$SB" && pwd) - -git init -q --bare -b main "$SB/origin.git" -git clone -q "$SB/origin.git" "$SB/Delphi" 2>/dev/null -cp -R "$here/bin" "$here/lib" "$SB/Delphi/" -cp "$here/delphi.conf" "$here/moves.tsv" "$SB/Delphi/" - -C="$SB/Delphi/context" -A="$C/software/application-software/argos" -mkdir -p "$C/software/harness/instructions" "$C/software/harness/mcp" \ - "$A/blocks" "$A/harness/instructions" "$A/harness/skills/run-tests" "$A/layouts/argos-dev" - -printf 'name: NER\n' > "$C/scope.yml" -printf 'name: Software\nrecommend:\n - software/harness/instructions/base.md\n' > "$C/software/scope.yml" -printf 'name: Application Software\n' > "$C/software/application-software/scope.yml" -cat > "$A/scope.yml" <<'EOF' -name: Argos -recommend: - - software/application-software/argos/blocks/overview.md - - software/application-software/argos/harness/skills/run-tests -EOF -printf '# Software conventions\n\n- Use conventional commits.\n- Open PRs against develop.\n' \ - > "$C/software/harness/instructions/base.md" -printf '"github": {\n "command": "gh-mcp",\n "args": []\n}\n' > "$C/software/harness/mcp/github.json" -printf '# Argos\n\nArgos is the telemetry dashboard.\nIt has an Angular client and a Rust server.\n' \ - > "$A/harness/instructions/argos.md" -printf '# Argos overview\n\nLine one.\nLine two.\nLine three.\n' > "$A/blocks/overview.md" -printf '# Testing\n\nRun `npm test` in angular-client.\nRun `cargo test` in the server.\n' > "$A/blocks/testing.md" -printf -- '---\nname: run-tests\ndescription: Run the Argos test suites.\n---\n\nRun both suites and summarize failures.\n' \ - > "$A/harness/skills/run-tests/SKILL.md" -mkdir -p "$A/harness/skills/run-tests/scripts" -printf '#!/bin/sh\necho running tests\n' > "$A/harness/skills/run-tests/scripts/run.sh" -chmod +x "$A/harness/skills/run-tests/scripts/run.sh" -cat > "$A/harness/skills/triage.skill" <<'EOF' -name: triage -description: Triage a failing Argos test. -body: - - software/application-software/argos/blocks/testing.md -EOF - -git init -q -b main "$SB/argos-src" -printf '# Argos code\n' > "$SB/argos-src/README.md" -git -C "$SB/argos-src" add -A && git -C "$SB/argos-src" commit -qm init -git clone -q --bare "$SB/argos-src" "$SB/argos.git" - -cat > "$A/layouts/argos-dev/manifest.yml" </dev/null -git -C "$SB/Delphi" branch -q -u origin/main 2>/dev/null || true - -mkdir -p "$SB/bin"; : > "$SB/gh.prs" -{ printf '#!/bin/sh\nSB=%q\n' "$SB"; cat <<'EOF'; } > "$SB/bin/gh" -# stub gh for the Delphi sandbox: never contacts GitHub -echo "gh $*" >> "$SB/gh.log" -n=$(awk -v b="$4" '$1 == b { print $2 }' "$SB/gh.prs") # args: pr list|create --head … -case "$1 $2" in - "api user") echo sandbox-user ;; - "pr list") [ -z "$n" ] || { [ "$8" = author ] && echo sandbox-user || echo "$n"; } ;; - "pr create") n=$(($(wc -l < "$SB/gh.prs") + 1)); echo "$4 $n" >> "$SB/gh.prs"; echo "https://github.invalid/pr/$n" ;; -esac -exit 0 -EOF -chmod +x "$SB/bin/gh" - -cat < [-> ]`. Source is a file or directory under `context/`; a directory maps every file under it (recursive, 1:1, executable bit kept). -- Default dest (when `->` is omitted), by source path: - `…/harness/skills/[/…]` → `/[/…]`; `…/docs/` → `docs/`; +- Default dest (when `->` is omitted), by source path (first match of the path component): + `…/harness/skills/[/…]` → `/[/…]`; `…/docs[/]` → `docs[/]`; anything else → `context/`. +- Dests are unique and never nested (no dest inside another entry's dest, including the + instruction file and the layout's `files/`), and never under `.git/`, `.delphi/`, `repos/`, or + `worktrees/`. To put an extra file into a synced skill folder, sync the skill's files + individually (e.g. `…/skills/open-pr/SKILL.md` plus `…/pr-body.md -> .claude/skills/open-pr/pr-body.md`). - `instructions` builds the harness instruction file (e.g. `CLAUDE.md`) from parts, blank line between. It is **generated**: edits to it are not proposed (sync a part to edit it). A layout uses either `instructions` or its own `files/`, not both. @@ -58,15 +62,18 @@ Bookkeeping in `.git/delphi/` (never in the working tree). ``` # dest source kind -CLAUDE.md - assembled +CLAUDE.md software/harness/instructions/base.md assembled (one row per part) .claude/skills/run-local/SKILL.md software/…/harness/skills/run-local/SKILL.md sync CLAUDE.md software/…/layouts/argos-dev/files/CLAUDE.md layout (when not assembled) +.claude/settings.json software/harness/settings/claude.json copy (listed only) .delphi/manifest.yml software/…/layouts/argos-dev/manifest.yml layout ``` -Copied files are **not** part of the compile. They are committed to `working` directly -(`delphi: copy …`) when first listed, and recorded in `.git/delphi/copied` -(`destsourcedelphi-commit`). They are never overwritten or proposed. +Copied files are **not** part of the compile (the lock lists them so refresh knows what to copy). +They are committed to `working` directly (`delphi: copy …`) when first listed, and recorded in +`.git/delphi/copied` (`destsourcedelphi-commit`, the Delphi commit copied from). A dest +that already exists is left alone (warned, recorded). Copies are never overwritten or proposed, +and they never count as pending changes; `diff` lists a copy you edited as local. ## 4. Commands @@ -79,11 +86,16 @@ Copied files are **not** part of the compile. They are committed to `working` di | `workspace propose [ws] [--dry-run]` | refresh, route (below), one PR per workspace (force-updated) | | `workspace status` | one row per workspace: dirty, clean/proposed/unproposed, behind | | `layout new --from ` / `layout list` | layout PR / list layouts | -| `block mv ` | move a source, log it in `moves.tsv`, rewrite manifests; workspaces follow on refresh | +| `block mv ` | move a source (any path under `context/` except layouts and `scope.yml`), log it in `moves.tsv`, rewrite manifests and scope recommendations; workspaces follow on refresh | | `check` | validate the repo (below) | | `setup [dir]` | remember where the Delphi checkout is | -`propose --dry-run` prints the same listing as `workspace diff`. +`propose --dry-run` prints the same listing as `workspace diff` (stdout), one line per changed +file: `update|new|manifest -> `, `noop|local ()`, then +`Unresolved:` with `: `. `diff --upstream` prints +` <- (: )` per changed source. `status` counts a workspace +as clean when nothing proposable is pending (local files don't count). `propose` makes one commit +on the propose branch. ## 5. What changed, and where it goes (routing) @@ -92,20 +104,24 @@ Pending diff = `generated-merged..HEAD` (committed changes only). Per changed fi | Change | Result | |---|---| | synced or layout file, modified (incl. binary, mode) | copy the file over its source | -| synced or layout file, deleted | no-op if its entry was removed from `.delphi/manifest.yml`, else unresolved | +| synced file, deleted | no-op if its entry was removed from `.delphi/manifest.yml`, else unresolved | +| layout file, deleted | unresolved (remove it from the layout's `files/` in Delphi) | | new file inside a synced **directory** | new file in that source directory | | new `sync` entry in `.delphi/manifest.yml` whose source doesn't exist yet | source created from the workspace file(s) | +| new file whose source already exists in Delphi | unresolved (it arrives on refresh once the entry merges) | +| symlink, type change, other `.delphi/` files | unresolved | | `.delphi/manifest.yml` modified | replaces the layout manifest | -| assembled instruction file | unresolved ("generated; sync a part to edit it") | +| assembled instruction file | unresolved ("generated; sync a part to edit it"); deleting it is a no-op if `instructions` was dropped | | anything else (copies, new files elsewhere) | **local**: listed, never proposed | One source synced to several dests: identical edits route once; differing edits are unresolved. Unresolved items are listed in the PR body; they never block it. **Shared awareness.** `diff`, `propose`, and `refresh` tag a synced file **shared** when other -layouts on `origin/main` also use its source (`shared: argos-dev, nero-dev`), so you know an edit -reaches other teams. Refresh lists synced files updated from Delphi with the last author and -commit subject. +layouts on `origin/main` also use its source in `instructions`, `sync`, or `copy` +(`shared: argos-dev, nero-dev`), so you know an edit reaches other teams. Refresh lists synced +files updated from Delphi with the last author and commit subject (`updated (: +)`, `removed `, `copied `). ## 6. Unchanged from v1 @@ -125,7 +141,8 @@ MCP fragment assembly (MCP/settings are ordinary synced or copied files), manife ## 8. check -Scopes have `scope.yml`; every `.yml` parses; manifests: `name` = directory and unique, adapter +Scopes have `scope.yml`; Delphi's own YAML (`scope.yml`, layout manifests) parses (other `.yml` +files are content and may use full YAML); scope `recommend` paths exist; manifests: `name` = directory and unique, adapter exists, only known keys, entries well-formed, sources exist, dests safe and unique (no dest inside another entry's dest), not both `instructions` and a `files/` instruction file; no file named after an instruction file under `context/` except inside `layouts/*/files/`; `moves.tsv` rows diff --git a/docs/goals.md b/docs/goals.md index 7e0cb38..fb3b85d 100644 --- a/docs/goals.md +++ b/docs/goals.md @@ -1,7 +1,7 @@ # Delphi CLI — Goals What the CLI must achieve, not how. Any rewrite or simplification must keep these. -Details live in the spec (`context/docs/delphi-design.md`). +Details live in the spec (`docs/design.md`). ## Purpose diff --git a/lib/block.sh b/lib/block.sh deleted file mode 100644 index 7341e0b..0000000 --- a/lib/block.sh +++ /dev/null @@ -1,39 +0,0 @@ -# block.sh — `delphi block mv `: move a block, log it in moves.tsv, rewrite references. -. "$DELPHI_ROOT/lib/parse.sh" -. "$DELPHI_ROOT/lib/provenance.sh" -. "$DELPHI_ROOT/lib/pr.sh" -. "$DELPHI_ROOT/lib/check.sh" - -block_main() { - local verb=${1:-} - [ $# -gt 0 ] && shift - parse_args "--model --effort --yes" "$@"; eval "set -- $ARGS" - [ "$verb" = mv ] && [ $# -eq 2 ] || die "usage: delphi block mv " - block_mv "${1%/}" "${2%/}" -} - -block_mv() { - local old=$1 new=$2 p src dst f row - for p in "$old" "$new"; do - path_ok "$p" || die "unsafe path: '$p'" - case "/$p" in */blocks/?*|*/docs/?*|*/harness/?*) ;; *) die "'$p' is not under a scope's blocks/, docs/, or harness/" ;; esac - done - delphi_fetch - p=$(delphi_commit main) || exit 1 - provenance_resolve "$OPT_MODEL" "$OPT_EFFORT" claude-code - pr_begin "delphi/mv/${old##*/}-$(date +%Y%m%d)" "$p" - src=$(safe_path "$PR_WT/context" "$old") || exit 1 - dst=$(safe_path "$PR_WT/context" "$new") || exit 1 - [ -e "$src" ] || die "no such block on origin/main: $old" - [ -e "$dst" ] && die "already exists on origin/main: $new" - mkdir -p "$(dirname "$dst")" && git -C "$PR_WT" mv "context/$old" "context/$new" || die "git mv failed" - make_tmp; row="$REPLY/row" - printf '%s\t%s\t%s\n' "$old" "$new" "$(date +%Y-%m-%d)" | tee -a "$PR_WT/moves.tsv" > "$row" - while IFS= read -r f; do rewrite_moves "$row" "$f" || true; done < $new" || die "nothing to commit" - pr_finish "delphi: move $old -> $new" "Moves \`$old\` to \`$new\` and rewrites references. Workspaces follow on their next refresh." - printf '%s\n' "$PR_BRANCH" -} diff --git a/lib/check.sh b/lib/check.sh deleted file mode 100644 index 9ba5048..0000000 --- a/lib/check.sh +++ /dev/null @@ -1,148 +0,0 @@ -# check.sh — repo-wide validation. `check_tree ` prints every violation, returns 1 if any. -# Layouts are validated by compiling them; the rules here cover what compile doesn't enforce. -. "$DELPHI_ROOT/lib/parse.sh" -. "$DELPHI_ROOT/lib/compile.sh" - -check_main() { - [ $# -eq 0 ] || die "usage: delphi check" - if check_tree "$DELPHI_ROOT"; then info "check: ok"; else exit 1; fi -} - -_ce() { printf '%s\n' "$*" >> "$_CHECK_ERRS"; } - -# _ck_entry : placement rule for the kind (instructions|skills|mcp| -# settings|body); `body` (skill specs) and `any` (recommendations) must also exist. -_ck_entry() { - local f=$1 key=$2 p=$3 want='*' - path_ok "${p%/\*}" || { _ce "$f: $key: unsafe path '$p'"; return 0; } - case $4 in - instructions) want='*/harness/instructions/*' ;; skills) want='*/harness/skills/*' ;; - mcp) want='*/harness/mcp/*.json' ;; settings) want='*/harness/settings/*' ;; body) want='*/blocks/*' ;; - esac - case "/$p" in $want) ;; *) _ce "$f: $key: '$p' is not under ${want#\*/}" ;; esac - case $4 in body|any) [ -e "$_CK_ROOT/context/$p" ] || _ce "$f: $key: missing '$p'" ;; esac - return 0 -} - -check_tree() { - _CK_ROOT=$1 - local ctx="$1/context" f rel recs k p n names dup name hfile h i=0 - make_tmp; _CHECK_ERRS="$REPLY/errs"; : > "$_CHECK_ERRS" - [ -d "$ctx" ] || { _ce "missing context/ directory"; cat "$_CHECK_ERRS" >&2; return 1; } - - # scopes: every non-reserved directory needs scope.yml - while IFS= read -r d; do - [ -f "$d/scope.yml" ] || _ce "${d#$1/}: scope directory has no scope.yml" - for f in "$d"/*; do - [ -f "$f" ] && [ "${f##*/}" != scope.yml ] && _ce "${f#$1/}: stray file (scopes hold only scope.yml, blocks/, docs/, harness/, layouts/, child scopes)" - done - done < /dev/null 2>> "$_CHECK_ERRS" || true - done </dev/null) || continue - while IFS= read -r p; do [ -n "$p" ] && _ck_entry "${f#$1/}" recommend "$p" any; done </dev/null) || continue - [ -n "$(yaml_get "$recs" name)" ] || _ce "$rel: missing name" - [ -n "$(yaml_get "$recs" description)" ] || _ce "$rel: missing description" - for k in $(yaml_keys "$recs"); do - case $k in name|description|body|references) ;; *) _ce "$rel: unknown key '$k'" ;; esac - done - while IFS= read -r p; do [ -n "$p" ] && _ck_entry "$rel" body "$p" body; done </dev/null) || continue - name=$(yaml_get "$recs" name) - dup=${f%/manifest.yml}; dup=${dup##*/} - [ -n "$name" ] || _ce "$rel: missing name" - [ "$name" = "$dup" ] || _ce "$rel: name '$name' must equal its directory '$dup'" - in_list "$name" "$names" && _ce "$rel: layout name '$name' is not unique" - names="$names -$name" - h=$(yaml_get "$recs" harness) - if [ -z "$h" ]; then _ce "$rel: missing harness" - elif [ ! -f "$DELPHI_ROOT/lib/harness/$h.sh" ]; then _ce "$rel: unknown harness '$h'" - else # compile it: catches missing paths, empty globs, duplicate outputs, bad skill specs - i=$((i + 1)); mkdir "$_CHECK_ERRS.$i" - ( compile "$1" "$(dirname "${rel#context/}")" "$_CHECK_ERRS.$i" ) 2>&1 > /dev/null | sed "s#^delphi: #$rel: #" >> "$_CHECK_ERRS" || true - fi - for k in $(yaml_keys "$recs"); do - case $k in name|harness|instructions|blocks|docs|skills|mcp|settings|repos) ;; *) _ce "$rel: unknown key '$k'" ;; esac - done - n=$(yaml_list "$recs" settings | awk 'NF' | awk 'END { print NR }') - [ "$n" -le 1 ] || _ce "$rel: at most one settings file" - for k in instructions skills mcp settings; do - while IFS= read -r p; do [ -n "$p" ] && _ck_entry "$rel" "$k" "$p" "$k"; done <newdate\n", NR }' "$1/moves.tsv" >> "$_CHECK_ERRS" - - # MCP fragments are valid JSON (only if jq is installed) - if command -v jq > /dev/null 2>&1; then - while IFS= read -r f; do - [ -z "$f" ] && continue - { printf '{'; cat "$f"; printf '}'; } | jq empty > /dev/null 2>&1 || _ce "${f#$1/}: not a valid mcpServers member" - done <&2; return 1; fi - return 0 -} diff --git a/lib/compile.sh b/lib/compile.sh deleted file mode 100644 index 7fabfef..0000000 --- a/lib/compile.sh +++ /dev/null @@ -1,182 +0,0 @@ -# compile.sh — layout -> workspace files + .delphi/lock.tsv (segment map). Deterministic. -# -# compile -# a checked-out Delphi tree (usually a temp worktree at a specific commit) -# layout directory relative to context/ (…/layouts/) -# empty directory to write into -# Requires parse.sh. Sets R_HARNESS to the layout's harness name. - -_r_count() { if [ -f "$1" ]; then wc -l < "$1" | tr -d ' '; else echo 0; fi; } - -# _r_seg -_r_seg() { printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" >> "$R_LOCK"; } - -# _r_file : append a source file (relative to context/) to an output file. -_r_file() { - local out="$R_OUT/$1" src start end - src=$(safe_path "$R_SRC/context" "$2") || exit 1 - [ -f "$src" ] || die "compile: missing file context/$2" - [ -s "$src" ] || die "compile: empty file context/$2" - mkdir -p "$(dirname "$out")" - start=$(( $(_r_count "$out") + 1 )) - cat "$src" >> "$out" - [ -n "$(tail -c 1 "$src")" ] && printf '\n' >> "$out" - end=$(_r_count "$out") - _r_seg "$1" "$start" "$end" "$2" "$(git hash-object "$src")" -} - -# _r_copy : 1:1 copy (keeps the executable bit); the output path must not already exist. -_r_copy() { - [ -e "$R_OUT/$1" ] && die "compile: two sources map to the same output '$1'" - _r_file "$1" "$2" - if [ -x "$R_SRC/context/$2" ]; then chmod +x "$R_OUT/$1" || die "compile: cannot chmod $1"; fi -} - -# _r_text