diff --git a/bin/lib/visual-render.sh b/bin/lib/visual-render.sh
index b37e9da..8282fe3 100644
--- a/bin/lib/visual-render.sh
+++ b/bin/lib/visual-render.sh
@@ -382,6 +382,10 @@ nano_visual_page_start() {
.chip.sev-ok { color: var(--ok); border-color: var(--ok); }
.pr-link { color: var(--info); }
.unsafe-url { color: var(--bad); font-family: monospace; }
+ .copy-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
+ .copy-btn { background: var(--panel-2); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit; font-size: 0.9rem; }
+ .copy-btn:hover, .copy-btn:focus { background: var(--panel); border-color: var(--info); color: var(--info); }
+ .copy-status { color: var(--muted); font-size: 0.85rem; }
diff --git a/bin/render-artifact.sh b/bin/render-artifact.sh
index e25ac0a..457a32a 100755
--- a/bin/render-artifact.sh
+++ b/bin/render-artifact.sh
@@ -10,10 +10,12 @@
# [--interactive] [--out ]
# [--manifest-only]
#
-# PR 1 scope: /plan renderer + manifest + safety locks. Other phases
-# exit 1 with a clear "phase not yet supported" message; PR 2 wires
-# think/review/security/qa/ship. journal/stack are reserved for PR 3
-# (exit 2). --interactive is reserved for PR 4 (exit 2).
+# PR 1 scope: /plan renderer + manifest + safety locks.
+# PR 2 wires think/review/security/qa/ship.
+# PR 3 wires journal/stack aggregate views.
+# PR 4 adds --interactive for copy-only buttons on /plan and /review
+# (copy as prompt / Markdown / JSON patch). No filesystem writes,
+# no network calls, no form submission.
set -e
@@ -49,7 +51,10 @@ PR 1+2+3 scope:
Flags:
--strict require nano_artifact_trust == verified
- --interactive reserved for PR 4 (exit 2)
+ --interactive enable copy-only interactive controls (PR 4 scope:
+ /plan and /review get "copy as prompt", "copy as Markdown",
+ and "copy as JSON patch" buttons; no filesystem writes,
+ no network calls, no form submission)
--out write HTML to explicit path under \$NANOSTACK_STORE/visual
--manifest-only write manifest only, skip HTML
--latest resolve via find-artifact.sh (default if no path)
@@ -90,8 +95,11 @@ while [ $# -gt 0 ]; do
--latest) USE_LATEST=true ;;
--strict) STRICT=true ;;
--interactive)
- echo "render-artifact: --interactive is reserved for PR 4 (copy-only interactive mode)" >&2
- exit 2
+ # Codex PR 4 pass 1: --interactive widens the CSP to allow
+ # inline script. Only enable it for phases that actually emit
+ # copy controls (plan and review in v1) so the wider policy
+ # never applies where it has no purpose.
+ INTERACTIVE=true
;;
--manifest-only) MANIFEST_ONLY=true ;;
--out)
@@ -166,6 +174,21 @@ case "$PHASE" in
;;
esac
+# Codex PR 4 pass 1: --interactive is supported only for /plan and
+# /review in v1. Reject elsewhere with exit 1 so the wider CSP
+# (script-src 'unsafe-inline') never applies to phases that emit no
+# copy controls. The architect's spec already scoped interactive UI
+# to those two phases.
+if [ "$INTERACTIVE" = true ]; then
+ case "$PHASE" in
+ plan|review) ;;
+ *)
+ echo "render-artifact: --interactive is supported only for /plan and /review in PR 4" >&2
+ exit 1
+ ;;
+ esac
+fi
+
# Default the journal date to today if neither --today nor --date
# was supplied (codex PR 3 pass 2: bare `journal` left this empty
# and the page banner read "journal · " with the filename containing
@@ -487,6 +510,39 @@ HTML
printf ' \n'
fi
printf ' \n'
+
+ # PR 4 interactive: copy buttons that export the plan back to the
+ # agent in the requested shape. Built from the normalized JSON so
+ # malformed shapes still produce useful payloads.
+ if [ "${INTERACTIVE:-false}" = true ]; then
+ local plan_prompt plan_markdown plan_json_patch
+ # Coerce every payload field with tostring/map(tostring) so a
+ # schema-warning artifact (numbers, objects, nulls in unexpected
+ # places) cannot crash jq under set -e. Codex PR 4 pass 1.
+ plan_prompt=$(printf '%s' "$norm" | jq -r '
+ "Approve the plan and continue.\n" +
+ "Goal: " + ((.summary.goal // "Not recorded") | tostring) + "\n" +
+ "Scope: " + ((.summary.scope // "Not recorded") | tostring) + "\n" +
+ "Planned files:\n - " + ((.summary.planned_files // []) | map(tostring) | join("\n - ")) + "\n" +
+ "Risks:\n - " + ((.summary.risks // []) | map(tostring) | join("\n - "))
+ ')
+ plan_markdown=$(printf '%s' "$norm" | jq -r '
+ "## Plan summary\n\n" +
+ "- **Goal:** " + ((.summary.goal // "Not recorded") | tostring) + "\n" +
+ "- **Scope:** " + ((.summary.scope // "Not recorded") | tostring) + "\n" +
+ "- **Approval:** " + ((.summary.plan_approval // "Not recorded") | tostring) + "\n\n" +
+ "### Planned files\n\n" +
+ ((.summary.planned_files // []) | map(tostring) | map("- `" + . + "`") | join("\n"))
+ ')
+ plan_json_patch=$(printf '%s' "$norm" | jq -c '{
+ action: "plan.approve",
+ goal: .summary.goal,
+ scope: .summary.scope,
+ planned_files: .summary.planned_files,
+ out_of_scope: .summary.out_of_scope
+ }')
+ render_copy_actions "$plan_prompt" "$plan_markdown" "$plan_json_patch"
+ fi
}
# Shared helper: find the latest artifact for , surfacing
@@ -678,6 +734,44 @@ HTML
render_findings_section "$norm" "Review findings"
render_context_checkpoint "$norm"
+
+ # PR 4 interactive: review triage payloads. The agent can paste
+ # the prompt back to drive a re-review or accept the findings
+ # as-is.
+ if [ "${INTERACTIVE:-false}" = true ]; then
+ local review_prompt review_markdown review_json_patch
+ # Coerce every concatenated field with tostring (codex PR 4
+ # pass 1). A schema-warning review with .summary.blocking as a
+ # string or .scope_drift.status as a number would otherwise
+ # crash jq under set -e.
+ review_prompt=$(printf '%s' "$norm" | jq -r '
+ "Address the review findings before /ship.\n" +
+ "Summary: " + ((.summary.blocking // 0) | tostring) + " blocking / " +
+ ((.summary.should_fix // 0) | tostring) + " should-fix / " +
+ ((.summary.nitpicks // 0) | tostring) + " nitpicks\n" +
+ "Scope drift: " + ((.scope_drift.status // "unknown") | tostring) + "\n" +
+ "Now-fix:\n - " +
+ ((.findings // []) | map(select(.severity == "blocking" or .severity == "should_fix")) | map((.description // "(no description)") | tostring) | join("\n - "))
+ ')
+ review_markdown=$(printf '%s' "$norm" | jq -r '
+ "## Review findings\n\n" +
+ "| Severity | Count |\n|----|----|\n" +
+ "| Blocking | " + ((.summary.blocking // 0) | tostring) + " |\n" +
+ "| Should fix | " + ((.summary.should_fix // 0) | tostring) + " |\n" +
+ "| Nitpicks | " + ((.summary.nitpicks // 0) | tostring) + " |\n" +
+ "| Positive | " + ((.summary.positive // 0) | tostring) + " |\n\n" +
+ "### Now-fix\n\n" +
+ ((.findings // []) | map(select(.severity == "blocking" or .severity == "should_fix"))
+ | map("- **" + ((.severity // "?") | tostring) + "**: " + ((.description // "") | tostring)) | join("\n"))
+ ')
+ review_json_patch=$(printf '%s' "$norm" | jq -c '{
+ action: "review.triage",
+ blocking_ids: ((.findings // []) | map(select(.severity == "blocking") | .id)),
+ should_fix_ids: ((.findings // []) | map(select(.severity == "should_fix") | .id)),
+ scope_drift: .scope_drift.status
+ }')
+ render_copy_actions "$review_prompt" "$review_markdown" "$review_json_patch"
+ fi
}
render_security_body() {
@@ -897,6 +991,109 @@ render_findings_section() {
printf ' \n'
}
+# ─── Copy-only interactive controls (PR 4) ──────────────────
+#
+# Emits an actions card with three buttons (copy as prompt / copy as
+# Markdown / copy as JSON patch). Each button copies a pre-computed
+# payload to the clipboard. The script uses only
+# navigator.clipboard.writeText; no fetch, no XHR, no localStorage,
+# no form submission. CI lockes those forbidden APIs.
+#
+# Payloads are passed as JSON-encoded strings (via nano_json_string)
+# so embedded quotes, newlines, and control characters stay safe.
+# Each payload is also rendered as a ...
block
+# so the user can read and copy manually if the browser blocks
+# clipboard access (e.g. file:// without explicit permission).
+render_copy_actions() {
+ local prompt_payload="$1"
+ local markdown_payload="$2"
+ local json_payload="$3"
+ if [ "${INTERACTIVE:-false}" != true ]; then
+ return 0
+ fi
+
+ # Encode each payload as a JSON string literal so the inline JS
+ # can read it via JSON.parse. Critically: when embedding a JSON
+ # string INSIDE , several HTML parser states
+ # can swallow or re-interpret the script body. Codex PR 4 pass 3
+ # caught that escaping only `` left the `