muse-crew 0.8.0 → 0.9.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 +3 -0
- package/docs/artifact-ux.md +110 -0
- package/docs/guide.md +2 -2
- package/docs/ooda-report.md +21 -10
- package/docs/terminal-ux.md +110 -0
- package/docs/upgrade-paths.md +105 -0
- package/identities/hazel.md +20 -7
- package/lib/AGENTS.md +1 -0
- package/lib/append-ooda-step.js +25 -5
- package/lib/crew-api.js +37 -6
- package/lib/crew-release.sh +8 -2
- package/lib/publish-npm.sh +24 -6
- package/lib/read-ooda-verdict.js +60 -2
- package/lib/schema.sql +9 -0
- package/lib/update-watch.js +79 -1
- package/lib/ux-doctrine.js +63 -0
- package/package.json +1 -1
- package/workflows/bugfix.js +165 -38
- package/workflows/chore.js +43 -8
- package/workflows/crew-dispatch.js +5 -1
- package/workflows/crew-init.js +20 -3
- package/workflows/docs.js +18 -1
- package/workflows/standard.js +157 -37
package/lib/crew-api.js
CHANGED
|
@@ -54,6 +54,16 @@ function validateVisualProtocol(value) {
|
|
|
54
54
|
throw usageError("visual_protocol must be true, false, or null.");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// environment_type validation: 'artifact' | 'terminal' | null. undefined
|
|
58
|
+
// means "not provided" (create: defaults to null; update: leaves the field
|
|
59
|
+
// alone). null is the explicit clear operation — it returns the project to
|
|
60
|
+
// unclassified (no experiential QA). Anything else is a usage error.
|
|
61
|
+
function validateEnvironmentType(value) {
|
|
62
|
+
if (value === undefined || value === null) return null;
|
|
63
|
+
if (value === "artifact" || value === "terminal") return value;
|
|
64
|
+
throw usageError("environment_type must be 'artifact', 'terminal', or null.");
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
// ---------------------------------------------------------------------------
|
|
58
68
|
// Crew home resolution (fail closed)
|
|
59
69
|
// ---------------------------------------------------------------------------
|
|
@@ -102,11 +112,11 @@ function openDb(crewHome) {
|
|
|
102
112
|
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
103
113
|
}
|
|
104
114
|
}
|
|
115
|
+
const projectCols = db.prepare("PRAGMA table_info(projects)").all();
|
|
105
116
|
// Migration: add visual_protocol column if missing (per-project visual
|
|
106
117
|
// protocol toggle, 2026-09-12). Same idempotent PRAGMA-check pattern;
|
|
107
118
|
// ADD COLUMN without a default leaves existing rows NULL, which is the
|
|
108
119
|
// inherit state by design (no backfill step).
|
|
109
|
-
const projectCols = db.prepare("PRAGMA table_info(projects)").all();
|
|
110
120
|
if (!projectCols.some((c) => c.name === "visual_protocol")) {
|
|
111
121
|
try {
|
|
112
122
|
db.exec("ALTER TABLE projects ADD COLUMN visual_protocol INTEGER CHECK (visual_protocol IN (0, 1))");
|
|
@@ -115,6 +125,19 @@ function openDb(crewHome) {
|
|
|
115
125
|
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
116
126
|
}
|
|
117
127
|
}
|
|
128
|
+
// Migration: add environment_type column if missing (per-project
|
|
129
|
+
// user-facing surface for experiential QA routing, 2026-09-17). Same
|
|
130
|
+
// idempotent PRAGMA-check pattern; ADD COLUMN without a default leaves
|
|
131
|
+
// existing rows NULL, which is the unclassified state by design
|
|
132
|
+
// (no backfill step).
|
|
133
|
+
if (!projectCols.some((c) => c.name === "environment_type")) {
|
|
134
|
+
try {
|
|
135
|
+
db.exec("ALTER TABLE projects ADD COLUMN environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal'))");
|
|
136
|
+
} catch (e) {
|
|
137
|
+
// Another process may have added it concurrently; ignore duplicate-column errors.
|
|
138
|
+
if (!/duplicate column name/i.test(e.message)) throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
118
141
|
}
|
|
119
142
|
return db;
|
|
120
143
|
}
|
|
@@ -152,6 +175,9 @@ function mapProject(row) {
|
|
|
152
175
|
quiesced: !!row.quiesced,
|
|
153
176
|
// Tri-state: null (inherit) stays null, never coerced to false.
|
|
154
177
|
visual_protocol: row.visual_protocol == null ? null : !!row.visual_protocol,
|
|
178
|
+
// environment_type: 'artifact' | 'terminal' | null (unclassified).
|
|
179
|
+
// Null stays null — no coercion, no default.
|
|
180
|
+
environment_type: row.environment_type == null ? null : row.environment_type,
|
|
155
181
|
created_at: row.created_at,
|
|
156
182
|
updated_at: row.updated_at,
|
|
157
183
|
};
|
|
@@ -1599,6 +1625,7 @@ commands["create-project"] = (db, args, ctx) => {
|
|
|
1599
1625
|
}
|
|
1600
1626
|
const timestamp = now();
|
|
1601
1627
|
const visualProtocol = validateVisualProtocol(args.visual_protocol);
|
|
1628
|
+
const environmentType = validateEnvironmentType(args.environment_type);
|
|
1602
1629
|
const row = {
|
|
1603
1630
|
id, display_name: displayName, repo_path: repoPath, deploy_type: deployType,
|
|
1604
1631
|
deploy_slug: args.deploy_slug ?? null,
|
|
@@ -1606,6 +1633,7 @@ commands["create-project"] = (db, args, ctx) => {
|
|
|
1606
1633
|
simultaneity: args.simultaneity ?? 2,
|
|
1607
1634
|
quiesced: args.quiesced ? 1 : 0,
|
|
1608
1635
|
visual_protocol: visualProtocol == null ? null : (visualProtocol ? 1 : 0),
|
|
1636
|
+
environment_type: environmentType,
|
|
1609
1637
|
created_at: timestamp, updated_at: timestamp,
|
|
1610
1638
|
};
|
|
1611
1639
|
if (!Number.isInteger(row.simultaneity) || row.simultaneity < 1 || row.simultaneity > 100) {
|
|
@@ -1613,9 +1641,9 @@ commands["create-project"] = (db, args, ctx) => {
|
|
|
1613
1641
|
}
|
|
1614
1642
|
db.prepare(
|
|
1615
1643
|
`INSERT INTO projects (id, display_name, repo_path, deploy_type, deploy_slug,
|
|
1616
|
-
description, simultaneity, quiesced, visual_protocol, created_at, updated_at)
|
|
1644
|
+
description, simultaneity, quiesced, visual_protocol, environment_type, created_at, updated_at)
|
|
1617
1645
|
VALUES (@id, @display_name, @repo_path, @deploy_type, @deploy_slug,
|
|
1618
|
-
@description, @simultaneity, @quiesced, @visual_protocol, @created_at, @updated_at)`).run(row);
|
|
1646
|
+
@description, @simultaneity, @quiesced, @visual_protocol, @environment_type, @created_at, @updated_at)`).run(row);
|
|
1619
1647
|
const initialProvenance = tryStampInitialProvenance(db, ctx.crewHome, repoPath, deployType);
|
|
1620
1648
|
return { project: mapProject(row), initial_provenance: initialProvenance };
|
|
1621
1649
|
};
|
|
@@ -1654,13 +1682,16 @@ commands["update-project"] = (db, args) => {
|
|
|
1654
1682
|
patch.simultaneity = args.simultaneity;
|
|
1655
1683
|
}
|
|
1656
1684
|
if (args.quiesced !== undefined) patch.quiesced = args.quiesced ? 1 : 0;
|
|
1657
|
-
// NOT covered by the context-change guard below: visual_protocol
|
|
1658
|
-
// once from launch args at dispatch time, so a
|
|
1659
|
-
// future launches.
|
|
1685
|
+
// NOT covered by the context-change guard below: visual_protocol and
|
|
1686
|
+
// environment_type are read once from launch args at dispatch time, so a
|
|
1687
|
+
// mid-run change only affects future launches.
|
|
1660
1688
|
if (args.visual_protocol !== undefined) {
|
|
1661
1689
|
const vp = validateVisualProtocol(args.visual_protocol);
|
|
1662
1690
|
patch.visual_protocol = vp == null ? null : (vp ? 1 : 0);
|
|
1663
1691
|
}
|
|
1692
|
+
if (args.environment_type !== undefined) {
|
|
1693
|
+
patch.environment_type = validateEnvironmentType(args.environment_type);
|
|
1694
|
+
}
|
|
1664
1695
|
|
|
1665
1696
|
const contextChanged =
|
|
1666
1697
|
(args.repo_path !== undefined && args.repo_path !== current.repo_path) ||
|
package/lib/crew-release.sh
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
#
|
|
9
9
|
# Layout:
|
|
10
10
|
# ~/.jarvis/
|
|
11
|
-
# releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/ + seed/workflows/ docs)
|
|
11
|
+
# releases/<full-commit-hash>/ # immutable snapshot (workflows/ + lib/ + seed/workflows/ + docs/)
|
|
12
12
|
# current -> releases/<hash> # active release
|
|
13
13
|
# workflows -> current/workflows # convenience (cron/dispatcher reads this)
|
|
14
14
|
# lib -> current/lib # convenience
|
|
@@ -231,12 +231,18 @@ cmd_deploy() {
|
|
|
231
231
|
# Extract from committed tree (not working directory)
|
|
232
232
|
_seed=""
|
|
233
233
|
git cat-file -e "HEAD:seed/workflows" 2>/dev/null && _seed="seed/workflows" || true
|
|
234
|
+
# docs/ ships the shared crew references the workflow prompts read at
|
|
235
|
+
# runtime ($CREW_HOME/current/docs/terminal-ux.md) — the prompts are
|
|
236
|
+
# not the mechanism; the file they point at must exist in the release.
|
|
237
|
+
_docs=""
|
|
238
|
+
git cat-file -e "HEAD:docs" 2>/dev/null && _docs="docs" || true
|
|
234
239
|
# shellcheck disable=SC2086
|
|
235
|
-
git archive HEAD -- workflows lib $_seed | tar -xC "$staging_dir"
|
|
240
|
+
git archive HEAD -- workflows lib $_seed $_docs | tar -xC "$staging_dir"
|
|
236
241
|
else
|
|
237
242
|
# Plain directory (npm install): copy directly
|
|
238
243
|
[ -d workflows ] && cp -r workflows "$staging_dir/"
|
|
239
244
|
[ -d lib ] && cp -r lib "$staging_dir/"
|
|
245
|
+
[ -d docs ] && cp -r docs "$staging_dir/"
|
|
240
246
|
if [ -d seed/workflows ]; then
|
|
241
247
|
mkdir -p "$staging_dir/seed"
|
|
242
248
|
cp -r seed/workflows "$staging_dir/seed/"
|
package/lib/publish-npm.sh
CHANGED
|
@@ -212,7 +212,13 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
212
212
|
|
|
213
213
|
# 11. Publish. "previously published versions" means a retried Publish
|
|
214
214
|
# already landed this version (the merge lock guarantees no other task
|
|
215
|
-
# picked it) — continue.
|
|
215
|
+
# picked it) — continue. A "staged version" 409 means npm accepted the
|
|
216
|
+
# publish but staged it: the PUT returned 2xx yet the version is
|
|
217
|
+
# invisible in the packument until it finalizes (~7 min observed
|
|
218
|
+
# 2026-09-17). Never re-PUT a staged version — the registry rejects it
|
|
219
|
+
# and the re-PUT proves nothing. Poll the registry at step 12 until it
|
|
220
|
+
# finalizes or the budget exhausts (fail closed). Any other failure is
|
|
221
|
+
# fatal.
|
|
216
222
|
if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
|
|
217
223
|
echo "PUBLISHED=$TARGET_VERSION"
|
|
218
224
|
else
|
|
@@ -220,6 +226,10 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
220
226
|
*"previously published versions"*)
|
|
221
227
|
echo "PUBLISHED_ALREADY=$TARGET_VERSION"
|
|
222
228
|
;;
|
|
229
|
+
*"staged version"*)
|
|
230
|
+
echo "PUBLISHED_STAGED=$TARGET_VERSION"
|
|
231
|
+
echo "publish staged by registry — polling for visibility, not re-PUTting"
|
|
232
|
+
;;
|
|
223
233
|
*)
|
|
224
234
|
rm -f "$TGZ"
|
|
225
235
|
fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
|
|
@@ -231,12 +241,20 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
231
241
|
# 12. Verify: ground truth is the registry, not any agent's summary.
|
|
232
242
|
# The registry is eventually consistent: a publish followed by an
|
|
233
243
|
# immediate read can observe the pre-publish version (canary 5a027278
|
|
234
|
-
# hit a stale read replica ~6s after publish).
|
|
235
|
-
#
|
|
236
|
-
#
|
|
244
|
+
# hit a stale read replica ~6s after publish). Worse, npm may STAGE a
|
|
245
|
+
# publish: the PUT returns 2xx but the version stays invisible in the
|
|
246
|
+
# packument until it finalizes (~7 min observed 2026-09-17) — a
|
|
247
|
+
# re-PUT then 409s as "previously staged". A short budget would park
|
|
248
|
+
# a landed publish, so the default budget covers staged finalization
|
|
249
|
+
# with headroom (30 x 30s = 15 min, still bounded). A single stale
|
|
250
|
+
# read must never park a landed publish, so this block retries with
|
|
251
|
+
# backoff and client cache-busting instead of failing on the first
|
|
252
|
+
# mismatch. Budget exhaustion fails closed: a publish that never
|
|
253
|
+
# becomes visible is not claimed — a human with 2FA investigates
|
|
254
|
+
# (e.g. `npm stage approve`) and the version is never re-PUT blind.
|
|
237
255
|
# STEP-12-ANCHOR: publish verification (retry-tolerant)
|
|
238
|
-
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-
|
|
239
|
-
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-
|
|
256
|
+
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-30}"
|
|
257
|
+
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-30}"
|
|
240
258
|
REG=""
|
|
241
259
|
for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
|
|
242
260
|
# --prefer-online busts npm's client-side packument cache; the retry
|
package/lib/read-ooda-verdict.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
// Reads <phase-dir>/verdict.json and prints exactly one JSON line to stdout.
|
|
14
14
|
//
|
|
15
15
|
// Exit 0 with {ok:true, verdict, reason, summary, expected, actual, attempt,
|
|
16
|
-
// visual_loop_unavailable} when the record exists,
|
|
17
|
-
// field, the verdict equals --expect, and a FAIL
|
|
16
|
+
// visual_loop_unavailable, terminal_loop_unavailable} when the record exists,
|
|
17
|
+
// parses, has a verdict field, the verdict equals --expect, and a FAIL
|
|
18
|
+
// carries a non-empty reason.
|
|
18
19
|
//
|
|
19
20
|
// visual_loop_unavailable (2026-09-16): a PASS verdict with missing
|
|
20
21
|
// experiential evidence must never be terminal — the QA closeout parks
|
|
@@ -29,6 +30,16 @@
|
|
|
29
30
|
// (playwright, see-act, not possible, not installed, unavailable,
|
|
30
31
|
// could not run/drive/launch, no browser).
|
|
31
32
|
//
|
|
33
|
+
// terminal_loop_unavailable (2026-09-17): the terminal-surface counterpart.
|
|
34
|
+
// A PASS with a terminal loop that never ran (the CLI would not execute)
|
|
35
|
+
// must also park instead of stamping done. Same shape, terminal signals:
|
|
36
|
+
// (1) the OODA log has a terminal-action step with exit 3 and NOT
|
|
37
|
+
// POSSIBLE in the observation — the terminal QA contract for
|
|
38
|
+
// "the CLI will not run";
|
|
39
|
+
// (2) verdict.json's missing_evidence names CLI tool-unavailability
|
|
40
|
+
// (command not found, not recognized, no runtime, not installed,
|
|
41
|
+
// not possible, unavailable, could not run/execute/launch).
|
|
42
|
+
//
|
|
32
43
|
// Exit 2 with {ok:false, code, error} when:
|
|
33
44
|
// missing — verdict.json is absent (the agent never wrote one)
|
|
34
45
|
// corrupt — unparseable JSON, or parsed but no verdict field
|
|
@@ -103,6 +114,7 @@ function main() {
|
|
|
103
114
|
actual: record.actual === undefined ? "" : record.actual,
|
|
104
115
|
attempt: record.attempt === undefined ? "" : record.attempt,
|
|
105
116
|
visual_loop_unavailable: visualLoopUnavailable(dir, record),
|
|
117
|
+
terminal_loop_unavailable: terminalLoopUnavailable(dir, record),
|
|
106
118
|
}) + "\n");
|
|
107
119
|
}
|
|
108
120
|
|
|
@@ -152,4 +164,50 @@ function visualLoopUnavailable(dir, record) {
|
|
|
152
164
|
return oodaLogUnavailable(dir) || missingEvidenceUnavailable(record);
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
// Terminal-loop availability: true when the experiential terminal loop
|
|
168
|
+
// could not run. Signal 1 — a terminal-action OODA step exited 3 with NOT
|
|
169
|
+
// POSSIBLE in its observation (the terminal QA contract for "the CLI will
|
|
170
|
+
// not run"). Signal 2 — the verdict's own missing_evidence names CLI
|
|
171
|
+
// tool-unavailability. Same string-match shape as the visual version, never
|
|
172
|
+
// a judgment about report prose.
|
|
173
|
+
const TERMINAL_ACTIONS = { terminal: true };
|
|
174
|
+
const TERMINAL_TOOL_UNAVAILABLE = /(command not found|not recognized|no runtime|not possible|not installed|unavailable|could not (run|execute|launch)|no cli)/i;
|
|
175
|
+
|
|
176
|
+
function oodaLogTerminalUnavailable(dir) {
|
|
177
|
+
let text;
|
|
178
|
+
try {
|
|
179
|
+
text = readFileSync(join(dir, "ooda-log.jsonl"), "utf8");
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const lines = text.split("\n");
|
|
184
|
+
for (let k = 0; k < lines.length; k++) {
|
|
185
|
+
const line = lines[k].trim();
|
|
186
|
+
if (!line) continue;
|
|
187
|
+
let step;
|
|
188
|
+
try {
|
|
189
|
+
step = JSON.parse(line);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!step || !TERMINAL_ACTIONS[step.action]) continue;
|
|
194
|
+
if (step.exit === 3 && /not possible/i.test(String(step.observation || ""))) return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function missingEvidenceTerminalUnavailable(record) {
|
|
200
|
+
let me = record.missing_evidence;
|
|
201
|
+
if (me === undefined) me = record.missing;
|
|
202
|
+
if (!Array.isArray(me)) return false;
|
|
203
|
+
for (let k = 0; k < me.length; k++) {
|
|
204
|
+
if (TERMINAL_TOOL_UNAVAILABLE.test(String(me[k]))) return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function terminalLoopUnavailable(dir, record) {
|
|
210
|
+
return oodaLogTerminalUnavailable(dir) || missingEvidenceTerminalUnavailable(record);
|
|
211
|
+
}
|
|
212
|
+
|
|
155
213
|
main();
|
package/lib/schema.sql
CHANGED
|
@@ -25,6 +25,15 @@ CREATE TABLE IF NOT EXISTS projects (
|
|
|
25
25
|
simultaneity INTEGER NOT NULL DEFAULT 2 CHECK (simultaneity >= 0 AND simultaneity <= 100),
|
|
26
26
|
quiesced INTEGER NOT NULL DEFAULT 0 CHECK (quiesced IN (0, 1)),
|
|
27
27
|
visual_protocol INTEGER CHECK (visual_protocol IN (0, 1)),
|
|
28
|
+
-- environment_type: the project's user-facing surface for experiential QA.
|
|
29
|
+
-- 'artifact' = a rendered web UI served from a built artifact (Hazel drives
|
|
30
|
+
-- it with the see-act browser loop). 'terminal' = a command-line interface
|
|
31
|
+
-- (Hazel drives it herself and keeps attempt-scoped transcripts). NULL =
|
|
32
|
+
-- unclassified: no experiential QA — the same as today's non-artifact
|
|
33
|
+
-- behavior. Nullable by design: existing rows stay NULL (no backfill), and
|
|
34
|
+
-- deploy_type is NOT reused for this (it names the deployment target, not
|
|
35
|
+
-- the UX surface a user experiences).
|
|
36
|
+
environment_type TEXT CHECK (environment_type IN ('artifact', 'terminal')),
|
|
28
37
|
created_at TEXT NOT NULL,
|
|
29
38
|
updated_at TEXT NOT NULL
|
|
30
39
|
);
|
package/lib/update-watch.js
CHANGED
|
@@ -453,6 +453,20 @@ async function dashboardCheck(deps, ctx, summary) {
|
|
|
453
453
|
"\n" +
|
|
454
454
|
"Automatic dashboard upgrade: " + oldSha7 + " -> " + sha.slice(0, 7) + ".\n" +
|
|
455
455
|
"Filed by the update watcher (policy auto_update_dashboard).\n" +
|
|
456
|
+
// Enrollment guidance: when the watcher never recorded a trusted base,
|
|
457
|
+
// the old sha is "unknown" and the ancestor check below parks
|
|
458
|
+
// fail-closed by design. A human establishes the base once with the
|
|
459
|
+
// enroll command, then re-queues this task.
|
|
460
|
+
(!recorded
|
|
461
|
+
? "\nENROLLMENT: this crew has no trusted dashboard base — the watcher\n" +
|
|
462
|
+
"was never told which dashboard commit the live artifact was deployed\n" +
|
|
463
|
+
"from (fresh watcher state, or state reset). The ancestor check below\n" +
|
|
464
|
+
"cannot run against \"unknown\", so park this task and say so.\n" +
|
|
465
|
+
"A human establishes the base once, then re-queues:\n" +
|
|
466
|
+
" node " + crewHome + "/current/lib/update-watch.js --crew-home " + crewHome + " --record-dashboard-sha <sha>\n" +
|
|
467
|
+
"The sha must be the commit the live artifact was actually published\n" +
|
|
468
|
+
"from — verify it exists in " + proj.repo_path + ", never guess.\n"
|
|
469
|
+
: "") +
|
|
456
470
|
"\n" +
|
|
457
471
|
"Journey (execute mechanically, no judgment):\n" +
|
|
458
472
|
"1. In " + proj.repo_path + ": git fetch origin && git rev-parse origin/HEAD — must\n" +
|
|
@@ -487,6 +501,47 @@ async function dashboardCheck(deps, ctx, summary) {
|
|
|
487
501
|
}
|
|
488
502
|
}
|
|
489
503
|
|
|
504
|
+
// ── Dashboard enrollment ─────────────────────────────────────────────
|
|
505
|
+
// Records the trusted dashboard base sha in the watcher state. This is the
|
|
506
|
+
// enrollment operation: a human (or crew-init on first install) declares
|
|
507
|
+
// "this crew's dashboard artifact was deployed from this commit". The
|
|
508
|
+
// watcher files dashboard upgrades as <recorded> -> <remote>; without a
|
|
509
|
+
// recorded base the old sha is "unknown" and the filed task parks
|
|
510
|
+
// fail-closed by design. Moving an existing base is a human decision —
|
|
511
|
+
// pass { force: true }.
|
|
512
|
+
// Returns { ok, projectId, sha, action: "enrolled" | "moved" | "already" }.
|
|
513
|
+
async function enrollDashboardBase(deps, sha, opts) {
|
|
514
|
+
opts = opts || {};
|
|
515
|
+
const crewHome = deps.crewHome;
|
|
516
|
+
if (!/^[0-9a-f]{40}$/i.test(sha || "")) {
|
|
517
|
+
throw new Error("--record-dashboard-sha needs a 40-hex commit sha");
|
|
518
|
+
}
|
|
519
|
+
const projects = await deps.listProjects();
|
|
520
|
+
const proj = (projects || []).find(function (p) {
|
|
521
|
+
return p && p.deploy_type === "artifact" && p.repo_path;
|
|
522
|
+
});
|
|
523
|
+
if (!proj) throw new Error("no artifact project registered — cannot enroll a dashboard base");
|
|
524
|
+
const projectId = proj.id || proj.project_id;
|
|
525
|
+
try {
|
|
526
|
+
await deps.gitCatFile(proj.repo_path, sha);
|
|
527
|
+
} catch (e) {
|
|
528
|
+
throw new Error("sha " + sha.slice(0, 7) + " not found in " + proj.repo_path + " — verify the commit the live artifact was deployed from, never guess");
|
|
529
|
+
}
|
|
530
|
+
const state = readState(crewHome);
|
|
531
|
+
state.dashboard = state.dashboard || {};
|
|
532
|
+
const existing = state.dashboard[projectId] && state.dashboard[projectId].last_filed_sha;
|
|
533
|
+
if (existing && existing.toLowerCase() === sha.toLowerCase()) {
|
|
534
|
+
return { ok: true, projectId: projectId, sha: existing, action: "already" };
|
|
535
|
+
}
|
|
536
|
+
if (existing && !opts.force) {
|
|
537
|
+
throw new Error("dashboard base already enrolled as " + existing.slice(0, 7) + " — pass --force to move it (a human decision)");
|
|
538
|
+
}
|
|
539
|
+
state.dashboard[projectId] = { last_filed_sha: sha.toLowerCase() };
|
|
540
|
+
writeState(crewHome, state);
|
|
541
|
+
appendLog(crewHome, "enroll", "dashboard base " + (existing ? "moved" : "enrolled") + ": " + projectId + " @ " + sha.slice(0, 7));
|
|
542
|
+
return { ok: true, projectId: projectId, sha: sha.toLowerCase(), action: existing ? "moved" : "enrolled" };
|
|
543
|
+
}
|
|
544
|
+
|
|
490
545
|
// ── Pipeline ─────────────────────────────────────────────────────────
|
|
491
546
|
|
|
492
547
|
async function run(deps) {
|
|
@@ -585,6 +640,11 @@ function buildRealDeps(crewHome) {
|
|
|
585
640
|
// constant ".crew-version".
|
|
586
641
|
return shOut("git", ["-C", repoPath, "show", sha + ":" + filePath]).trim();
|
|
587
642
|
},
|
|
643
|
+
gitCatFile: function (repoPath, sha) {
|
|
644
|
+
// Throws when the object is missing or not a commit; the enrollment
|
|
645
|
+
// command fails closed on it.
|
|
646
|
+
return shOut("git", ["-C", repoPath, "cat-file", "-e", sha + "^{commit}"]).trim();
|
|
647
|
+
},
|
|
588
648
|
createTask: function (args) { return apiCall(crewHome, "create-task", args).task; }
|
|
589
649
|
};
|
|
590
650
|
}
|
|
@@ -594,13 +654,30 @@ function buildRealDeps(crewHome) {
|
|
|
594
654
|
function main() {
|
|
595
655
|
const args = process.argv.slice(2);
|
|
596
656
|
let crewHome = null;
|
|
657
|
+
let enrollSha = null;
|
|
658
|
+
let enrollForce = false;
|
|
597
659
|
for (let i = 0; i < args.length; i++) {
|
|
598
660
|
if (args[i] === "--crew-home" && i + 1 < args.length) crewHome = args[i + 1];
|
|
661
|
+
if (args[i] === "--record-dashboard-sha" && i + 1 < args.length) enrollSha = args[i + 1];
|
|
662
|
+
if (args[i] === "--force") enrollForce = true;
|
|
599
663
|
}
|
|
600
664
|
if (!crewHome) {
|
|
601
|
-
process.stderr.write("usage: node update-watch.js --crew-home <path
|
|
665
|
+
process.stderr.write("usage: node update-watch.js --crew-home <path> [--record-dashboard-sha <sha> [--force]]\n");
|
|
602
666
|
process.exit(2);
|
|
603
667
|
}
|
|
668
|
+
if (enrollSha) {
|
|
669
|
+
enrollDashboardBase(buildRealDeps(crewHome), enrollSha, { force: enrollForce }).then(
|
|
670
|
+
function (res) {
|
|
671
|
+
process.stdout.write("update-watch: dashboard base " + res.action + ": " + res.projectId + " @ " + res.sha.slice(0, 7) + "\n");
|
|
672
|
+
process.exit(0);
|
|
673
|
+
},
|
|
674
|
+
function (e) {
|
|
675
|
+
process.stderr.write("update-watch: enrollment failed (" + firstLine(e) + ")\n");
|
|
676
|
+
process.exit(1);
|
|
677
|
+
}
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
604
681
|
run(buildRealDeps(crewHome)).then(
|
|
605
682
|
function (res) {
|
|
606
683
|
res.summary.forEach(function (line) { process.stdout.write(line + "\n"); });
|
|
@@ -624,6 +701,7 @@ module.exports = {
|
|
|
624
701
|
dashboardDecision: dashboardDecision,
|
|
625
702
|
crewVersionOrdering: crewVersionOrdering,
|
|
626
703
|
fileCrewUpgrade: fileCrewUpgrade,
|
|
704
|
+
enrollDashboardBase: enrollDashboardBase,
|
|
627
705
|
readState: readState,
|
|
628
706
|
writeState: writeState,
|
|
629
707
|
appendLog: appendLog,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ux-doctrine.js — UX-surface doctrine page resolution.
|
|
2
|
+
//
|
|
3
|
+
// One canonical doctrine page per environment_type value: the crew reads
|
|
4
|
+
// the same page per surface, resolved mechanically from project config —
|
|
5
|
+
// never from prose, never hardcoded per-prompt. Adding a surface is one
|
|
6
|
+
// line in DOCTRINE_PAGES plus one docs page; the workflows resolve the
|
|
7
|
+
// page from environment_type and hand the path to every phase prompt.
|
|
8
|
+
//
|
|
9
|
+
// Workflows mirror this map inline (one line) because the workflow
|
|
10
|
+
// runtime's relative-import support is unverified — tests pin the mirror
|
|
11
|
+
// against this file, so the two can never silently diverge.
|
|
12
|
+
//
|
|
13
|
+
// Pure and deterministic: no clock, no randomness, no I/O.
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const { existsSync } = require("node:fs");
|
|
17
|
+
const { join } = require("node:path");
|
|
18
|
+
|
|
19
|
+
// THE MAP. environment_type value -> doctrine page filename, relative to
|
|
20
|
+
// the release docs dir ($CREW_HOME/current/docs/). null is unclassified —
|
|
21
|
+
// it maps to no page, never to a guess.
|
|
22
|
+
const DOCTRINE_PAGES = {
|
|
23
|
+
artifact: "artifact-ux.md",
|
|
24
|
+
terminal: "terminal-ux.md",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function doctrinePage(environmentType) {
|
|
28
|
+
if (environmentType == null) return null;
|
|
29
|
+
return Object.prototype.hasOwnProperty.call(DOCTRINE_PAGES, environmentType)
|
|
30
|
+
? DOCTRINE_PAGES[environmentType]
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Absolute path of the doctrine page for a crew home, or null when the
|
|
35
|
+
// surface is unclassified/unknown. Does not check the file exists —
|
|
36
|
+
// callers that need existence use doctrinePageExists.
|
|
37
|
+
function doctrinePath(crewHome, environmentType) {
|
|
38
|
+
const page = doctrinePage(environmentType);
|
|
39
|
+
return page ? join(crewHome, "current", "docs", page) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function doctrinePageExists(crewHome, environmentType) {
|
|
43
|
+
const p = doctrinePath(crewHome, environmentType);
|
|
44
|
+
return p ? existsSync(p) : false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { DOCTRINE_PAGES, doctrinePage, doctrinePath, doctrinePageExists };
|
|
48
|
+
|
|
49
|
+
if (require.main === module) {
|
|
50
|
+
// CLI: node ux-doctrine.js --page <environment_type>
|
|
51
|
+
// node ux-doctrine.js --path <crewHome> <environment_type>
|
|
52
|
+
const args = process.argv.slice(2);
|
|
53
|
+
if (args[0] === "--page" && args.length === 2) {
|
|
54
|
+
const page = doctrinePage(args[1] === "null" ? null : args[1]);
|
|
55
|
+
console.log(page === null ? "null" : page);
|
|
56
|
+
} else if (args[0] === "--path" && args.length === 3) {
|
|
57
|
+
const p = doctrinePath(args[1], args[2] === "null" ? null : args[2]);
|
|
58
|
+
console.log(p === null ? "null" : p);
|
|
59
|
+
} else {
|
|
60
|
+
console.error("usage: node ux-doctrine.js --page <environment_type|null> | --path <crewHome> <environment_type|null>");
|
|
61
|
+
process.exit(2);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/package.json
CHANGED