muse-crew 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +5 -3
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +13 -0
- package/docs/state-boundary-spec.md +78 -0
- package/lib/AGENTS.md +6 -2
- package/lib/advance-publish-base.js +250 -0
- package/lib/classify-surface.js +109 -0
- package/lib/commit-scaffold.js +121 -0
- package/lib/compose-evidence-caption.js +55 -7
- package/lib/compute-publish-diff.js +145 -0
- package/lib/crew-api.js +34 -3
- package/lib/setup-project-repo.js +57 -11
- package/package.json +1 -1
- package/seed/cron-body-template.md +5 -4
- package/workflows/bugfix.js +58 -65
- package/workflows/chore.js +58 -65
- package/workflows/crew-dispatch.js +21 -6
- package/workflows/crew-init.js +113 -58
- package/workflows/standard.js +58 -65
package/API.md
CHANGED
|
@@ -385,13 +385,15 @@ Register a new project.
|
|
|
385
385
|
| `id` | slug | yes | Unique project identifier |
|
|
386
386
|
| `display_name` | string (1–120) | yes | |
|
|
387
387
|
| `repo_path` | string (1–1000) | yes | Absolute path to the project's Git repo |
|
|
388
|
-
| `deploy_type` | `npm` · `artifact` · `vercel` · empty string | yes | Publish target: `npm` publishes the package to the npm registry, `artifact` rebuilds the live artifact, `vercel` is not yet implemented (blocks the task), empty skips the Publish phase |
|
|
389
|
-
| `deploy_slug` | slug or null | no | Required when `deploy_type` is `artifact` |
|
|
388
|
+
| `deploy_type` | `npm` · `artifact` · `vercel` · empty string | yes | Publish target: `npm` publishes the package to the npm registry, `artifact` rebuilds the live artifact, `vercel` is not yet implemented (blocks the task), empty skips the Publish phase. When omitted and the classified surface is `artifact`, defaults to `artifact` (see surface classification below). |
|
|
389
|
+
| `deploy_slug` | slug or null | no | Required when `deploy_type` is `artifact`. When `deploy_type` is auto-defaulted to `artifact`, filled from the repo's `space.json` slug when present; explicitly chosen `deploy_type`/`deploy_slug` values are never overwritten. |
|
|
390
390
|
| `description` | string (≤ 3000) | no | |
|
|
391
391
|
| `simultaneity` | integer (1–100) | no | Max concurrent tasks; defaults to 2 |
|
|
392
392
|
| `quiesced` | boolean | no | Start paused; defaults to false |
|
|
393
393
|
| `visual_protocol` | boolean or null | no | Tri-state: `null` = inherit crew default (off), `true` = enable for this project, `false` = explicit off. Defaults to null. |
|
|
394
|
-
| `environment_type` | `artifact` · `terminal` · null | no | User-facing surface for experiential QA routing: `artifact` = a rendered web UI (Hazel drives it with the see-act browser loop), `terminal` = a CLI (Hazel drives it herself, keeping transcripts). `null` = unclassified: no experiential QA. `deploy_type` names the deployment target, but `deploy_type: "artifact"` remains a legacy artifact-surface signal so pre-field projects keep today's experiential QA (the migration does not backfill the column); on conflicting config artifact wins.
|
|
394
|
+
| `environment_type` | `artifact` · `terminal` · null | no | User-facing surface for experiential QA routing: `artifact` = a rendered web UI (Hazel drives it with the see-act browser loop), `terminal` = a CLI (Hazel drives it herself, keeping transcripts). `null` = unclassified: no experiential QA. `deploy_type` names the deployment target, but `deploy_type: "artifact"` remains a legacy artifact-surface signal so pre-field projects keep today's experiential QA (the migration does not backfill the column); on conflicting config artifact wins. Omitted means auto-classify from `repo_path` (see surface classification below); an explicit value, including explicit `null`, always wins. |
|
|
395
|
+
|
|
396
|
+
**Surface classification (repo-derived defaults):** when `repo_path` is provided and `environment_type` is omitted, `create-project` mechanically classifies the repo at registration via `lib/classify-surface.js` — `space.json` present → `artifact`; else a non-empty `package.json` `bin` → `terminal`; otherwise unclassified (`null`). The classifier never throws: bad paths and malformed files classify as unclassified. Classification only fills omitted fields — an explicitly supplied `environment_type` (including explicit `null`) always wins, and deploy defaults follow the *effective* surface, so an explicit `"terminal"` never triggers artifact deploy defaults.
|
|
395
397
|
|
|
396
398
|
### `updateproject`
|
|
397
399
|
|
package/docs/guide.md
CHANGED
|
@@ -261,7 +261,7 @@ Every 15 minutes, the `crew-poll` cron fires:
|
|
|
261
261
|
|
|
262
262
|
7. **Task completes** — when all phases finish, the dispatcher marks the task `done` on the next tick.
|
|
263
263
|
|
|
264
|
-
Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch.
|
|
264
|
+
Every tick also records its own release identity: right after acknowledging the poll, the dispatcher appends one line — `{"seq": <n>, "release": "<active release id>"}` — to `$CREW_HOME/.tick-releases.jsonl`. `release` is the active release (the basename of the crew home's `current` symlink, the same resolution the Crew API uses), and `seq` is the file's non-empty line count plus one; the file is append-only and never rewritten. Ordering is the proof, not timestamps — deliberately, since workflow scripts may not read the clock. For the self-upgrade proof, a tick whose line names the new release is demonstrably running the newly built code. A failed write never fails the tick: the writer swallows its own errors, carries no schema, and is wrapped in try/catch. The writer echoes the appended line to stdout (2026-09-17, rooms #12–#14): the fire-and-forget agent call carries no schema, and the platform's JSON-candidate scan throws on an empty schema-less result — so the writer speaks the same non-empty shape as every other fire-and-forget call in the dispatcher.
|
|
265
265
|
|
|
266
266
|
### Failure handling and retry
|
|
267
267
|
|
|
@@ -106,6 +106,19 @@ request builder (`lib/build-readback-request.js`, `--base`) use the identical
|
|
|
106
106
|
base: the previously-stamped provenance, or the empty tree for a genuine
|
|
107
107
|
first publish. Request builder and verifier never disagree on the base.
|
|
108
108
|
|
|
109
|
+
**Diff transport (room #14, 2026-09-17):** the diff is computed by the
|
|
110
|
+
deterministic `lib/compute-publish-diff.js` (pinned per-run alongside the
|
|
111
|
+
other lifecycle files), which writes the raw diff bytes to
|
|
112
|
+
`$CREW_HOME/.publish-diffs/<taskId>[-rN].diff` and returns only a small JSON
|
|
113
|
+
summary (`sha256`, `changed_lines` = added+removed, `has_binary`,
|
|
114
|
+
`has_rename`). The LLM never carries diff bytes — an earlier shape asked an
|
|
115
|
+
agent to return the raw diff as a JSON string field, and the JSON ferry
|
|
116
|
+
dropped a valid 700-line diff ("Publish diff parsed to zero files"). The
|
|
117
|
+
trigger agent verifies the file with `sha256sum` before any `artifact_edit`
|
|
118
|
+
call and pastes the verified content into the edit. The 200 changed-line
|
|
119
|
+
budget counts added+removed lines from the summary, never raw diff output
|
|
120
|
+
lines (context lines inflated the old count ~2x).
|
|
121
|
+
|
|
109
122
|
## The shape: code detects, the tick ferries, code certifies
|
|
110
123
|
|
|
111
124
|
A standalone verification workflow cannot work with the async inspection
|
|
@@ -141,3 +141,81 @@ New `tests/gitignore.test.js` and `tests/repo-orchestration.test.js`:
|
|
|
141
141
|
no approval to change).
|
|
142
142
|
- Does not add new API endpoints.
|
|
143
143
|
- Does not change Room #12 or any existing crew's behavior.
|
|
144
|
+
|
|
145
|
+
## 8. Setup commits its scaffold (0.11.1)
|
|
146
|
+
|
|
147
|
+
**Defect (room #13, 2026-09-17):** 0.11.0's setup wrote `.orchestration/`
|
|
148
|
+
(and the consented `.gitignore` update) but never committed them. The
|
|
149
|
+
first task's Integrate then refused to merge onto the dirty main — twice,
|
|
150
|
+
deterministically. An uncommitted scaffold deterministically breaks the
|
|
151
|
+
first task after setup.
|
|
152
|
+
|
|
153
|
+
**Contract:** setup leaves the repo's main checkout clean. After the
|
|
154
|
+
scaffold + `.gitignore` steps, `lib/commit-scaffold.js` commits exactly
|
|
155
|
+
the crew's paths (`.orchestration/`, plus `.gitignore` when the setup step
|
|
156
|
+
changed it under consent). The commit message names what was staged,
|
|
157
|
+
computed from the index.
|
|
158
|
+
|
|
159
|
+
**Rules:**
|
|
160
|
+
- Only the crew's paths are ever staged. The user's unrelated changes
|
|
161
|
+
(tracked modifications, untracked files) are never swept in.
|
|
162
|
+
- Idempotent: nothing new staged → no commit, `{ committed: false,
|
|
163
|
+
reason: "nothing-to-commit" }`. Never an empty commit.
|
|
164
|
+
- Missing `.orchestration/` → throw (the scaffold step did not run).
|
|
165
|
+
- Commit failures (e.g. no git identity) throw loudly with git's stderr.
|
|
166
|
+
No identity is ever invented — the commit belongs to the user; the
|
|
167
|
+
caller configures identity and re-runs.
|
|
168
|
+
- With `--consent`, `.gitignore` must be clean before setup touches it:
|
|
169
|
+
a pre-existing dirty `.gitignore` fails setup loudly (refuse — never
|
|
170
|
+
sweep the user's edits into the setup commit). `.orchestration/` is
|
|
171
|
+
crew-owned: pre-existing uncommitted content there is committed, not
|
|
172
|
+
refused (this also repairs 0.11.0 rooms).
|
|
173
|
+
- `lib/setup-project-repo.js` (the composer) runs all three steps and
|
|
174
|
+
exits non-zero on any failure. crew-init's dashboard mode calls the
|
|
175
|
+
composer (one agent call, replacing the old two-call shape); a failed
|
|
176
|
+
commit is logged loudly and surfaced as `repoSetupWarning` in the init
|
|
177
|
+
summary, since the first task will fail Integrate until the scaffold
|
|
178
|
+
is committed.
|
|
179
|
+
|
|
180
|
+
**Tests:** `tests/commit-scaffold.test.js` (behavioral: clean tree after
|
|
181
|
+
setup, accurate message, idempotency, non-repo no-op, missing scaffold
|
|
182
|
+
throws, missing identity throws, composer refusal on dirty `.gitignore`,
|
|
183
|
+
declined consent leaves `.gitignore` untouched; structural: crew-init
|
|
184
|
+
calls the composer).
|
|
185
|
+
|
|
186
|
+
## 9. Publish base advances past the setup scaffold (0.11.2)
|
|
187
|
+
|
|
188
|
+
**Defect (room #14, 2026-09-17):** `create-project` stamps
|
|
189
|
+
provenance `source_commit` at the pre-scaffold repo HEAD; the scaffold
|
|
190
|
+
commit lands on top. The first task's publish diff then carries the whole
|
|
191
|
+
scaffold (517 insertions in room #14) and blows the 200 changed-line
|
|
192
|
+
diff-transport budget. The scaffold is crew-owned infrastructure the
|
|
193
|
+
dashboard artifact never consumes, so the correct publish base is the
|
|
194
|
+
scaffold commit itself.
|
|
195
|
+
|
|
196
|
+
**Contract:** after the scaffold commit, crew-init runs
|
|
197
|
+
`lib/advance-publish-base.js`, which moves `source_commit` to the scaffold
|
|
198
|
+
commit only when every condition proves safety:
|
|
199
|
+
|
|
200
|
+
- this init created the project (`--project-created`): the stamp is ours,
|
|
201
|
+
never a real publish's;
|
|
202
|
+
- the scaffold commit's parent is exactly the stamped base: nothing (an
|
|
203
|
+
un-published task commit, a human push) slipped between the stamp and
|
|
204
|
+
the scaffold. An intermediate commit skips — the base still covers the
|
|
205
|
+
un-published work;
|
|
206
|
+
- the scaffold commit touches only crew-owned paths (`.orchestration/`,
|
|
207
|
+
`.gitignore`): a foreign path skips, so user content is never dropped
|
|
208
|
+
from a publish diff;
|
|
209
|
+
- no provenance + root scaffold commit → stamp fresh (the empty-repo-at-
|
|
210
|
+
create case); no provenance + non-root scaffold → skip (unverified
|
|
211
|
+
history is not claimed).
|
|
212
|
+
|
|
213
|
+
Every other case skips without touching provenance. A base-pointer move is
|
|
214
|
+
not a publish: `published_at` is preserved, and the result is read back
|
|
215
|
+
and verified exact before returning. The call is best-effort from
|
|
216
|
+
crew-init: an advance failure is logged loudly and init continues with the
|
|
217
|
+
stamped base.
|
|
218
|
+
|
|
219
|
+
**Tests:** `tests/advance-publish-base.test.js` (behavioral: advance,
|
|
220
|
+
idempotency, all fail-closed skips, fresh stamp, usage errors; structural:
|
|
221
|
+
crew-init wiring).
|
package/lib/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
14
14
|
- `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
|
|
15
15
|
- `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
|
|
16
16
|
- `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
|
|
17
|
-
- `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the QA verdict, the `publish: verified` commit (or the global provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all
|
|
17
|
+
- `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the QA verdict, the `publish: verified` commit (or the global provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all, the newest `baseline:` note is `baseline: none`, AND captures exist (a `baseline: none` note means no baseline captures to compare against — it never overrules a real QA verdict; the note's premise is the post-deploy capture, so without captures it would fabricate evidence again). Capture guard (2026-09-17, room #14): the composer resolves the audit dir itself (`<home>/workspace/ts-spaces/<deploy_slug>/audits/<auditDir>/`, the same root crew-api.js and readback-disk.js resolve) and verifies `screenshot.png` + `screenshot-mobile.png` are files before claiming "desktop + mobile captures below" — a bare `--audit-dir` name is never trusted. Missing deploy_slug, missing dir, or missing PNGs gets the honest no-captures opener and Evidence line (terminal surfaces named explicitly: "terminal surface — QA judges the CLI, not pixels"); the caller only appends attachment lines when its own freshness check passes. "Latest" is by event timestamp, sorted explicitly. Exits non-zero on missing input.
|
|
18
18
|
- `edit-image.py` — deterministic pixel-level evidence editing (2026-09-15, Pillow): `crop --region x,y,w,h` (pixel-exact, rejects out-of-bounds), `zoom --factor <f> [--center x,y]` (nearest-neighbor, returns to source frame size), `label --text <caption>` (caption bar), `nup --cols <n> --in <a.png> --in <b.png> [--labels "a|b"]` (side-by-side grid). Byte-deterministic PNG output; no network, no time, no randomness. Derivatives supplement raw frames — the source frame stays archived.
|
|
19
19
|
- `render-html.js` — deterministic HTML evidence composition (2026-09-15, headless Chromium): renders a local HTML layout to PNG (fixed viewport width, device scale 1, full-page screenshot). Hermetic: remote HTTP(S) assets are blocked and fail the render loudly; relative image paths resolve against the HTML file; local/system fonts only. Exit 3 reports NOT POSSIBLE when Chromium is unavailable. The composition layer above edit-image.py's pixel layer.
|
|
20
20
|
- `see-act.js` — single-step browser driver for experiential QA (2026-09-14): one browser action per invocation (`aria`, `shot`, `click`, `scroll`, `type`), one JSON line on stdout, exit 0/2/3 (3 = NOT POSSIBLE: missing playwright-core or Chromium). Spawns its own loopback forward proxy on an ephemeral port (Chromium blocks direct loopback). The QA work agent closes the OODA loop: run a step, read the screenshot/aria, decide the next. Determinism: no wall-clock reads, no randomness. `SEE_ACT_ARCHIVE_DIR=<phase-dir>` (2026-09-14): every screenshot is archived automatically as `001-shot-desktop.png`, `002-click-mobile.png`, ... (counter in `<dir>/.seq`); `--out` becomes optional; JSON carries `screenshot` + `archived`; explicit `--out` + archive copies into the archive; unusable dir is NOT POSSIBLE exit 3.
|
|
@@ -29,5 +29,9 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
29
29
|
- `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
|
|
30
30
|
- `gitignore.js` — deterministic .gitignore management for crew-owned paths (2026-09-17): the crew touches exactly one user-owned file outside `.orchestration/` — the repo's `.gitignore`. `ensureGitignoreEntries(repoPath, entries)` creates the file when missing, appends missing entries (exact line match, no duplicates), preserves existing content byte-for-byte, and is idempotent. `describeGitignoreChange(repoPath, entries)` renders the exact diff for the setup consent conversation. Crew-owned entries: `.worktrees/`, `.orchestration/user/`. CLI: `--repo <path> [--dry-run]`.
|
|
31
31
|
- `repo-orchestration.js` — repository-local `.orchestration/` scaffold (2026-09-17): `scaffoldRepoOrchestration(repoPath, crewRepoPath)` creates `$REPO/.orchestration/{workflows,identities,phases,user}/`, seeds workflows/identities/phases from the crew repo's platform defaults with no-clobber semantics (existing project customizations never overwritten), and writes a README in `user/` explaining it's for local config. Idempotent. CLI: `--repo <path> --crew-repo <path>`.
|
|
32
|
-
- `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`). The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]
|
|
32
|
+
- `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's main checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
|
|
33
|
+
- `commit-scaffold.js` — deterministic setup-scaffold commit (2026-09-18): stages only `.orchestration/` (+ `.gitignore` with `--gitignore`) and commits with a message naming exactly what was staged. Idempotent (`nothing-to-commit`, never an empty commit); not-a-git-repo is a clean no-op; missing `.orchestration/` and commit failures (e.g. no git identity) throw loudly — the commit belongs to the user, so no identity is ever invented here. Exports `isPathDirty` (porcelain check for one path) for the composer's pre-mutation guard. CLI: `--repo <path> [--gitignore]`.
|
|
33
34
|
- `sample-project.js` — optional sample-project setup (2026-09-17): extracts `seed/sample-project.tar.gz` (stripping the `sample-project/` prefix), `git init`, initial commit. No remote required. Registration happens via the existing `create-project` action — no sample-specific endpoint. CLI: `--tarball <path> --dest <dir> --name <project-name>`.
|
|
35
|
+
- `compute-publish-diff.js` — deterministic publish-diff computer (2026-09-18, room #14): replaces the agent-ferried `git diff` JSON string field (the JSON ferry dropped a valid 700-line diff — "Publish diff parsed to zero files"). Runs `git diff <base> <HEAD>` via argv (no shell), writes the raw diff bytes to `--out`, and prints only a small JSON summary (`commit`, `base`, `diff_file`, `sha256`, `bytes`, `file_count`, `changed_lines` = added+removed, never raw diff output lines, `files`, `has_binary`, `has_rename`). Empty-tree base is the first-publish ancestor (hardcoded, never `merge-base` on the empty tree); non-ancestor is semantic JSON (`ok:false, error:"not-ancestor"`, exit 0); usage errors exit 2 with nothing on stdout. Pinned per-run alongside the other lifecycle files (`COMPUTE_DIFF` in `PIN_BASENAMES`) so a mid-run release swap cannot change it under the workflow.
|
|
36
|
+
- `classify-surface.js` — deterministic project-surface classifier (2026-09-18, room #14 defect): a pure, never-throwing scan of the registered repo — `space.json` present → `artifact` surface (deploy_slug suggestion = `space.json`'s valid slug, verbatim — presence alone still means "web app"), `package.json` with a non-empty `bin` → `terminal` surface, otherwise null (unclassified); artifact wins conflicts (the deployed artifact is what users see, same rule as workflows/standard.js). CLI `--repo-path` prints one JSON object (`{surface, deploy_slug, signals}`), always exit 0 (classification failure is a null result, exit 2 only for usage). `create-project` fills omitted `environment_type`/`deploy_type`/`deploy_slug` from the classification; explicit values (including explicit null) always win and drive the effective surface.
|
|
37
|
+
- `advance-publish-base.js` — publish-base advance past the setup scaffold (2026-09-18, room #14): `create-project` stamps provenance at the pre-scaffold repo HEAD, then crew-init commits `.orchestration/` (+ the consented `.gitignore`) on top — the first task's publish diff would carry the whole scaffold (517 insertions) and blow the 200-line diff-transport budget. The script moves `source_commit` to the scaffold commit only when `--project-created` (the stamp is this init's, never a real publish's), the scaffold commit's parent is exactly the stamped base, and the scaffold touches only crew-owned paths (`.orchestration/`, `.gitignore`); no provenance + root scaffold commit stamps fresh. Every other case skips without touching provenance; `published_at` is preserved on advance. Called from crew-init.js (best-effort, non-fatal). CLI: `--crew-home <path> --repo <path> --scaffold-sha <sha> [--project-created]`.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Advance the publish base past crew-init's scaffold commit (2026-09-18).
|
|
3
|
+
//
|
|
4
|
+
// Room #14 (2026-09-17): create-project stamps provenance.source_commit at the
|
|
5
|
+
// pre-scaffold repo HEAD; crew-init then commits the .orchestration/ scaffold
|
|
6
|
+
// on top. The first task's publish diff then carries the whole scaffold (517
|
|
7
|
+
// insertions in room #14) and blows the 200-line diff-transport budget. The
|
|
8
|
+
// scaffold is crew-owned infrastructure the dashboard artifact never consumes,
|
|
9
|
+
// so the correct publish base is the scaffold commit itself.
|
|
10
|
+
//
|
|
11
|
+
// This script moves provenance.source_commit to the scaffold commit only when
|
|
12
|
+
// every fail-closed condition holds:
|
|
13
|
+
// 1. --project-created: this init created the project, so the existing stamp
|
|
14
|
+
// is this run's initial stamp — never a real publish's.
|
|
15
|
+
// 2. The scaffold commit's parent IS the stamped source_commit: nothing
|
|
16
|
+
// slipped between the stamp and the scaffold, so no un-published task
|
|
17
|
+
// work can be silently dropped from a future diff.
|
|
18
|
+
// 3. Every path changed by the scaffold commit is crew-owned
|
|
19
|
+
// (.orchestration/ or .gitignore).
|
|
20
|
+
// 4. Special case: no provenance was ever stamped (empty repo at create
|
|
21
|
+
// time) and the scaffold commit is the root commit -> stamp fresh.
|
|
22
|
+
// Anything else yields { ok: true, action: "skipped", reason } — never an
|
|
23
|
+
// error, never a clobber. stdout carries exactly one JSON object; operational
|
|
24
|
+
// failures go to stderr with a non-zero exit (the caller treats failure as
|
|
25
|
+
// best-effort: init continues with the stamped base).
|
|
26
|
+
//
|
|
27
|
+
// No wall-clock reads, no randomness — the published_at on a fresh stamp is
|
|
28
|
+
// left to crew-api's default; on an advance the existing published_at is
|
|
29
|
+
// preserved (a base-pointer move is not a publish).
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { readlinkSync } from "node:fs";
|
|
33
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
38
|
+
const RELEASE_RE = /^([0-9a-f]{40}|pkg-\d+\.\d+\.\d+)$/;
|
|
39
|
+
|
|
40
|
+
function usage() {
|
|
41
|
+
console.error(
|
|
42
|
+
"Usage: node lib/advance-publish-base.js --crew-home <path> --repo <path> --scaffold-sha <sha> --project-created"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgv(argv) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === "--crew-home") out.crewHome = argv[++i];
|
|
51
|
+
else if (a === "--repo") out.repo = argv[++i];
|
|
52
|
+
else if (a === "--scaffold-sha") out.scaffoldSha = argv[++i];
|
|
53
|
+
else if (a === "--project-created") out.projectCreated = true;
|
|
54
|
+
else {
|
|
55
|
+
console.error("unknown argument: " + a);
|
|
56
|
+
usage();
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function git(repo, args) {
|
|
64
|
+
return execFileSync("git", ["-C", repo].concat(args), {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
timeout: 15000,
|
|
67
|
+
}).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function crewApi(crewHome, command, jsonArg) {
|
|
71
|
+
const apiPath = join(HERE, "crew-api.js");
|
|
72
|
+
const args = ["--crew-home", crewHome, command];
|
|
73
|
+
if (jsonArg !== undefined) args.push("--json", jsonArg);
|
|
74
|
+
const out = execFileSync("node", [apiPath].concat(args), {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 15000,
|
|
77
|
+
}).trim();
|
|
78
|
+
return JSON.parse(out);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveActiveRelease(crewHome) {
|
|
82
|
+
// Mirrors lib/crew-api.js resolveActiveRelease minus the self-path check
|
|
83
|
+
// (this script is caller-pinned, not release-pinned).
|
|
84
|
+
const target = readlinkSync(join(crewHome, "current"));
|
|
85
|
+
return basename(resolve(crewHome, target));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isCrewOwnedPath(p) {
|
|
89
|
+
return p === ".gitignore" || p === ".orchestration" || p.startsWith(".orchestration/");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function skip(reason) {
|
|
93
|
+
console.log(JSON.stringify({ ok: true, action: "skipped", reason }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function main() {
|
|
97
|
+
const { crewHome, repo, scaffoldSha, projectCreated } = parseArgv(
|
|
98
|
+
process.argv.slice(2)
|
|
99
|
+
);
|
|
100
|
+
if (!crewHome || !repo || !scaffoldSha) {
|
|
101
|
+
usage();
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
if (!SHA_RE.test(scaffoldSha)) {
|
|
105
|
+
console.error("scaffold-sha is not a 40-char hex sha");
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
if (!projectCreated) {
|
|
109
|
+
skip("project was not created by this init — provenance left untouched");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let provenance;
|
|
114
|
+
try {
|
|
115
|
+
provenance = crewApi(crewHome, "get-provenance").provenance;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error("get-provenance failed: " + (e.message || e));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The scaffold commit's parent: empty output means S is the root commit.
|
|
122
|
+
let parent;
|
|
123
|
+
try {
|
|
124
|
+
const parents = git(repo, ["rev-list", "--parents", "-n", "1", scaffoldSha]).split(/\s+/);
|
|
125
|
+
parent = parents.length > 1 ? parents[1] : null;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error("rev-list failed: " + (e.message || e));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let changedPaths;
|
|
132
|
+
try {
|
|
133
|
+
changedPaths = git(repo, ["diff-tree", "--no-commit-id", "--name-only", "-r", scaffoldSha])
|
|
134
|
+
.split("\n")
|
|
135
|
+
.map((l) => l.trim())
|
|
136
|
+
.filter((l) => l.length > 0);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error("diff-tree failed: " + (e.message || e));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const foreign = changedPaths.filter((p) => !isCrewOwnedPath(p));
|
|
142
|
+
if (foreign.length > 0) {
|
|
143
|
+
skip(
|
|
144
|
+
"scaffold commit touches non-crew-owned paths (" +
|
|
145
|
+
foreign.slice(0, 5).join(", ") +
|
|
146
|
+
") — provenance left untouched"
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (provenance === null || provenance === undefined) {
|
|
152
|
+
// No stamp ever (empty repo at create time): stamp fresh only if the
|
|
153
|
+
// scaffold commit is the root commit — anything else means history we
|
|
154
|
+
// did not create, and we do not invent a base over it.
|
|
155
|
+
if (parent !== null) {
|
|
156
|
+
skip("no provenance stamped and scaffold commit is not the root commit — provenance left untouched");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let crewRelease = null;
|
|
160
|
+
try {
|
|
161
|
+
crewRelease = resolveActiveRelease(crewHome);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
skip("no provenance stamped and the active crew release is unresolvable (" + (e.message || e) + ")");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!RELEASE_RE.test(crewRelease)) {
|
|
167
|
+
skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
171
|
+
source_commit: scaffoldSha,
|
|
172
|
+
crew_release: crewRelease,
|
|
173
|
+
}));
|
|
174
|
+
if (!stamped || stamped.ok !== true) {
|
|
175
|
+
console.error("set-provenance did not return ok");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
console.log(JSON.stringify({ ok: true, action: "stamped", to: scaffoldSha.slice(0, 12) }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const base = provenance.source_commit;
|
|
183
|
+
if (!SHA_RE.test(base || "")) {
|
|
184
|
+
skip("existing provenance.source_commit is not a 40-char hex sha — provenance left untouched");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (base === scaffoldSha) {
|
|
188
|
+
skip("provenance already points at the scaffold commit");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (parent !== base) {
|
|
192
|
+
skip(
|
|
193
|
+
"scaffold commit's parent (" +
|
|
194
|
+
String(parent || "root").slice(0, 12) +
|
|
195
|
+
") is not the stamped base (" +
|
|
196
|
+
base.slice(0, 12) +
|
|
197
|
+
") — provenance left untouched"
|
|
198
|
+
);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let crewRelease = RELEASE_RE.test(provenance.crew_release || "")
|
|
203
|
+
? provenance.crew_release
|
|
204
|
+
: null;
|
|
205
|
+
if (!crewRelease) {
|
|
206
|
+
try {
|
|
207
|
+
const resolved = resolveActiveRelease(crewHome);
|
|
208
|
+
if (RELEASE_RE.test(resolved)) crewRelease = resolved;
|
|
209
|
+
} catch {
|
|
210
|
+
crewRelease = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!crewRelease) {
|
|
214
|
+
skip("no usable crew_release for the re-stamp — provenance left untouched");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
219
|
+
source_commit: scaffoldSha,
|
|
220
|
+
crew_release: crewRelease,
|
|
221
|
+
// A base-pointer move is not a publish: keep the existing published_at.
|
|
222
|
+
...(provenance.published_at ? { published_at: provenance.published_at } : {}),
|
|
223
|
+
}));
|
|
224
|
+
if (!stamped || stamped.ok !== true) {
|
|
225
|
+
console.error("set-provenance did not return ok");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
// Exact read-back: the stamp must match what we intended, or it is fiction
|
|
229
|
+
// (same discipline as lib/verify-publish.js).
|
|
230
|
+
const recheck = crewApi(crewHome, "get-provenance").provenance;
|
|
231
|
+
if (!recheck || recheck.source_commit !== scaffoldSha) {
|
|
232
|
+
console.error("post-stamp read-back mismatch: provenance does not point at the scaffold commit");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
console.log(
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
ok: true,
|
|
238
|
+
action: "advanced",
|
|
239
|
+
from: base.slice(0, 12),
|
|
240
|
+
to: scaffoldSha.slice(0, 12),
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
main();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error("advance-publish-base failed: " + (e.message || e));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// classify-surface.js — deterministic user-facing surface classifier.
|
|
3
|
+
//
|
|
4
|
+
// Room #14 (2026-09-17): the flashcards sample (a TS Space web app —
|
|
5
|
+
// space.json, client/, server/) was registered with environment_type=null
|
|
6
|
+
// and deploy_slug=null because the setup conversation never classified it —
|
|
7
|
+
// SETUP-EXPERIENCE.md doesn't even mention the step. Consequence: Capture
|
|
8
|
+
// skipped ("surface unclassified"), Publish had no artifact target, QA had
|
|
9
|
+
// no runnable surface — J1/J2 parked with "no build, deployed artifact, or
|
|
10
|
+
// runnable probe surface". The setup conversation is an agent; asking it to
|
|
11
|
+
// classify in prose is wording, not a mechanism.
|
|
12
|
+
//
|
|
13
|
+
// This script is the mechanism: a pure, deterministic repo scan. Signals:
|
|
14
|
+
// space.json present -> artifact surface (a Space web app).
|
|
15
|
+
// deploy_slug suggestion = space.json's slug
|
|
16
|
+
// (verbatim — the artifact system keys on it).
|
|
17
|
+
// Presence is the signal: an unparseable
|
|
18
|
+
// space.json still means "web app", just with
|
|
19
|
+
// no slug suggestion.
|
|
20
|
+
// package.json with a "bin" -> terminal surface (a CLI).
|
|
21
|
+
// otherwise -> null (unclassified — today's behavior).
|
|
22
|
+
// Artifact wins on contradictory signals (space.json + bin): the deployed
|
|
23
|
+
// artifact is what users see (same rule as workflows/standard.js).
|
|
24
|
+
//
|
|
25
|
+
// create-project fills OMITTED environment_type / deploy_type / deploy_slug
|
|
26
|
+
// from this classification; explicit values always win (including an
|
|
27
|
+
// explicit null environment_type, which means "unclassified").
|
|
28
|
+
//
|
|
29
|
+
// Fail-safe: any IO error, bad path, or unparseable file yields
|
|
30
|
+
// { surface: null, ... } — never a throw, never a misclassification.
|
|
31
|
+
//
|
|
32
|
+
// Usage: node lib/classify-surface.js --repo-path <path>
|
|
33
|
+
// Stdout: exactly one JSON object:
|
|
34
|
+
// { surface: "artifact"|"terminal"|null, deploy_slug: string|null, signals: [...] }
|
|
35
|
+
// Exit codes: 0 ok (always — classification failure is a null result) ·
|
|
36
|
+
// 2 usage/validation. On exit 2 nothing is printed to stdout.
|
|
37
|
+
|
|
38
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
function isDir(p) {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(p).isDirectory();
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJson(p) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validSlug(s) {
|
|
59
|
+
return typeof s === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function classifySurface(repoPath) {
|
|
63
|
+
const signals = [];
|
|
64
|
+
if (typeof repoPath !== "string" || repoPath.length === 0 || !isDir(repoPath)) {
|
|
65
|
+
return { surface: null, deploy_slug: null, signals };
|
|
66
|
+
}
|
|
67
|
+
// Artifact: space.json marks a TS Space web app.
|
|
68
|
+
if (existsSync(join(repoPath, "space.json"))) {
|
|
69
|
+
signals.push("space.json");
|
|
70
|
+
const space = readJson(join(repoPath, "space.json"));
|
|
71
|
+
const slug = space && validSlug(space.slug) ? space.slug : null;
|
|
72
|
+
if (slug) signals.push("space.json:slug=" + slug);
|
|
73
|
+
return { surface: "artifact", deploy_slug: slug, signals };
|
|
74
|
+
}
|
|
75
|
+
// Terminal: package.json "bin" declares a CLI entrypoint.
|
|
76
|
+
const pkg = readJson(join(repoPath, "package.json"));
|
|
77
|
+
if (pkg && pkg.bin !== undefined && pkg.bin !== null) {
|
|
78
|
+
const hasBin =
|
|
79
|
+
typeof pkg.bin === "string"
|
|
80
|
+
? pkg.bin.length > 0
|
|
81
|
+
: typeof pkg.bin === "object" && Object.keys(pkg.bin).length > 0;
|
|
82
|
+
if (hasBin) {
|
|
83
|
+
signals.push("package.json:bin");
|
|
84
|
+
return { surface: "terminal", deploy_slug: null, signals };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { surface: null, deploy_slug: null, signals };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isMainModule = (() => {
|
|
91
|
+
try {
|
|
92
|
+
return process.argv[1] === fileURLToPath(import.meta.url);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
if (isMainModule) {
|
|
99
|
+
const i = process.argv.indexOf("--repo-path");
|
|
100
|
+
const repoPath = i >= 0 ? process.argv[i + 1] : null;
|
|
101
|
+
if (!repoPath) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
JSON.stringify({ ok: false, error: "usage", message: "--repo-path is required" }) + "\n"
|
|
104
|
+
);
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
// Never throws: classifySurface is total over string inputs.
|
|
108
|
+
process.stdout.write(JSON.stringify(classifySurface(repoPath)) + "\n");
|
|
109
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/commit-scaffold.js — commit the crew's setup scaffold files.
|
|
3
|
+
//
|
|
4
|
+
// After project setup writes $REPO/.orchestration/ (and, with the user's
|
|
5
|
+
// consent, updates .gitignore), this module commits exactly those paths so
|
|
6
|
+
// the repo's main checkout is clean when setup finishes.
|
|
7
|
+
//
|
|
8
|
+
// Why this exists (room #13, 2026-09-17): 0.11.0's setup wrote the scaffold
|
|
9
|
+
// but never committed it. The first task's Integrate then refused to merge
|
|
10
|
+
// onto the dirty main — twice in a row, deterministically. An uncommitted
|
|
11
|
+
// scaffold deterministically breaks the first task after setup. Setup must
|
|
12
|
+
// leave main clean; this is the mechanical half of that contract.
|
|
13
|
+
//
|
|
14
|
+
// Contract:
|
|
15
|
+
// - Only ever stages `.orchestration/` and (with --gitignore) `.gitignore`.
|
|
16
|
+
// The user's unrelated changes are never swept into the setup commit.
|
|
17
|
+
// - Idempotent: nothing new staged → { committed: false, reason:
|
|
18
|
+
// "nothing-to-commit" }; never creates an empty commit.
|
|
19
|
+
// - Not a git repo → { committed: false, reason: "not-a-git-repo" }.
|
|
20
|
+
// There is no main to keep clean, so there is nothing to do.
|
|
21
|
+
// - Missing `.orchestration/` → throws. The scaffold step did not run;
|
|
22
|
+
// fail loudly, never silently skip.
|
|
23
|
+
// - Commit failures (e.g. no git identity configured) throw with git's
|
|
24
|
+
// stderr. The caller configures identity and re-runs; never invent an
|
|
25
|
+
// identity here — the commit belongs to the user, not the crew.
|
|
26
|
+
// - The commit message names exactly what was staged (computed from the
|
|
27
|
+
// index, not from flags).
|
|
28
|
+
//
|
|
29
|
+
// Usage:
|
|
30
|
+
// node lib/commit-scaffold.js --repo <path> [--gitignore]
|
|
31
|
+
// --gitignore: the setup step modified .gitignore (consent was given);
|
|
32
|
+
// include it in the commit.
|
|
33
|
+
// Prints JSON: { committed, sha, reason }
|
|
34
|
+
|
|
35
|
+
const fs = require("fs");
|
|
36
|
+
const path = require("path");
|
|
37
|
+
const { execFileSync } = require("child_process");
|
|
38
|
+
|
|
39
|
+
function git(repoPath, args, env) {
|
|
40
|
+
try {
|
|
41
|
+
// No .trim(): callers that parse fixed-width output (porcelain) need
|
|
42
|
+
// the raw lines; scalar callers trim themselves.
|
|
43
|
+
return execFileSync("git", args, {
|
|
44
|
+
cwd: repoPath,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
env: env || process.env,
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
const stderr = e.stderr ? String(e.stderr).trim() : "";
|
|
51
|
+
throw new Error("git " + args.join(" ") + " failed: " + (stderr || e.message));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Porcelain status lines touching exactly relPath (e.g. ".gitignore").
|
|
56
|
+
// Used by the composer as a pre-mutation guard; exported for tests.
|
|
57
|
+
function isPathDirty(repoPath, relPath) {
|
|
58
|
+
const out = git(repoPath, ["status", "--porcelain", "--", relPath]);
|
|
59
|
+
return out.split("\n").some(function (line) {
|
|
60
|
+
if (line.length < 4) return false;
|
|
61
|
+
const p = line.slice(3).replace(/^"|"$/g, "");
|
|
62
|
+
return p === relPath;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commitScaffold(repoPath, opts) {
|
|
67
|
+
opts = opts || {};
|
|
68
|
+
const includeGitignore = opts.includeGitignore === true;
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(path.join(repoPath, ".git"))) {
|
|
71
|
+
return { committed: false, sha: null, reason: "not-a-git-repo" };
|
|
72
|
+
}
|
|
73
|
+
if (!fs.existsSync(path.join(repoPath, ".orchestration"))) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
"no .orchestration/ scaffold found in " + repoPath + " — run the scaffold step before committing"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const paths = [".orchestration"];
|
|
80
|
+
if (includeGitignore) paths.push(".gitignore");
|
|
81
|
+
git(repoPath, ["add", "--"].concat(paths));
|
|
82
|
+
|
|
83
|
+
const staged = git(repoPath, ["diff", "--cached", "--name-only"]).trim();
|
|
84
|
+
if (!staged) {
|
|
85
|
+
return { committed: false, sha: null, reason: "nothing-to-commit" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stagedFiles = staged.split("\n");
|
|
89
|
+
const subject = stagedFiles.indexOf(".gitignore") !== -1
|
|
90
|
+
? "Muse Crew setup: commit .orchestration scaffold and crew .gitignore entries"
|
|
91
|
+
: "Muse Crew setup: commit .orchestration scaffold";
|
|
92
|
+
const body =
|
|
93
|
+
"Setup leaves the repo's main checkout clean: these are the crew-owned\n" +
|
|
94
|
+
"files written during project setup. Integrate refuses to merge onto a\n" +
|
|
95
|
+
"dirty main, so they are committed here instead of left uncommitted.";
|
|
96
|
+
git(repoPath, ["commit", "-m", subject, "-m", body, "--quiet"]);
|
|
97
|
+
const sha = git(repoPath, ["rev-parse", "HEAD"]).trim();
|
|
98
|
+
|
|
99
|
+
return { committed: true, sha: sha, reason: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { commitScaffold, isPathDirty };
|
|
103
|
+
|
|
104
|
+
if (require.main === module) {
|
|
105
|
+
const argv = process.argv.slice(2);
|
|
106
|
+
const get = (flag) => {
|
|
107
|
+
const i = argv.indexOf(flag);
|
|
108
|
+
return i >= 0 ? argv[i + 1] : null;
|
|
109
|
+
};
|
|
110
|
+
const repoPath = get("--repo");
|
|
111
|
+
if (!repoPath) {
|
|
112
|
+
console.error("Usage: node lib/commit-scaffold.js --repo <path> [--gitignore]");
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
console.log(JSON.stringify(commitScaffold(repoPath, { includeGitignore: argv.includes("--gitignore") })));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error("commit-scaffold failed: " + (e.message || e));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
}
|