muse-crew 0.11.1 → 0.13.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 +40 -7
- package/docs/guide.md +1 -1
- package/docs/publish-verification.md +24 -3
- package/docs/state-boundary-spec.md +37 -0
- package/lib/AGENTS.md +6 -3
- package/lib/advance-publish-base.js +271 -0
- package/lib/classify-surface.js +109 -0
- package/lib/compose-evidence-caption.js +59 -10
- package/lib/compute-publish-diff.js +145 -0
- package/lib/crew-api.js +283 -49
- package/lib/schema.sql +12 -0
- package/lib/test-worktree-backend.sh +29 -0
- package/lib/verify-publish.js +4 -3
- package/lib/worktree-lifecycle.sh +9 -3
- package/package.json +1 -1
- package/seed/cron-body-template.md +10 -7
- package/workflows/bugfix.js +67 -69
- package/workflows/chore.js +66 -68
- package/workflows/crew-dispatch.js +21 -6
- package/workflows/crew-init.js +69 -3
- package/workflows/standard.js +67 -69
- package/workflows/upgrade.js +8 -3
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
|
|
|
@@ -472,18 +474,49 @@ Set a global configuration value.
|
|
|
472
474
|
|
|
473
475
|
### `setprovenance`
|
|
474
476
|
|
|
475
|
-
Stamp publication provenance.
|
|
477
|
+
Stamp publication provenance. Provenance is per-project (2026-09-18): each
|
|
478
|
+
project row carries its own publish base (`provenance_source_commit`,
|
|
479
|
+
`provenance_crew_release`, `provenance_published_at`, `provenance_task_id`);
|
|
480
|
+
a crew home with N artifact projects carries N independent bases. There are
|
|
481
|
+
two stamp call paths, and they are different operations on the same row:
|
|
482
|
+
|
|
483
|
+
- **Full stamp** — called by the parent's publish verification (`lib/verify-publish.js`)
|
|
484
|
+
after independently verifying the rebuilt artifact: `source_commit` is the
|
|
485
|
+
verified build commit, `crew_release` the active release at publish time.
|
|
486
|
+
- **Refresh** — called by the release installer (`lib/advance-publish-base.js`)
|
|
487
|
+
after installing/activating a crew release: `crew_release` is refreshed to
|
|
488
|
+
the live release identity while preserving the existing `source_commit`.
|
|
489
|
+
A base-pointer move is not a publish.
|
|
490
|
+
|
|
491
|
+
Both paths flow through the single writer in `lib/crew-api.js`; an unknown
|
|
492
|
+
`project_id` fails closed (exit 3), and omitting `project_id` is a usage
|
|
493
|
+
error (exit 2) — there is no silent global fallback.
|
|
476
494
|
|
|
477
495
|
| Field | Type | Required | Notes |
|
|
478
496
|
|-------|------|----------|-------|
|
|
497
|
+
| `project_id` | string | yes | The project whose publish base is stamped |
|
|
479
498
|
| `source_commit` | 40-char hex | yes | Repo commit the artifact was built from |
|
|
480
|
-
| `crew_release` | 40-char hex | yes | Active crew release (`$CREW_HOME/current`) at
|
|
481
|
-
| `published_at` | ISO 8601 datetime (UTC) | yes | |
|
|
499
|
+
| `crew_release` | 40-char hex, or `pkg-<semver>` | yes | Active crew release (`$CREW_HOME/current`) at stamp time |
|
|
500
|
+
| `published_at` | ISO 8601 datetime (UTC) | yes | Defaults to now when omitted |
|
|
482
501
|
| `task_id` | string | no | Publishing task, for traceability |
|
|
483
502
|
|
|
503
|
+
A one-time migration attributes a pre-per-project legacy `config.provenance.*`
|
|
504
|
+
triple to a project row only when its `source_commit` is an ancestor of
|
|
505
|
+
exactly one project's repo HEAD (verified with git); zero or ambiguous
|
|
506
|
+
matches are recorded as `deferred:<reason>` and never attributed. A triple
|
|
507
|
+
that matches exactly one project but violates the writer's shape rules
|
|
508
|
+
(e.g. a hand-written abbreviated sha, which git resolves for the ancestor
|
|
509
|
+
check but which fails the 40-char storage rule) is recorded as
|
|
510
|
+
`deferred:stamp-failed:<reason>` — the migration never throws, so a bad
|
|
511
|
+
triple can never brick the home. A `deferred:*` watermark is re-evaluated
|
|
512
|
+
when a project is created (the only event that changes the match set), so
|
|
513
|
+
a triple that predates the home's first project is attributed once a
|
|
514
|
+
descendant project exists. The legacy keys are inert dead storage after a
|
|
515
|
+
successful migration — new code never reads them.
|
|
516
|
+
|
|
484
517
|
### `getprovenance`
|
|
485
518
|
|
|
486
|
-
Read
|
|
519
|
+
Read a project's publication provenance. Returns `{ "provenance": { "source_commit", "crew_release", "published_at", "task_id" } }`, or `{ "provenance": null }` when nothing has been published yet. A project that was never stamped reads `null` — another project's stamp is never visible here. `task_id` is the publishing task, for traceability — present when `setprovenance` was called with one, absent otherwise. `project_id` is required (exit 2 when omitted); an unknown `project_id` fails closed (exit 3).
|
|
487
520
|
|
|
488
521
|
---
|
|
489
522
|
|
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
|
|
@@ -210,7 +223,9 @@ For a task parked with `publish: verification-requested <commit>`:
|
|
|
210
223
|
slug (the project's publish target).
|
|
211
224
|
2. **Expected change.** The publish delta is `git diff <base> <commit>`
|
|
212
225
|
in `repo_path`, where `<base>` is the previously-stamped provenance
|
|
213
|
-
`source_commit` (read it via
|
|
226
|
+
`source_commit` for this project (read it via
|
|
227
|
+
`get-provenance --json '{"project_id":"<id>"}'` — provenance is
|
|
228
|
+
per-project; without `project_id` the call exits 2; use the empty-tree sha
|
|
214
229
|
`4b825dc642cb6eb9a060e54bf8d69288fbee4904` when no provenance is
|
|
215
230
|
stamped yet — a first publish). Never use `commit^1` as the base and
|
|
216
231
|
never take the expected change from the builder's report: push-time
|
|
@@ -280,9 +295,15 @@ For a task parked with `publish: verification-requested <commit>`:
|
|
|
280
295
|
(the crew-owned store). Do NOT use the artifact's `setprovenance`
|
|
281
296
|
action — it writes a different, non-authoritative store that QA never
|
|
282
297
|
reads, so the stamp would be invisible to every gate:
|
|
283
|
-
`set-provenance --json '{"source_commit":"<commit>","crew_release":"<release>","task_id":"<task>"}'`
|
|
298
|
+
`set-provenance --json '{"project_id":"<id>","source_commit":"<commit>","crew_release":"<release>","task_id":"<task>"}'`
|
|
284
299
|
(crew_release is the basename of the active release, e.g.
|
|
285
|
-
`pkg-0.7.10`).
|
|
300
|
+
`pkg-0.7.10`). This is the full publish-verification stamp — it
|
|
301
|
+
records a verified publish. Do not confuse it with the
|
|
302
|
+
provenance *refresh* (re-pointing `crew_release` at the active
|
|
303
|
+
release after a crew upgrade without a new publish), which also goes
|
|
304
|
+
through `set-provenance` but keeps the existing `source_commit`.
|
|
305
|
+
Then read the stamp back with
|
|
306
|
+
`get-provenance --json '{"project_id":"<id>"}'`
|
|
286
307
|
and confirm source_commit, crew_release, and task_id match exactly
|
|
287
308
|
what was sent — a stamp that cannot be read back is not a stamp. Only
|
|
288
309
|
then log the task note event
|
|
@@ -182,3 +182,40 @@ setup, accurate message, idempotency, non-repo no-op, missing scaffold
|
|
|
182
182
|
throws, missing identity throws, composer refusal on dirty `.gitignore`,
|
|
183
183
|
declined consent leaves `.gitignore` untouched; structural: crew-init
|
|
184
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
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, and the release system.
|
|
4
4
|
|
|
5
5
|
- `build-registry.js` — deterministic extractor that generates `workflows/registry.json` (workflow step registry) from the workflow files' `meta` blocks at release time; invoked by `crew-release.sh` deploy
|
|
6
|
-
- `crew-api.js` — the crew-owned task-service API (dependency inversion, 2026-09-11): a zero-dependency Node CLI implementing the API.md contract against `$CREW_HOME/crew-state.db` (schema in `schema.sql`). Workflows call it through their agents' shell; the dashboard delegates to it. All state-machine invariants live as CHECK constraints in the schema, never in client prose. Includes the `record-phase` composite (session + event in one transaction) and a one-time `migrate` import from a dashboard app.db.
|
|
6
|
+
- `crew-api.js` — the crew-owned task-service API (dependency inversion, 2026-09-11): a zero-dependency Node CLI implementing the API.md contract against `$CREW_HOME/crew-state.db` (schema in `schema.sql`). Workflows call it through their agents' shell; the dashboard delegates to it. All state-machine invariants live as CHECK constraints in the schema, never in client prose. Includes the `record-phase` composite (session + event in one transaction) and a one-time `migrate` import from a dashboard app.db. Active-release resolution is split in two (room #15): `resolveActiveReleaseName` (symlink-only — writers like the initial-provenance stamp record the active release without proving they are it) and `resolveActiveRelease` (symlink + self-path cross-check — verifiers like `scan-verification-pending` refuse to stamp claims when the running code isn't the active release's own). Provenance is per-project (2026-09-18, room #15 blocker 8): nullable `provenance_*` columns on the projects row, a single `stampProvenance()` writer, `set-provenance`/`get-provenance` require `project_id` (no silent global fallback), and a watermarked openDb backfill that attributes the legacy `config.provenance.*` triple to exactly-one ancestor match — never fabricated, otherwise deferred.
|
|
7
7
|
- `schema.sql` — the crew-owned state schema: projects, tasks, poll_state, config, agent_sessions, events. Vocabularies enforced by CHECK constraints; `rejected` is a valid event type (the 2026-09-11 crash was a stored session whose event was rejected). Column names match the historical dashboard tables for a verbatim migration.
|
|
8
8
|
- `crew-release.sh` — immutable release manager: deploy, rollback, prune
|
|
9
9
|
- `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
|
|
10
|
-
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
|
|
10
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin/main` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — so the push is a fast-forward; fail-soft with no remote, fail closed on conflict) (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on dirty `main`; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into main — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure. post-deploy's `.worktrees/` guard is add-then-reset, never an all-negative pathspec: `git add -A -- ':!.worktrees/'` still exits 1 on git 2.43.0 against the crew's own gitignored non-empty `.worktrees/` (room #15 J3), killing post-deploy under `set -e` — a plain `add -A` never errors on ignored paths and the follow-up `git reset -q -- .worktrees/` keeps stray lock files out of main even on pre-gitignore-entry repos.
|
|
11
11
|
- `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
|
|
12
12
|
- `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
|
|
13
13
|
- `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
|
|
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
|
|
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 task's project-scoped 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.
|
|
@@ -32,3 +32,6 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
32
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
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]`.
|
|
34
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). Provenance is per-project (room #15 blocker 8): `--project-id` is required and every read/stamp is project-scoped. CLI: `--crew-home <path> --repo <path> --scaffold-sha <sha> --project-id <id> [--project-created]`.
|
|
@@ -0,0 +1,271 @@
|
|
|
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-id <id> --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-id") out.projectId = argv[++i];
|
|
54
|
+
else if (a === "--project-created") out.projectCreated = true;
|
|
55
|
+
else {
|
|
56
|
+
console.error("unknown argument: " + a);
|
|
57
|
+
usage();
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function git(repo, args) {
|
|
65
|
+
return execFileSync("git", ["-C", repo].concat(args), {
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
timeout: 15000,
|
|
68
|
+
}).trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function crewApi(crewHome, command, jsonArg) {
|
|
72
|
+
const apiPath = join(HERE, "crew-api.js");
|
|
73
|
+
const args = ["--crew-home", crewHome, command];
|
|
74
|
+
if (jsonArg !== undefined) args.push("--json", jsonArg);
|
|
75
|
+
const out = execFileSync("node", [apiPath].concat(args), {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
timeout: 15000,
|
|
78
|
+
}).trim();
|
|
79
|
+
return JSON.parse(out);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveActiveRelease(crewHome) {
|
|
83
|
+
// Mirrors lib/crew-api.js resolveActiveRelease minus the self-path check
|
|
84
|
+
// (this script is caller-pinned, not release-pinned).
|
|
85
|
+
const target = readlinkSync(join(crewHome, "current"));
|
|
86
|
+
return basename(resolve(crewHome, target));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isCrewOwnedPath(p) {
|
|
90
|
+
return p === ".gitignore" || p === ".orchestration" || p.startsWith(".orchestration/");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function skip(reason) {
|
|
94
|
+
console.log(JSON.stringify({ ok: true, action: "skipped", reason }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function main() {
|
|
98
|
+
const { crewHome, repo, scaffoldSha, projectId, projectCreated } = parseArgv(
|
|
99
|
+
process.argv.slice(2)
|
|
100
|
+
);
|
|
101
|
+
if (!crewHome || !repo || !scaffoldSha || !projectId) {
|
|
102
|
+
usage();
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
if (!SHA_RE.test(scaffoldSha)) {
|
|
106
|
+
console.error("scaffold-sha is not a 40-char hex sha");
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
if (!projectCreated) {
|
|
110
|
+
skip("project was not created by this init — provenance left untouched");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let provenance;
|
|
115
|
+
try {
|
|
116
|
+
provenance = crewApi(crewHome, "get-provenance", JSON.stringify({ project_id: projectId })).provenance;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error("get-provenance failed: " + (e.message || e));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The scaffold commit's parent: empty output means S is the root commit.
|
|
123
|
+
let parent;
|
|
124
|
+
try {
|
|
125
|
+
const parents = git(repo, ["rev-list", "--parents", "-n", "1", scaffoldSha]).split(/\s+/);
|
|
126
|
+
parent = parents.length > 1 ? parents[1] : null;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("rev-list failed: " + (e.message || e));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let changedPaths;
|
|
133
|
+
try {
|
|
134
|
+
changedPaths = git(repo, ["diff-tree", "--no-commit-id", "--name-only", "-r", scaffoldSha])
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((l) => l.trim())
|
|
137
|
+
.filter((l) => l.length > 0);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error("diff-tree failed: " + (e.message || e));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const foreign = changedPaths.filter((p) => !isCrewOwnedPath(p));
|
|
143
|
+
if (foreign.length > 0) {
|
|
144
|
+
skip(
|
|
145
|
+
"scaffold commit touches non-crew-owned paths (" +
|
|
146
|
+
foreign.slice(0, 5).join(", ") +
|
|
147
|
+
") — provenance left untouched"
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (provenance === null || provenance === undefined) {
|
|
153
|
+
// No stamp ever (empty repo at create time): stamp fresh only if the
|
|
154
|
+
// scaffold commit is the root commit — anything else means history we
|
|
155
|
+
// did not create, and we do not invent a base over it.
|
|
156
|
+
if (parent !== null) {
|
|
157
|
+
skip("no provenance stamped and scaffold commit is not the root commit — provenance left untouched");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let crewRelease = null;
|
|
161
|
+
try {
|
|
162
|
+
crewRelease = resolveActiveRelease(crewHome);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
skip("no provenance stamped and the active crew release is unresolvable (" + (e.message || e) + ")");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!RELEASE_RE.test(crewRelease)) {
|
|
168
|
+
skip("no provenance stamped and the active release name is not a valid crew_release (" + crewRelease + ")");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let stamped;
|
|
172
|
+
try {
|
|
173
|
+
stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
174
|
+
project_id: projectId,
|
|
175
|
+
source_commit: scaffoldSha,
|
|
176
|
+
crew_release: crewRelease,
|
|
177
|
+
}));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error("set-provenance failed: " + (e.message || e));
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
if (!stamped || stamped.ok !== true) {
|
|
183
|
+
console.error("set-provenance did not return ok");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log(JSON.stringify({ ok: true, action: "stamped", to: scaffoldSha.slice(0, 12) }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const base = provenance.source_commit;
|
|
191
|
+
if (!SHA_RE.test(base || "")) {
|
|
192
|
+
skip("existing provenance.source_commit is not a 40-char hex sha — provenance left untouched");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (base === scaffoldSha) {
|
|
196
|
+
skip("provenance already points at the scaffold commit");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (parent !== base) {
|
|
200
|
+
skip(
|
|
201
|
+
"scaffold commit's parent (" +
|
|
202
|
+
String(parent || "root").slice(0, 12) +
|
|
203
|
+
") is not the stamped base (" +
|
|
204
|
+
base.slice(0, 12) +
|
|
205
|
+
") — provenance left untouched"
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let crewRelease = RELEASE_RE.test(provenance.crew_release || "")
|
|
211
|
+
? provenance.crew_release
|
|
212
|
+
: null;
|
|
213
|
+
if (!crewRelease) {
|
|
214
|
+
try {
|
|
215
|
+
const resolved = resolveActiveRelease(crewHome);
|
|
216
|
+
if (RELEASE_RE.test(resolved)) crewRelease = resolved;
|
|
217
|
+
} catch {
|
|
218
|
+
crewRelease = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!crewRelease) {
|
|
222
|
+
skip("no usable crew_release for the re-stamp — provenance left untouched");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let stamped;
|
|
227
|
+
try {
|
|
228
|
+
stamped = crewApi(crewHome, "set-provenance", JSON.stringify({
|
|
229
|
+
project_id: projectId,
|
|
230
|
+
source_commit: scaffoldSha,
|
|
231
|
+
crew_release: crewRelease,
|
|
232
|
+
// A base-pointer move is not a publish: keep the existing published_at.
|
|
233
|
+
...(provenance.published_at ? { published_at: provenance.published_at } : {}),
|
|
234
|
+
}));
|
|
235
|
+
} catch (e) {
|
|
236
|
+
console.error("set-provenance failed: " + (e.message || e));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
if (!stamped || stamped.ok !== true) {
|
|
240
|
+
console.error("set-provenance did not return ok");
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
// Exact read-back: the stamp must match what we intended, or it is fiction
|
|
244
|
+
// (same discipline as lib/verify-publish.js).
|
|
245
|
+
let recheck;
|
|
246
|
+
try {
|
|
247
|
+
recheck = crewApi(crewHome, "get-provenance", JSON.stringify({ project_id: projectId })).provenance;
|
|
248
|
+
} catch (e) {
|
|
249
|
+
console.error("post-stamp get-provenance failed: " + (e.message || e));
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
if (!recheck || recheck.source_commit !== scaffoldSha) {
|
|
253
|
+
console.error("post-stamp read-back mismatch: provenance does not point at the scaffold commit");
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
console.log(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
ok: true,
|
|
259
|
+
action: "advanced",
|
|
260
|
+
from: base.slice(0, 12),
|
|
261
|
+
to: scaffoldSha.slice(0, 12),
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
main();
|
|
268
|
+
} catch (e) {
|
|
269
|
+
console.error("advance-publish-base failed: " + (e.message || e));
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
@@ -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
|
+
}
|