Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09589f02c |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
projectPath: process.cwd(),
|
|
||||||
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,
|
name: mission.name,
|
||||||
missionId: mission.id,
|
projectPath: process.cwd(),
|
||||||
missionVersion: mission.updatedAt,
|
interactive: true,
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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';
|
||||||
|
await runPrdWizard({
|
||||||
if (process.stdout.isTTY) {
|
name,
|
||||||
await runPrdWizard({
|
projectPath: process.cwd(),
|
||||||
name,
|
interactive: true,
|
||||||
projectPath: process.cwd(),
|
});
|
||||||
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
@@ -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,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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
+32
-43
@@ -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 => {
|
if (section === undefined) {
|
||||||
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
|
return;
|
||||||
if (section === undefined) {
|
}
|
||||||
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`)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user