Compare commits

..
Author SHA1 Message Date
fredandscooby b09589f02c fix(tmux): confirm delivery by draft transition, not by prompt glyph (#1257)
ci/woodpecker/pr/ci Pipeline failed
send-message.sh located the REPL input box with grep -E '❯|^>|│ >'. That set is
Claude Code's box. A pi seat renders a bare U+2500 rule with no glyph, so on every
idle pi seat the capture succeeded, the grep matched nothing, status stayed
"unconfirmed", and the tool exited 2 "may be UNDELIVERED" with the paste and the
Enter both landed. The stderr tells the operator to retry, and that retry is the
duplicate delivery reported against the same tool.

Confirmation is now runtime-agnostic: our message tail sits on the input line
(located by cursor row, no glyph) before Enter and has left it after. That
transition is positive proof of submission.

Absence still proves nothing, which is the guard the 2026-08 fix was reaching for
and got backwards. Two positive checks keep it:

  - a prompt box that IS locatable and still carries our tail => draft, exit 2.
    This covers the cursor-row blind spot: a cooked pane whose foreground process
    never reads stdin echoes the paste through the kernel line discipline and
    moves the cursor off it on Enter, which by cursor row alone is indistinguishable
    from a real submit.
  - no draft ever observed on the input line => unconfirmed, non-zero.

Tests, both red-first against the shipping blob d397907:

  test-send-message-glyph-agnostic.sh (new, 6 fixtures)  4/6 -> 6/6
  test-send-message-verdict.sh (fixture 2 reshaped, 2b added)  3/4 -> 4/4

Fixture 2 of the verdict suite asserted exit 2 for a glyphless pane that submits
and was labelled "false-positive FIXED". A pi seat is that fixture, so the suite
was locking the bug in. It is reshaped deliberately, and the guard it was credited
with moves to new fixture 2b (glyphless AND non-submitting, raw/no-echo) so the
"never infer delivered from absence" property is tested positively rather than as
a side effect.

Measured on tmux 3.7b (sb-it-1-dt), 3.5a (fomo-lin), and dragon-lin.

Co-authored-by: scooby <[email protected]>
2026-08-16 16:35:36 -05:00
17 changed files with 241 additions and 1599 deletions
@@ -39,20 +39,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.) # recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
@@ -97,13 +97,34 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever. # would otherwise accumulate forever.
sleep 0.5 sleep 0.5
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is # 2) Submit, then POSITIVELY confirm submission by DRAFT TRANSITION, not by prompt
# still a draft. Success requires positive evidence — the queued banner, OR the # glyph. The historical bug was treating ABSENCE of a draft as delivery; the
# REPL input box located AND clear of our message tail. The historical bug was # 2026-08 fix over-corrected to glyph inference (grep '|^>|│ >'), which locates
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched # only Claude Code's box and false-NEGATIVES every glyphless REPL (pi renders a
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered" # U+2500 rule, no glyph) — a delivered message reported "UNDELIVERED", driving a
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only # retry that duplicates it. Runtime-agnostic evidence: our message tail sits on
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud. # the INPUT line (located by the cursor row, not a glyph) BEFORE Enter, and has
# LEFT it AFTER — that transition is positive proof of submission and needs no
# glyph. Absence alone still never means delivered: if we never saw our draft on
# the input line we stay UNCONFIRMED (wrong/dead pane), and a draft that never
# leaves the input line stays a DRAFT (exit 2), preserving both historical guards.
_cursor_line() { # echo the pane's current input (cursor) line, glyph-free
local cy line
cy=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" -F '#{cursor_y}' 2>/dev/null) || return 1
[ -n "$cy" ] || return 1
"${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null | sed -n "$((cy + 1))p"
}
_draft_on_input() { # true iff our message tail is sitting on the input line now
[ -n "$snippet" ] || return 1
printf '%s' "$(_cursor_line)" | grep -qF "$snippet"
}
# Baseline: after the paste, our draft must be on the input line. This is positive
# proof we are on the right pane and the paste landed — the anchor the transition
# check measures against.
saw_draft=0
_draft_on_input && saw_draft=1
status="unconfirmed" status="unconfirmed"
for attempt in $(seq 1 $((RETRIES + 1))); do for attempt in $(seq 1 $((RETRIES + 1))); do
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
@@ -113,20 +134,26 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
status="queued"; break status="queued"; break
fi fi
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO # POSITIVE draft evidence from a located prompt box, when one exists. This is the
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery. # cursor-row check's blind spot: a pane in COOKED mode (a plain shell whose
# foreground process never reads stdin) echoes our paste via the kernel line
# discipline and moves the cursor off it on Enter, which is indistinguishable from
# a real submit by cursor row alone. If a prompt box IS locatable and still carries
# our tail, that is affirmative proof the message was not consumed. Absence of a
# glyph is still never used for anything — that inference is the original E7 bug.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1) promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then if [ -n "$promptline" ] && [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
status="draft"; continue status="draft"; continue
fi fi
# Input box located AND clear of our tail => positively submitted. This is the if [ "$saw_draft" = 1 ]; then
# only path to success besides the queued banner. if _draft_on_input; then
status="delivered"; break status="draft"; continue # still on the input line => not submitted; flush + retry
fi
status="delivered"; break # left the input line => positively submitted
fi
# No confirmed baseline yet: try to (re)acquire it; never infer delivery from absence.
if _draft_on_input; then saw_draft=1; status="draft"; continue; fi
status="unconfirmed"; continue
done done
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; } [ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Red-first regression test for E7 (#1017 task 2): the confirm-check must bind
# "delivered" to WHETHER THE MESSAGE WAS SUBMITTED, not to which runtime's prompt
# glyph is present. A pi seat renders a U+2500 rule input box with no /^>/│ >
# glyph; send-message.sh:118 locates the box only by glyph, so a genuinely
# delivered message on a glyphless REPL falsely reports exit 2 "may be UNDELIVERED",
# and the operator's rc=2-driven retry duplicates it.
#
# Parameterized on $SEND: RED against the shipping blob (B and D fail), GREEN
# against a candidate patch. No pi; no fake HOME; hermetic throwaway socket.
#
# Submission counting is EXACT and terminal-echo-independent: the fixture message
# is `echo <tok> >>SINK`; each real submission appends one line. wc -l SINK ==
# number of times the REPL actually executed the send. This does not depend on how
# many times the marker string is painted on screen.
set -u
SEND="${SEND:?set SEND=/path/to/send-message.sh}"
SOCKET="glyphagnostic-$$"
TMP="$(mktemp -d)"
tmux() { command tmux -L "$SOCKET" "$@"; }
cleanup() { command tmux -L "$SOCKET" kill-server 2>/dev/null; rm -rf "$TMP"; }
trap cleanup EXIT
pass=0; fail=0
ok() { printf 'ok %s\n' "$1"; pass=$((pass+1)); }
no() { printf 'FAIL %s -- %s\n' "$1" "$2"; fail=$((fail+1)); }
mk() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" "PS1='$2' exec bash --noprofile --norc -i"; sleep 0.5; }
subs() { [ -f "$1" ] && wc -l <"$1" | tr -d ' ' || echo 0; } # exact submission count
echo "SEND=$SEND tmux $(command tmux -V | awk '{print $2}')"
# --- A (control): glyph box () that submits => exit 0, exactly one submission.
mk ctl ' '
SINK="$TMP/sink.ctl"
out=$("$SEND" -L "$SOCKET" -t ctl -m "echo x >>'$SINK'" 2>"$TMP/e.ctl"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "control: -box submits => exit 0, exactly one submission"
else no "control: -box submits => exit 0, one submission" "rc=$rc subs=$(subs "$SINK") err=[$(cat "$TMP/e.ctl")]"; fi
# --- B (THE false-rc regression): glyphless U+2500 box that SUBMITS. Message lands
# (subs==1) yet shipping reports exit 2. Must be exit 0.
mk sub $'──────── \n'
SINK="$TMP/sink.sub"
out=$("$SEND" -L "$SOCKET" -t sub -m "echo x >>'$SINK'" 2>"$TMP/e.sub"); rc=$?; sleep 0.4
if [ "$rc" = 0 ] && [ "$(subs "$SINK")" = 1 ]; then
ok "glyphless: U+2500 box that submits => exit 0 (delivered, not 'UNDELIVERED')"
else no "glyphless: U+2500 box that submits => exit 0" \
"rc=$rc subs=$(subs "$SINK")(delivered=$([ "$(subs "$SINK")" -ge 1 ] && echo yes||echo no)) err=[$(cat "$TMP/e.sub")]"; fi
# --- D (duplicate arm): operator follows the rc=2 stderr and retries once. On the
# glyphless box, shipping => two submissions (the reported duplicate). The
# property: one logical send => exactly one submission. Same fix closes it.
mk dup $'──────── \n'
SINK="$TMP/sink.dup"
tries=0
for attempt in 1 2; do
tries=$((tries+1))
out=$("$SEND" -L "$SOCKET" -t dup -m "echo x >>'$SINK'" 2>/dev/null); rc=$?
sleep 0.4
[ "$rc" = 0 ] && break # operator stops retrying only when told delivered
done
if [ "$(subs "$SINK")" = 1 ]; then
ok "duplicate: one logical send (rc-driven retry) => exactly one submission (tries=$tries)"
else no "duplicate: one logical send => exactly one submission" "submissions=$(subs "$SINK") tries=$tries"; fi
# --- E (faithful hung managed TUI, NOT a cooked shell): raw/no-echo, paints nothing.
# A cooked `sleep infinity` echoes the paste via the kernel line discipline and
# false-passes a cursor-row fix that is correct on real seats (measured). So: raw.
mk_rawstuck() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity'"; sleep 0.5; }
mk_rawstuck estuck
SINK="$TMP/sink.estuck"
out=$("$SEND" -L "$SOCKET" -t estuck -r 1 -m "this stuck draft was never submitted" 2>/dev/null); rc=$?
sleep 0.3
if [ "$rc" != 0 ] && [ "$(subs "$SINK")" = 0 ]; then
ok "raw/no-echo stuck TUI (not submitted) => non-zero (no false delivered)"
else no "raw stuck TUI must NOT report delivered" "rc=$rc subs=$(subs "$SINK")"; fi
# --- F (busy/queued branch, your BUSY-not-runtime finding): glyphless pane rendering the
# queued banner, never consuming. QUEUED_RE :113 fires before the glyph grep => rc=0.
mk_busy() { tmux new-session -d -s "$1" -x 120 -y 40 -c "$TMP" \
"bash --noprofile --norc -c 'printf \"Press up to edit queued messages\n\"; exec sleep infinity'"; sleep 0.5; }
mk_busy ebusy
SINK="$TMP/sink.ebusy"
out=$("$SEND" -L "$SOCKET" -t ebusy -m "echo x >>'$SINK'" 2>/dev/null); rc=$?; sleep 0.3
if [ "$rc" = 0 ]; then
ok "busy/queued-banner glyphless => exit 0 (queued is delivery; runtime owns custody)"
else no "busy/queued-banner must report delivered" "rc=$rc"; fi
# --- C (historical-bug guard): unresolvable target. No pane ever carried our draft
# => must fail, never infer delivered from absence of a glyph/snippet.
if out=$("$SEND" -L "$SOCKET" -t "nonexistent-$$" -m "echo x >>'$TMP/sink.wrong'" 2>/dev/null); then
no "wrong-pane: unresolvable target must NOT report success" "expected non-zero, got 0"
else ok "wrong-pane: unresolvable target => non-zero (no false delivered)"; fi
echo "---"; echo "pass=$pass fail=$fail"
[ "$fail" = 0 ]
@@ -4,10 +4,13 @@
# #
# 1. DELIVERED — a REPL that renders a ` ` input box and submits on Enter # 1. DELIVERED — a REPL that renders a ` ` input box and submits on Enter
# (text scrolls to history, box clears) => exit 0 "✓ delivered". # (text scrolls to history, box clears) => exit 0 "✓ delivered".
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact # 2. DELIVERED — a pane with NO prompt glyph that DOES submit => exit 0. A pi
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered" # seat is this fixture (U+2500 rule, no glyph). Reshaped for
# exit 0; post-patch it MUST fail loud (exit 2, stderr # #1257; see the note at the fixture for why the old exit-2
# "could not confirm submission"). # assertion was wrong.
# 2b. UNCONFIRMED— a glyphless pane that never submits (raw/no-echo hung TUI)
# => must fail loud. This carries the historical
# false-positive guard that fixture 2 used to be credited with.
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the # 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft". # input line) => exit 2, stderr "unsubmitted draft".
set -uo pipefail set -uo pipefail
@@ -37,19 +40,44 @@ else
no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]" no "delivered: -prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
fi fi
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this # --- Fixture 2: NO prompt glyph, and the pane DOES submit (interactive bash).
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2. # RESHAPED 2026-08-16 (#1257), deliberately. This fixture previously asserted
# exit 2 here and was labelled "false-positive FIXED". That assertion was wrong,
# and locking it in is what kept E7 alive: the pane submits, so "delivered" is
# the truth, and a pi seat — whose input box is a bare U+2500 rule with no glyph
# — IS this fixture. Reporting exit 2 for it told operators a delivered message
# may be undelivered, and the retry that advice invites is the duplicate.
#
# The guard this fixture was reaching for is real and is NOT dropped: "never
# infer delivered from absence" is now enforced positively by fixture 2b below
# (glyphless AND not submitting => must fail) and by fixture 3 (locatable box
# still carrying our tail => draft). Absence alone decides nothing either way.
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \ tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i' 'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
sleep 0.3 sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); rc=$?
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])" if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -qF "✓ delivered"; then
ok "delivered: glyphless pane that submits => exit 0 (runtime-agnostic, E7 FIXED)"
else
no "delivered: glyphless pane that submits => exit 0" "rc=$rc out=[$out] err=[$(cat "$TMP/e2")]"
fi
# --- Fixture 2b: NO prompt glyph AND never submits — a hung managed TUI holding the
# terminal in raw/no-echo, which is what a stuck agent seat actually is (measured
# on live pi: stty -echo -icanon). Nothing is echoed, nothing is consumed, so
# there is no positive evidence of submission and the tool MUST fail loud. This
# is the historical false-positive guard, kept as a positive test.
tmux -L "$SOCKET" new-session -d -s rawstuck -c "$TMP" \
'bash --noprofile --norc -c "stty -echo -icanon min 1 time 0 2>/dev/null; exec sleep infinity"'
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=rawstuck" -r 1 -m "verdict fixture two-b never submitted" 2>"$TMP/e2b"); then
no "unconfirmed: glyphless hung TUI must NOT report success" "expected non-zero, got 0 (out=[$out])"
else else
rc=$? rc=$?
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then if [ "$rc" -ne 0 ] && grep -qF "could not confirm submission" "$TMP/e2b"; then
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)" ok "unconfirmed: glyphless hung TUI (raw/no-echo) => non-zero + 'could not confirm submission'"
else else
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]" no "unconfirmed: glyphless hung TUI => non-zero + stderr" "rc=$rc err=[$(cat "$TMP/e2b")]"
fi fi
fi fi
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/brain": "workspace:*", "@mosaicstack/brain": "workspace:*",
@@ -1,149 +0,0 @@
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerMissionCommand } from './mission.js';
import { PrdService } from '@mosaicstack/prdy';
import type { MissionInfo } from '../tui/gateway-api.js';
// ── Mocks: the gateway is not available in adapter tests ──────────────────────
// vi.hoisted: the mock factory is hoisted above imports, so the fixture must
// be initialized there too.
const MISSION = vi.hoisted(
(): MissionInfo => ({
id: 'mission-plan-1',
name: 'Plan Mission Alpha',
description: null,
status: 'planning',
projectId: null,
userId: null,
phase: null,
milestones: null,
config: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-03-04T05:06:07.000Z',
}),
);
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchMissions: vi.fn().mockResolvedValue([MISSION]),
fetchMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
fetchMissionTasks: vi.fn().mockResolvedValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
const originalCwd = process.cwd();
let projectDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerMissionCommand(program);
return program;
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-'));
process.chdir(projectDir);
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleStub.push(logSpy);
});
afterEach(() => {
// Restore only the per-test spies; module factory mocks keep their
// implementations across tests.
for (const stub of consoleStub) stub.mockRestore();
consoleStub = [];
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic mission --plan (thin adapter over PrdService)', () => {
it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// PRD landed in the same store `mosaic prdy` uses.
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
// Fresh service instance (new-process equivalent) reads the linkage back.
const service = new PrdService({ projectPath: projectDir });
const docs = await service.list();
expect(docs).toHaveLength(1);
const prd = docs[0]!;
expect(prd.title).toBe('Plan Mission Alpha');
expect(prd.version).toBe(1);
const links = await service.listMissionLinks(prd.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: MISSION.id,
missionVersion: MISSION.updatedAt, // mission version marker
prdVersion: 1,
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked'));
});
it('linkage is persisted in the YAML authority document itself (survives restart)', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8');
const persisted = parseYaml(raw) as { missions: Array<Record<string, unknown>> };
expect(persisted.missions).toHaveLength(1);
expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' });
});
it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => {
// Mission path.
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// prdy path (service, non-interactive entry).
const service = new PrdService({ projectPath: projectDir });
const direct = await service.create({ name: 'Directly Created' });
const all = await service.list();
expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort());
expect(all).toHaveLength(2);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toContain(`${direct.id}.yaml`);
// Both are v1 in the same store with distinct stable ids.
for (const doc of all) {
expect(doc.version).toBe(1);
expect(files).toContain(`${doc.id}.yaml`);
}
});
});
+3 -30
View File
@@ -256,41 +256,14 @@ async function planMission(
console.log(`Planning mission: ${mission.name}\n`); console.log(`Planning mission: ${mission.name}\n`);
try { try {
// Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in const { runPrdWizard } = await import('@mosaicstack/prdy');
// PrdService — no second writer path. The mission's updatedAt serves as await runPrdWizard({
// its version marker (the gateway exposes no numeric mission version).
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
if (process.stdout.isTTY) {
const created = await runPrdWizard({
name: mission.name, name: mission.name,
projectPath: process.cwd(), projectPath: process.cwd(),
interactive: true, interactive: true,
}); });
const linked = await service.linkMission({
prdId: created.id,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
});
console.log(
`\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`,
);
return;
}
const doc = await service.planForMission({
name: mission.name,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
});
console.log(
`PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`,
);
} catch (err) { } catch (err) {
console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`); console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1); process.exit(1);
} }
} }
-204
View File
@@ -1,204 +0,0 @@
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerPrdyCommand } from './prdy.js';
import { PrdService } from '@mosaicstack/prdy';
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
// outside the tmp project dir) ──────────────────────────────────────────────
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
class ProcessExitError extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
}
}
function stubProcessExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new ProcessExitError(code ?? 0);
}) as never);
}
const originalCwd = process.cwd();
let projectDir: string;
let errorSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let exitStub: ReturnType<typeof stubProcessExit>;
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerPrdyCommand(program);
return program;
}
function runPrdy(args: string[]): Promise<unknown> {
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
}
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'cmd-import-prd',
title: 'Command Import PRD',
status: 'approved', // must be forced to draft: validity is not approval
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
process.chdir(projectDir);
exitStub = stubProcessExit();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore only the per-test spies: module factory mocks must keep their
// implementations for the next test.
exitStub.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic prdy (thin adapter over PrdService)', () => {
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
await runPrdy(['--init', 'Adapter Created']);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.title).toBe('Adapter Created');
expect(docs[0]?.version).toBe(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
});
it('--import <file> creates a valid import through the service', async () => {
const filePath = path.join(projectDir, 'incoming.yaml');
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
await runPrdy(['--import', filePath]);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.id).toBe('cmd-import-prd');
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
});
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
const filePath = path.join(projectDir, 'broken.yaml');
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
// Typed refusal surfaced to the user, nothing created.
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Conflict Target' });
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
const beforeBytes = await readFile(storeFile, 'utf8');
const filePath = path.join(projectDir, 'divergent.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Divergent Command Import',
}),
),
'utf8',
);
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
// Original authority document is byte-identical on disk.
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
});
it('--import --accept-successor persists the successor version explicitly', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Successor Target' });
const filePath = path.join(projectDir, 'divergent2.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Accepted Via CLI',
}),
),
'utf8',
);
await runPrdy(['--import', filePath, '--accept-successor']);
const doc = await service.get(existing.id);
expect(doc.version).toBe(2);
expect(doc.title).toBe('Accepted Via CLI');
expect(doc.status).toBe('draft');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
});
it('--export writes a labeled generated view and never touches authority', async () => {
const service = new PrdService({ projectPath: projectDir });
const created = await service.create({ name: 'Export Via CLI' });
const before = await service.get(created.id);
await runPrdy(['--export', created.id]);
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
const md = await readFile(mdPath, 'utf8');
expect(md).toContain('generated view — do not edit');
expect(md).toContain(`prd-id: ${created.id}`);
expect(md).toContain('prd-version: 1');
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining(`Generated view written: ${mdPath}`),
);
// Authority unchanged by the export.
expect(await service.get(created.id)).toEqual(before);
});
});
+1 -60
View File
@@ -2,10 +2,6 @@ import type { Command } from 'commander';
import { withAuth } from './with-auth.js'; import { withAuth } from './with-auth.js';
import { fetchProjects } from '../tui/gateway-api.js'; import { fetchProjects } from '../tui/gateway-api.js';
/**
* `mosaic prdy` — thin adapter over PrdService (@mosaicstack/prdy).
* All reads/writes go through the service; there is no local writer path.
*/
export function registerPrdyCommand(program: Command) { export function registerPrdyCommand(program: Command) {
const cmd = program const cmd = program
.command('prdy') .command('prdy')
@@ -13,18 +9,12 @@ export function registerPrdyCommand(program: Command) {
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242') .option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD') .option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD') .option('--update [name]', 'Update an existing PRD')
.option('--import <file>', 'Import a YAML PRD document (validated, conflict-aware)')
.option('--accept-successor', 'With --import: accept a conflicted import as next version')
.option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file')
.option('--project <idOrName>', 'Scope to project') .option('--project <idOrName>', 'Scope to project')
.action( .action(
async (opts: { async (opts: {
gateway: string; gateway: string;
init?: string | boolean; init?: string | boolean;
update?: string | boolean; update?: string | boolean;
import?: string;
acceptSuccessor?: boolean;
export?: string | boolean;
project?: string; project?: string;
}) => { }) => {
// Detect project context when --project flag is provided // Detect project context when --project flag is provided
@@ -41,69 +31,20 @@ export function registerPrdyCommand(program: Command) {
} }
} }
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
try { try {
if (opts.import !== undefined) { const { runPrdWizard } = await import('@mosaicstack/prdy');
const input = { filePath: opts.import };
if (opts.acceptSuccessor) {
const successor = await service.acceptSuccessor(input);
console.log(
`Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`,
);
return;
}
const result = await service.importDocument(input);
console.log(
result.kind === 'created'
? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})`
: `PRD ${result.document.id} already present with identical content — nothing to do.`,
);
return;
}
if (opts.export !== undefined) {
const id =
typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined;
const result = await service.exportMarkdown({ id });
console.log(
`Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`,
);
return;
}
const name = const name =
typeof opts.init === 'string' typeof opts.init === 'string'
? opts.init ? opts.init
: typeof opts.update === 'string' : typeof opts.update === 'string'
? opts.update ? opts.update
: 'untitled'; : 'untitled';
if (process.stdout.isTTY) {
await runPrdWizard({ await runPrdWizard({
name, name,
projectPath: process.cwd(), projectPath: process.cwd(),
interactive: true, interactive: true,
}); });
return;
}
// Non-interactive fallback routes through the service directly.
const doc = await service.create({ name });
console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`);
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === 'PrdImportConflictError') {
const conflict = err as { proposal?: { version?: number } };
console.error(`${err.message}`);
console.error(
`Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`,
);
process.exit(1);
}
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`); console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1); process.exit(1);
} }
+14 -74
View File
@@ -1,6 +1,6 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { PrdService } from './service.js'; import { createPrd, listPrds, loadPrd } from './prd.js';
import { runPrdWizard } from './wizard.js'; import { runPrdWizard } from './wizard.js';
interface InitCommandOptions { interface InitCommandOptions {
@@ -18,22 +18,6 @@ interface ShowCommandOptions {
readonly id?: string; readonly id?: string;
} }
interface ImportCommandOptions {
readonly project: string;
readonly file: string;
readonly acceptSuccessor?: boolean;
}
interface ExportCommandOptions {
readonly project: string;
readonly id?: string;
readonly out?: string;
}
function serviceFor(project: string): PrdService {
return new PrdService({ projectPath: project });
}
export function buildPrdyCli(): Command { export function buildPrdyCli(): Command {
const program = new Command(); const program = new Command();
program.name('mosaic').description('Mosaic CLI').exitOverride(); program.name('mosaic').description('Mosaic CLI').exitOverride();
@@ -54,9 +38,11 @@ export function buildPrdyCli(): Command {
template: options.template, template: options.template,
interactive: true, interactive: true,
}) })
: await serviceFor(options.project).create({ : await createPrd({
name: options.name, name: options.name,
projectPath: options.project,
template: options.template, template: options.template,
interactive: false,
}); });
console.log( console.log(
@@ -66,7 +52,6 @@ export function buildPrdyCli(): Command {
id: doc.id, id: doc.id,
title: doc.title, title: doc.title,
status: doc.status, status: doc.status,
version: doc.version,
projectPath: doc.projectPath, projectPath: doc.projectPath,
}, },
null, null,
@@ -80,7 +65,7 @@ export function buildPrdyCli(): Command {
.description('List PRD documents for a project') .description('List PRD documents for a project')
.requiredOption('--project <path>', 'Project path') .requiredOption('--project <path>', 'Project path')
.action(async (options: ListCommandOptions) => { .action(async (options: ListCommandOptions) => {
const docs = await serviceFor(options.project).list(); const docs = await listPrds(options.project);
console.log(JSON.stringify(docs, null, 2)); console.log(JSON.stringify(docs, null, 2));
}); });
@@ -90,65 +75,20 @@ export function buildPrdyCli(): Command {
.requiredOption('--project <path>', 'Project path') .requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id') .option('--id <id>', 'PRD document id')
.action(async (options: ShowCommandOptions) => { .action(async (options: ShowCommandOptions) => {
const doc = await serviceFor(options.project).get(options.id); if (options.id !== undefined) {
console.log(JSON.stringify(doc, null, 2)); const docs = await listPrds(options.project);
}); const match = docs.find((doc) => doc.id === options.id);
prdy if (match === undefined) {
.command('import') throw new Error(`PRD id not found: ${options.id}`);
.description('Import a YAML PRD document (validated; conflicts propose a successor)') }
.requiredOption('--project <path>', 'Project path')
.requiredOption('--file <file>', 'Path to YAML PRD document')
.option('--accept-successor', 'Accept a conflicted import as the next version')
.action(async (options: ImportCommandOptions) => {
const service = serviceFor(options.project);
const input = { filePath: options.file };
if (options.acceptSuccessor) { console.log(JSON.stringify(match, null, 2));
const successor = await service.acceptSuccessor(input);
console.log(
JSON.stringify(
{
ok: true,
outcome: 'successor-accepted',
id: successor.id,
version: successor.version,
},
null,
2,
),
);
return; return;
} }
const result = await service.importDocument(input); const doc = await loadPrd(options.project);
console.log( console.log(JSON.stringify(doc, null, 2));
JSON.stringify(
{
ok: true,
outcome: result.kind,
id: result.document.id,
version: result.document.version,
status: result.document.status,
},
null,
2,
),
);
});
prdy
.command('export')
.description('Render a PRD to a labeled generated-view Markdown file')
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.option('--out <path>', 'Output path (default docs/prdy/<id>.md)')
.action(async (options: ExportCommandOptions) => {
const result = await serviceFor(options.project).exportMarkdown({
id: options.id,
outPath: options.out,
});
console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2));
}); });
return program; return program;
+1 -24
View File
@@ -1,35 +1,12 @@
// PrdService is the single authority surface for PRD documents. The raw store export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
// writers (createPrd/savePrd) are deliberately NOT exported: every mutation
// goes through the service so there is no second writer path.
export { loadPrd, listPrds, parsePrdDocument } from './prd.js';
export { runPrdWizard } from './wizard.js'; export { runPrdWizard } from './wizard.js';
export { buildPrdyCli, runPrdyCli } from './cli.js'; export { buildPrdyCli, runPrdyCli } from './cli.js';
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js'; export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
export {
PrdService,
PRD_GENERATED_VIEW_LABEL,
PrdError,
PrdNotFoundError,
PrdUpdateError,
PrdImportInvalidError,
PrdImportConflictError,
} from './service.js';
export type { export type {
PrdStatus, PrdStatus,
PrdTemplate, PrdTemplate,
PrdTemplateSection, PrdTemplateSection,
PrdSection, PrdSection,
PrdMissionLinkage,
PrdDocument, PrdDocument,
CreatePrdOptions, CreatePrdOptions,
PrdServiceOptions,
PrdCreateInput,
PrdSectionPatch,
PrdUpdateInput,
PrdLinkMissionInput,
PrdPlanForMissionInput,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
} from './types.js'; } from './types.js';
+1 -37
View File
@@ -17,49 +17,17 @@ const prdSectionSchema = z.object({
fields: z.record(z.string(), z.string()), fields: z.record(z.string(), z.string()),
}); });
const prdMissionLinkageSchema = z.object({
missionId: z.string().min(1),
missionVersion: z.string().min(1),
prdVersion: z.number().int().min(1),
requirementIds: z.array(z.string()),
linkedAt: z.string().datetime(),
});
const prdDocumentSchema = z.object({ const prdDocumentSchema = z.object({
id: z.string().min(1), id: z.string().min(1),
title: z.string().min(1), title: z.string().min(1),
status: z.enum(['draft', 'review', 'approved', 'archived']), status: z.enum(['draft', 'review', 'approved', 'archived']),
projectPath: z.string().min(1), projectPath: z.string().min(1),
template: z.string().min(1), template: z.string().min(1),
// Defaults keep documents written by older prdy versions loadable.
version: z.number().int().min(1).default(1),
sections: z.array(prdSectionSchema), sections: z.array(prdSectionSchema),
missions: z.array(prdMissionLinkageSchema).default([]),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}); });
/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */
function coerceTimestamps(value: unknown): unknown {
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(coerceTimestamps);
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]),
);
}
return value;
}
/** Validate an unknown value as a PRD document (throws zod errors on failure). */
export function parsePrdDocument(value: unknown): PrdDocument {
return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument;
}
function expandHome(projectPath: string): string { function expandHome(projectPath: string): string {
if (!projectPath.startsWith('~')) { if (!projectPath.startsWith('~')) {
return projectPath; return projectPath;
@@ -106,8 +74,6 @@ function prdDirectory(projectPath: string): string {
return path.join(projectPath, PRD_DIRECTORY); return path.join(projectPath, PRD_DIRECTORY);
} }
export { prdDirectory };
function prdFilePath(projectPath: string, id: string): string { function prdFilePath(projectPath: string, id: string): string {
return path.join(prdDirectory(projectPath), `${id}.yaml`); return path.join(prdDirectory(projectPath), `${id}.yaml`);
} }
@@ -147,13 +113,11 @@ export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument>
status: 'draft', status: 'draft',
projectPath: resolvedProjectPath, projectPath: resolvedProjectPath,
template: template.id, template: template.id,
version: 1,
sections: template.sections.map((section) => ({ sections: template.sections.map((section) => ({
id: section.id, id: section.id,
title: section.title, title: section.title,
fields: Object.fromEntries(section.fields.map((field) => [field, ''])), fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
})), })),
missions: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -226,7 +190,7 @@ export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`); throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
} }
const document = parsePrdDocument(parsed); const document = prdDocumentSchema.parse(parsed);
documents.push(document); documents.push(document);
} }
-433
View File
@@ -1,433 +0,0 @@
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { beforeEach, describe, expect, it } from 'vitest';
import {
PRD_GENERATED_VIEW_LABEL,
PrdImportConflictError,
PrdImportInvalidError,
PrdNotFoundError,
PrdService,
PrdUpdateError,
} from './index.js';
import type { PrdDocument } from './index.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
let projectDir: string;
async function makeProject(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
}
function service(): PrdService {
return new PrdService({ projectPath: projectDir });
}
function storeDir(): string {
return path.join(projectDir, 'docs', 'prdy');
}
/** Handcraft a full, schema-valid PRD document for import scenarios. */
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
return {
id: 'imported-prd-20260101-000000',
title: 'Imported PRD',
status: 'draft',
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
{
id: 'scope-non-goals',
title: 'Scope / Non-Goals',
fields: { inScope: '', outOfScope: '' },
},
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
async function writeImportFile(doc: PrdDocument): Promise<string> {
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
await writeFile(filePath, yaml.dump(doc), 'utf8');
return filePath;
}
beforeEach(async () => {
projectDir = await makeProject();
});
// ── Single authority store (AC: prdy path and mission path resolve to the
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
describe('PrdService single authority store', () => {
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
const direct = await service().create({ name: 'Direct PRD' });
const viaMission = await service().planForMission({
name: 'Mission PRD',
missionId: 'mission-1',
missionVersion: '2026-01-01T00:00:00.000Z',
});
const files = await readdir(storeDir());
expect(files).toContain(`${direct.id}.yaml`);
expect(files).toContain(`${viaMission.id}.yaml`);
// A fresh service instance (new process equivalent) resolves both.
const all = await service().list();
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
// Stable versions: creation is v1; linkage writes do not bump content version.
expect((await service().get(direct.id)).version).toBe(1);
expect((await service().get(viaMission.id)).version).toBe(1);
});
it('round-trips documents through the store with identity intact', async () => {
const created = await service().create({ name: 'Round Trip', template: 'feature' });
const fresh = await service().get(created.id);
expect(fresh).toEqual(created);
expect(fresh.id).toBe(created.id);
expect(fresh.template).toBe('feature');
expect(fresh.status).toBe('draft');
});
it('throws a typed error for unknown ids and empty stores', async () => {
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── Mission linkage persistence (AC: linkage survives restart via fresh
// service instances) ────────────────────────────────────────────────────────
describe('PrdService mission linkage', () => {
it('persists linkage and reads it back from a fresh service instance', async () => {
const created = await service().planForMission({
name: 'Linked PRD',
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
requirementIds: ['FR-1', 'FR-2'],
});
// Fresh instance — nothing in memory from the creating call.
const links = await service().listMissionLinks(created.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
prdVersion: 1,
requirementIds: ['FR-1', 'FR-2'],
});
// Linkage is carried in the YAML authority file itself.
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const persisted = yaml.load(raw) as PrdDocument;
expect(persisted.missions[0]?.missionId).toBe('mission-42');
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
});
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
const created = await service().planForMission({
name: 'Relink PRD',
missionId: 'mission-7',
missionVersion: 'v1',
});
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
});
const relinked = await service().linkMission({
prdId: created.id,
missionId: 'mission-7',
missionVersion: 'v2',
requirementIds: ['NFR-1'],
});
expect(relinked.missions).toHaveLength(1);
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
});
it('does not bump the content version when writing linkage', async () => {
const created = await service().create({ name: 'Stable Version' });
const linked = await service().linkMission({
prdId: created.id,
missionId: 'm',
missionVersion: 'v1',
});
expect(linked.version).toBe(1);
});
});
// ── Update semantics ──────────────────────────────────────────────────────────
describe('PrdService update', () => {
it('applies section patches and bumps the content version', async () => {
const created = await service().create({ name: 'Updatable' });
const updated = await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
});
expect(updated.version).toBe(2);
expect(updated.sections[0]?.fields).toMatchObject({
context: 'Some context',
objective: 'Goal',
});
expect((await service().get(created.id)).version).toBe(2);
});
it('refuses unknown section ids with a typed error', async () => {
const created = await service().create({ name: 'Strict' });
await expect(
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
).rejects.toBeInstanceOf(PrdUpdateError);
});
});
// ── Markdown export is a labeled generated view, never authority ──────────────
describe('PrdService exportMarkdown', () => {
it('writes a generated view carrying the label and source identity', async () => {
const created = await service().create({ name: 'Exported PRD' });
const result = await service().exportMarkdown({ id: created.id });
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
expect(result.content).toContain(`prd-id: ${created.id}`);
expect(result.content).toContain('prd-version: 1');
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
});
it('reflects the current version after updates', async () => {
const created = await service().create({ name: 'Versioned Export' });
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
});
const result = await service().exportMarkdown({ id: created.id });
expect(result.content).toContain('prd-version: 2');
});
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
const created = await service().create({ name: 'Guarded PRD' });
const before = structuredClone(await service().get(created.id));
const result = await service().exportMarkdown({ id: created.id });
await writeFile(
result.filePath,
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
'utf8',
);
const after = await service().get(created.id);
expect(after).toEqual(before);
expect(after.version).toBe(1);
expect(after.title).toBe(before.title);
});
it('never parses Markdown files that sit in the store directory', async () => {
const created = await service().create({ name: 'Decoy Guard' });
// A decoy .md file with invalid YAML must be invisible to the store.
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
// And a decoy .yaml-named Markdown body must not silently validate either.
await service().exportMarkdown({ id: created.id });
const listed = await service().list();
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
await expect(service().get(created.id)).resolves.toBeTruthy();
});
});
// ── Import: validated, conflict-aware, never silently merging ─────────────────
describe('PrdService importDocument', () => {
it('creates a valid import through the service, as draft — validity is not approval', async () => {
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('created');
expect(result.document.id).toBe('imported-prd-20260101-000000');
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
expect(result.document.version).toBe(1);
const persisted = await service().get('imported-prd-20260101-000000');
expect(persisted.status).toBe('draft');
const files = await readdir(storeDir());
expect(files).toContain('imported-prd-20260101-000000.yaml');
});
it('reports identical content as a no-op without writing', async () => {
const created = await service().create({ name: 'Existing PRD' });
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const filePath = await writeImportFile(importFixture({ ...created }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('identical');
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
expect(after).toBe(before);
});
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
const existing = await service().create({ name: 'Authority PRD' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
requirementIds: ['FR-0'],
});
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
const divergent = importFixture({
...existing,
title: 'Divergent Title',
sections: [
{
id: 'introduction',
title: 'Introduction',
fields: { context: 'changed', objective: '' },
},
],
});
const filePath = await writeImportFile(divergent);
const attempt = service().importDocument({ filePath });
let caught: unknown;
try {
await attempt;
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PrdImportConflictError);
const error = caught as PrdImportConflictError;
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
expect(error.existing.id).toBe(existing.id);
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
expect(error.proposal.status).toBe('draft');
// Original authority content untouched on disk.
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
expect(afterBytes).toBe(beforeBytes);
});
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
const existing = await service().create({ name: 'Successor Base' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
});
const divergent = importFixture({
...existing,
title: 'Accepted Successor Title',
});
const filePath = await writeImportFile(divergent);
const successor = await service().acceptSuccessor({ filePath });
expect(successor.id).toBe(existing.id);
expect(successor.version).toBe(existing.version + 1);
expect(successor.title).toBe('Accepted Successor Title');
expect(successor.status).toBe('draft');
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
// Persisted for a fresh reader.
const fresh = await service().get(existing.id);
expect(fresh.version).toBe(2);
expect(fresh.title).toBe('Accepted Successor Title');
});
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
const cases: Array<{ name: string; body: string }> = [
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
{
name: 'bad-status.yaml',
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
},
{
name: 'bad-version.yaml',
body: yaml.dump(importFixture({ version: 0 })),
},
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
];
for (const fixture of cases) {
const filePath = path.join(projectDir, fixture.name);
await writeFile(filePath, fixture.body, 'utf8');
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
PrdImportInvalidError,
);
}
// Nothing was created: the authority store does not even exist yet.
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
});
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
const filePath = await writeImportFile(importFixture());
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── No second writer: no code path reads exported Markdown back into authority ─
describe('no-second-writer invariant (source-level)', () => {
// Resolve the package source dir whether vitest runs from the package root
// (turbo/pnpm test) or from the worktree root.
function resolveSrcDir(): string {
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
}
const srcDir = resolveSrcDir();
const sourceFiles = [
'cli.ts',
'index.ts',
'prd.ts',
'service.ts',
'templates.ts',
'types.ts',
'wizard.ts',
];
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
for (const file of sourceFiles) {
const text = await readFile(path.join(srcDir, file), 'utf8');
const readLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
for (const line of readLines) {
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
}
}
});
it('the mosaic prdy/mission adapters never read a .md file', async () => {
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
for (const file of ['prdy.ts', 'mission.ts']) {
const text = await readFile(path.join(adapterDir, file), 'utf8');
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
false,
);
}
});
});
-379
View File
@@ -1,379 +0,0 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
import type {
PrdCreateInput,
PrdDocument,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
PrdLinkMissionInput,
PrdMissionLinkage,
PrdPlanForMissionInput,
PrdServiceOptions,
PrdUpdateInput,
} from './types.js';
/**
* PrdService is the SINGLE authority surface for PRD documents.
*
* Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
* through this service; the YAML store under `docs/prdy/` is the authority and
* exported Markdown is a generated view that no code path reads back.
*/
// ── Typed errors ───────────────────────────────────────────────────────────────
export class PrdError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message);
this.name = 'PrdError';
}
}
export class PrdNotFoundError extends PrdError {
constructor(message: string) {
super(message, 'PRD_NOT_FOUND');
this.name = 'PrdNotFoundError';
}
}
export class PrdUpdateError extends PrdError {
constructor(message: string) {
super(message, 'PRD_UPDATE_INVALID');
this.name = 'PrdUpdateError';
}
}
/** Structural refusal: the import payload failed schema validation. Nothing is written. */
export class PrdImportInvalidError extends PrdError {
constructor(
message: string,
readonly issues?: string,
) {
super(message, 'PRD_IMPORT_INVALID');
this.name = 'PrdImportInvalidError';
}
}
/**
* Conflict refusal: an existing PRD shares the imported id but the content
* diverges. Carries a PROPOSED successor (existing version + 1) that is only
* persisted via an explicit {@link PrdService.acceptSuccessor} call — import
* never overwrites and never merges.
*/
export class PrdImportConflictError extends PrdError {
constructor(
message: string,
readonly existing: PrdDocument,
readonly proposal: PrdDocument,
) {
super(message, 'PRD_IMPORT_CONFLICT');
this.name = 'PrdImportConflictError';
}
}
// ── Service ────────────────────────────────────────────────────────────────────
/** The generated-view label carried by every Markdown export. */
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';
export class PrdService {
private readonly projectPath: string;
constructor(options: PrdServiceOptions) {
this.projectPath = options.projectPath;
}
/** Create a new PRD (version 1, draft) in the authority store. */
async create(input: PrdCreateInput): Promise<PrdDocument> {
return createPrd({
name: input.name,
projectPath: this.projectPath,
template: input.template,
interactive: false,
});
}
/** Read a PRD by id, or the most recently updated one. */
async get(id?: string): Promise<PrdDocument> {
const documents = await listPrds(this.projectPath);
if (id === undefined) {
const latest = documents[0];
if (latest === undefined) {
throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
}
return latest;
}
const match = documents.find((doc) => doc.id === id);
if (match === undefined) {
throw new PrdNotFoundError(`PRD id not found: ${id}`);
}
return match;
}
/** List all PRDs in the authority store (most recently updated first). */
async list(): Promise<PrdDocument[]> {
return listPrds(this.projectPath);
}
/**
* Apply section field patches and bump the content version.
* Linkage entries are preserved; linkage writes do NOT bump the version.
*/
async update(input: PrdUpdateInput): Promise<PrdDocument> {
const doc = await this.get(input.id);
for (const patch of input.sections) {
const section = doc.sections.find((candidate) => candidate.id === patch.id);
if (section === undefined) {
throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
}
for (const [field, value] of Object.entries(patch.fields)) {
if (!(field in section.fields)) {
throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
}
section.fields[field] = value;
}
}
doc.version += 1;
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
/**
* Record (or refresh) a mission ↔ PRD linkage on the PRD document.
* Persisted in the YAML authority, so it survives restarts.
*/
async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
const doc = await this.get(input.prdId);
return this.applyLinkage(doc, input);
}
/** Read back the mission linkages recorded on a PRD. */
async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
const doc = await this.get(prdId);
return doc.missions;
}
/**
* Mission planning path: create a PRD for a mission AND persist the
* mission↔PRD linkage in a single authority write.
*/
async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
const doc = await this.create({ name: input.name, template: input.template });
return this.applyLinkage(doc, {
prdId: doc.id,
missionId: input.missionId,
missionVersion: input.missionVersion,
requirementIds: input.requirementIds,
});
}
/**
* Render the PRD to a Markdown GENERATED VIEW.
*
* The output carries source identity (PRD id + version + generated-view
* label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
* the authority store only loads `.yaml`/`.yml` files, and no code path in
* this package parses the exported Markdown.
*/
async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
const doc = await this.get(input?.id);
const content = renderMarkdown(doc);
const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf8');
return { filePath, content };
}
/**
* Import a YAML PRD document.
*
* Structural validation (zod) happens BEFORE anything is proposed or
* written. A structurally-valid import is persisted as `draft` — validity is
* NOT approval. If an existing PRD shares the id with divergent content, a
* typed {@link PrdImportConflictError} is thrown carrying a proposed
* successor; the original authority document is left byte-identical on disk.
*/
async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
const document = this.buildImportedDocument(incoming);
await savePrd(document);
return { kind: 'created', document };
}
if (canonicalCore(existing) === canonicalCore(incoming)) {
return { kind: 'identical', document: existing };
}
throw new PrdImportConflictError(
`PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
`Proposed successor: version ${existing.version + 1} (draft). ` +
`Accept explicitly with acceptSuccessor().`,
existing,
this.buildSuccessor(existing, incoming),
);
}
/**
* Explicitly accept a conflicted import as a successor version of the
* existing PRD. Re-validates the source file before writing; the successor
* is persisted with status `draft` (acceptance of the import is not approval
* of the PRD) and the existing mission linkages are carried forward.
*/
async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
throw new PrdNotFoundError(
`No existing PRD with id "${incoming.id}" — use importDocument to create it`,
);
}
const successor = this.buildSuccessor(existing, incoming);
await savePrd(successor);
return successor;
}
// ── internals ──────────────────────────────────────────────────────────────
private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
const entry: PrdMissionLinkage = {
missionId: input.missionId,
missionVersion: input.missionVersion,
prdVersion: doc.version,
requirementIds: input.requirementIds ?? [],
linkedAt: new Date().toISOString(),
};
// One entry per mission: refresh in place if the mission is already linked.
const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
if (index === -1) {
doc.missions.push(entry);
} else {
doc.missions[index] = entry;
}
// Linkage is mission-side metadata, not a content revision: bump the
// timestamp only so ids/versions stay stable for consumers.
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
private async readImportFile(filePath: string): Promise<PrdDocument> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
}
let parsed: unknown;
try {
parsed = yaml.load(raw);
} catch (error) {
throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
}
try {
return parsePrdDocument(parsed);
} catch (error) {
throw new PrdImportInvalidError(
`Import file failed PRD schema validation: ${filePath}`,
error instanceof Error ? error.message : String(error),
);
}
}
private buildImportedDocument(incoming: PrdDocument): PrdDocument {
const now = new Date().toISOString();
return {
...incoming,
// The import lands in THIS project's authority store.
projectPath: this.projectPath,
// A structurally-valid import is not thereby approved.
status: 'draft',
version: 1,
missions: [],
createdAt: now,
updatedAt: now,
};
}
private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
return {
...incoming,
id: existing.id,
projectPath: existing.projectPath,
status: 'draft',
version: existing.version + 1,
missions: existing.missions,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
}
}
// ── Markdown rendering (generated view) ───────────────────────────────────────
function canonicalCore(doc: PrdDocument): string {
return JSON.stringify([doc.title, doc.template, doc.sections]);
}
function renderMarkdown(doc: PrdDocument): string {
const lines: string[] = [
'<!--',
`${PRD_GENERATED_VIEW_LABEL}`,
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
`prd-id: ${doc.id}`,
`prd-version: ${doc.version}`,
`generated-at: ${new Date().toISOString()}`,
'-->',
'',
`# ${doc.title}`,
'',
`**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
'',
];
if (doc.missions.length > 0) {
lines.push('## Mission Linkage', '');
for (const mission of doc.missions) {
const requirements =
mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
lines.push(
`- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
);
}
lines.push('');
}
for (const section of doc.sections) {
lines.push(`## ${section.title}`, '');
for (const [field, value] of Object.entries(section.fields)) {
lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
}
}
lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
return lines.join('\n');
}
-75
View File
@@ -19,31 +19,13 @@ export interface PrdSection {
fields: Record<string, string>; fields: Record<string, string>;
} }
/**
* Mission ↔ PRD linkage recorded on the PRD document (the YAML authority).
*
* `missionVersion` is the mission-side revision marker available to the CLI
* (the gateway exposes `updatedAt` for missions — there is no numeric mission
* version yet). `prdVersion` snapshots the PRD content version at link time.
*/
export interface PrdMissionLinkage {
missionId: string;
missionVersion: string;
prdVersion: number;
requirementIds: string[];
linkedAt: string;
}
export interface PrdDocument { export interface PrdDocument {
id: string; id: string;
title: string; title: string;
status: PrdStatus; status: PrdStatus;
projectPath: string; projectPath: string;
template: string; template: string;
/** Content revision counter. Bumped by updates and accepted imports. */
version: number;
sections: PrdSection[]; sections: PrdSection[];
missions: PrdMissionLinkage[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -54,60 +36,3 @@ export interface CreatePrdOptions {
template?: string; template?: string;
interactive?: boolean; interactive?: boolean;
} }
// ── PrdService surface (single authority entry point) ─────────────────────────
export interface PrdServiceOptions {
projectPath: string;
}
export interface PrdCreateInput {
name: string;
template?: string;
}
export interface PrdSectionPatch {
id: string;
fields: Record<string, string>;
}
export interface PrdUpdateInput {
/** Defaults to the most recently updated PRD. */
id?: string;
sections: PrdSectionPatch[];
}
export interface PrdLinkMissionInput {
/** Defaults to the most recently updated PRD. */
prdId?: string;
missionId: string;
missionVersion: string;
requirementIds?: string[];
}
export interface PrdPlanForMissionInput extends PrdLinkMissionInput {
name: string;
template?: string;
}
export interface PrdExportInput {
/** Defaults to the most recently updated PRD. */
id?: string;
/** Override the generated-view output path. */
outPath?: string;
}
export interface PrdExportResult {
filePath: string;
content: string;
}
/** Discriminated result of a non-conflicting import. */
export type PrdImportResult =
| { kind: 'created'; document: PrdDocument }
| { kind: 'identical'; document: PrdDocument };
export interface PrdImportInput {
/** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */
filePath: string;
}
+26 -37
View File
@@ -2,8 +2,8 @@ import path from 'node:path';
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'; import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
import { PrdService } from './service.js'; import { createPrd, savePrd } from './prd.js';
import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js'; import type { CreatePrdOptions, PrdDocument } from './types.js';
interface WizardAnswers { interface WizardAnswers {
goals: string; goals: string;
@@ -11,41 +11,20 @@ interface WizardAnswers {
milestones: string; milestones: string;
} }
/** function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
* Translate wizard answers into section patches using the same keyword const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
* matching the wizard always used (first section whose id contains the
* keyword, then first field whose name contains it, else first field).
*/
function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] {
const bySection = new Map<string, PrdSectionPatch>();
const add = (keyword: string, value: string): void => {
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
if (section === undefined) { if (section === undefined) {
return; return;
} }
const fieldName = const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ?? Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
Object.keys(section.fields)[0]; Object.keys(section.fields)[0];
if (fieldName === undefined || section.fields[fieldName] === value) { if (fieldName !== undefined) {
return; section.fields[fieldName] = value;
} }
const existing = bySection.get(section.id);
if (existing === undefined) {
bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } });
} else {
existing.fields[fieldName] = value;
}
};
add('goal', answers.goals);
add('constraint', answers.constraints);
add('milestone', answers.milestones);
return [...bySection.values()];
} }
async function promptText(message: string, initialValue = ''): Promise<string> { async function promptText(message: string, initialValue = ''): Promise<string> {
@@ -84,10 +63,15 @@ async function promptTemplate(template?: string): Promise<string> {
return choice; return choice;
} }
/** function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
* Interactive PRD wizard. All writes go through PrdService — the wizard is a updateSectionField(doc, 'goal', answers.goals);
* prompt layer, never a second writer path. updateSectionField(doc, 'constraint', answers.constraints);
*/ updateSectionField(doc, 'milestone', answers.milestones);
doc.updatedAt = new Date().toISOString();
return doc;
}
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> { export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
intro('Mosaic PRD wizard'); intro('Mosaic PRD wizard');
@@ -98,15 +82,20 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocume
const constraints = await promptText('Key constraints'); const constraints = await promptText('Key constraints');
const milestones = await promptText('Planned milestones'); const milestones = await promptText('Planned milestones');
const service = new PrdService({ projectPath: options.projectPath }); const doc = await createPrd({
const doc = await service.create({ ...options,
name, name,
template, template,
interactive: true,
}); });
const patches = buildWizardPatches(doc, { goals, constraints, milestones }); const updated = applyWizardAnswers(doc, {
const updated = goals,
patches.length > 0 ? await service.update({ id: doc.id, sections: patches }) : doc; constraints,
milestones,
});
await savePrd(updated);
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`); outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
-37
View File
@@ -1,37 +0,0 @@
# Scratchpad — RI-4-001 One transitional PRD authority (RI-N3, #1275)
- Objective: single PrdService authority in `@mosaicstack/prdy`; `mosaic prdy` and
`mission --plan` become thin adapters; mission↔PRD linkage persisted on disk;
Markdown export is a labeled generated view (never read back); import is
validated/conflict-aware with typed refusals.
- Budget: ~35K tokens (card cap). Baselines: prdy build/lint rc=0, 0 tests;
mosaic build rc=0 (after root turbo build), lint rc=0, 1548 tests pass;
root build rc=0.
- Plan: (1) extend store schema (version, missions linkage) (2) PrdService +
typed errors (3) wizard/cli route through service (4) mosaic adapters
(5) contract specs both packages (6) gates (7) sabotage control (8) report
to /var/tmp/ri-050/ri-4-001-report.md.
- Decisions:
- Linkage lives ON the PRD document (`missions` array) — one authority file,
survives restart, no sidecar sync problems.
- `version` = content revision of sections/status (bumped by update/import
accept). Linkage writes bump `updatedAt` only, so ids/versions stay stable
for the card's "stable ids/versions" contract.
- Mission version marker = `mission.updatedAt` (gateway MissionInfo has no
numeric version field).
- Import reads YAML documents only — never the exported Markdown (keeps the
"no code path reads exported Markdown" invariant).
- Import of an existing id with identical core content → `identical` no-op;
divergent → typed `PrdImportConflictError` carrying proposed successor
(existing.version + 1, status draft, linkages preserved). Original bytes
untouched until explicit `acceptSuccessor`.
- `requirementIds` default `[]` at the mission command (no requirement
selection UI yet) — service accepts ids when a caller has them.
- Progress log:
- [16:35] baselines captured (prdy 0 tests; mosaic 1548 after root build; root build rc=0)
- [16:38] store schema v2 + PrdService + wizard/cli rerouted; prdy build/lint green
- [16:40] mosaic adapters done; prdy spec 20/20 (found+fixed: import project-path leak, empty-store typed error, YAML timestamp coercion)
- [16:44] mosaic specs 9/9 (fixed commander from:'user' argv, vi.mock hoisting, restoreAllMocks wiping factory mocks)
- [16:45] all gates green; 4 commits (e291bfb, 2c5d208, a23826c, 540d6f1)
- [16:46] sabotage: linkage write removed → prdy 3 fail / mosaic 2 fail, 1548/1548 pre-existing pass; restored byte-identically; re-green 20/20 + 1557/1557
- [16:47] report written to /var/tmp/ri-050/ri-4-001-report.md — card complete