its-magic 0.1.2-38 → 0.1.2-40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -313,6 +313,7 @@ binding before backlog/acceptance writes.
313
313
  - Regression: `tests/intake_evidence_fixtures_test.py` (R-0055 **AC-8** matrix tiers A/B),
314
314
  invoked from `tests/run-tests.ps1` / `tests/run-tests.sh` §26k.
315
315
  - **Packaged installs (BUG-0001 / DEC-0063)**: `intake_evidence_validate.py`, `intake_evidence_lib.py`, and `intake_bug_routing_guard.py` are mirrored under `template/scripts/` and listed in `docs/engineering/context/installer-owned-paths.manifest` so fresh install and `upgrade` copy them to the consumer’s `scripts/`. Drift guard: `python scripts/check_intake_template_parity.py --repo .` (also §26N in `tests/run-tests.*`). **Release (S0060)**: operator notes `handoffs/releases/S0060-release-notes.md` (gate summary + verify steps).
316
+ - **US-0084**: `remote_config_summary.py` and `guard_installer_publish.py` use the same **`template/scripts/`** mirror + manifest rows; npm **`package.json` `files`** also lists the active copies for publish.
316
317
  - **Installer completeness gate (BUG-0003 / DEC-0066)**: post-install invariant checks every path in `[required_install_script_paths]` from `docs/engineering/context/installer-owned-paths.manifest`. Missing paths fail closed with `INSTALL_COMPLETENESS_FAILED` and `INSTALL_REQUIRED_SCRIPT_MISSING:<path>`. Remediation: update manifest parity (active + `template/`), ensure required script exists under `template/scripts/`, keep install/clean ownership paired, then rerun `its-magic --mode missing|upgrade` (or `python installer.py --validate-install-completeness --target <repo>` for direct diagnostics).
317
318
  - **Guided** and **low-touch** (`INTAKE_GUIDED_MODE=0`) share the **same** pre-persistence
318
319
  validation pipeline; mandatory pack evidence is never skipped.
@@ -816,6 +817,36 @@ Operator troubleshooting:
816
817
  - Replace with env-var reference fields (`tokenEnv`, `passwordEnv`,
817
818
  `privateKeyPathEnv`, ...).
818
819
 
820
+ ### Published npm `installer.sh` / POSIX dash (US-0084)
821
+
822
+ - **Symptom**: `set: Illegal option -` on an early line when running `its-magic` or
823
+ `sh installer.sh` on Debian/Ubuntu (**`/bin/sh`** → **dash**).
824
+ - **Common causes**: bash-only `set` options (`pipefail`, `-o errexit`, `-u` bundles)
825
+ on the **unconditional** startup path, or **CRLF** line endings in the file that
826
+ ships from npm.
827
+ - **`sh` vs `bash`**: the Unix CLI path uses **`sh` + `installer.sh`** (**BUG-0004** /
828
+ **DEC-0068**). Do not assume bash for the first lines of **`installer.sh`**.
829
+ - **Remediation**:
830
+ - Upgrade to an **its-magic** build that includes **US-0084** (LF + POSIX guards).
831
+ - Normalize to **LF** only (e.g. `dos2unix installer.sh`, or fix checkout —
832
+ root **`.gitattributes`** uses `*.sh text eol=lf`).
833
+ - Reinstall from npm after verifying maintainer gates:
834
+ `python scripts/guard_installer_publish.py` (also **`npm run guard:installer`**
835
+ / **`prepublishOnly`**).
836
+ - **Normative**: **`docs/engineering/architecture.md`** **`# US-0084`**.
837
+
838
+ ### Automated checks (US-0084)
839
+
840
+ - `python tests/installer_shell_bug0004_test.py` — CR/LF rejection, forbidden
841
+ `set` tokens, optional **`dash -n`** when **`dash`** is on **`PATH`**.
842
+ - `python scripts/guard_installer_publish.py` — same checks for publish/CI
843
+ (**`prepublishOnly`**).
844
+ - `python scripts/remote_config_summary.py` — with **`REMOTE_EXECUTION=1`**,
845
+ read-only summary of **`REMOTE_CONFIG`** (default **`.cursor/remote.json`**);
846
+ stdout is **names-only** (no secret values). **`DEC-0070`**: when
847
+ **`REMOTE_EXECUTION=0`**, the helper exits **0** and skips validation
848
+ (stderr skip reason).
849
+
819
850
  ## Runtime QA autopilot contract (US-0065 / DEC-0047)
820
851
 
821
852
  Generated-project validation requires runtime proof, not static checks alone.
@@ -32,3 +32,23 @@ For each enabled target include:
32
32
  - Do not write inline credentials/tokens/private keys.
33
33
  - Only env reference names are permitted in connectivity artifacts.
34
34
  - Redact auth details in handoffs and release outputs.
35
+
36
+ ## Dev/QA remote profiles vs `release-targets.json` (US-0084)
37
+
38
+ Use this table to map **where you run tests** to the **US-0064** release/QA
39
+ connectivity model (**no parallel schema**). Cursor/dev **`REMOTE_CONFIG`** is
40
+ documented in the runbook; it complements, not replaces, **`release-targets.json`**.
41
+
42
+ | Operator path | Maps in `release-targets.json` | Scratchpad / dev config |
43
+ |---------------|----------------------------------|-------------------------|
44
+ | **WSL** | Local Linux on the same machine — not a separate target row by default. | Usually **`REMOTE_EXECUTION=0`**. Cite **environment label** **`WSL`** in QA evidence. |
45
+ | **Bare SSH Linux** | **`ssh-server`**: `hostEnv`, `userEnv`, `authEnv`, `remoteCommand`, `runtime`, ingress. | **`REMOTE_EXECUTION=1`**, **`REMOTE_CONFIG=.cursor/remote.json`** (see **`.cursor/scratchpad.md`**). |
46
+ | **Docker-over-SSH** | **`ssh-server.dockerOverSsh`**: `dockerHostEnv`, `dockerContextEnv`, `composeFile`, `service`. | Same scratchpad keys; operator sets **`DOCKER_HOST`** / context using **env names** only in docs. |
47
+
48
+ ### `docker_over_ssh` (operator summary)
49
+
50
+ When **`dockerOverSsh.enabled`** is true on **`ssh-server`**, connectivity flows
51
+ through SSH to a host where Docker commands run; **`dockerHostEnv`** and
52
+ **`dockerContextEnv`** name the operator env vars (values never pasted into
53
+ artifacts). **`composeFile`** and **`service`** identify the stack slice. Full
54
+ fields remain in **`docs/engineering/release-targets.json`** and **DEC-0044**.
@@ -0,0 +1,39 @@
1
+ # US-0084 — Minimal remote sanity path (Windows → WSL or SSH Linux)
2
+
3
+ Sprint **S0069** / story **US-0084**. This is a short operator walkthrough; normative
4
+ contracts live in **`docs/engineering/architecture.md`** **`# US-0084`**,
5
+ **`docs/engineering/release-targets.json`**, and **`docs/engineering/runtime-connectivity.md`**
6
+ (**US-0064**).
7
+
8
+ ## Path A — WSL (local Linux on the Windows machine)
9
+
10
+ 1. Install/launch **WSL** and open a Linux shell in the repo (or clone the repo inside WSL).
11
+ 2. Ensure **Node.js** and **Python 3** are available on **PATH** (same as host runbook).
12
+ 3. Run **`npm pack`** / local **`its-magic`** or **`sh installer.sh --target <repo> --mode missing --create`** from the package root.
13
+ 4. Run **`python tests/installer_shell_bug0004_test.py`** and your stack’s **`TEST_COMMAND`** (from merged runbook / scratchpad).
14
+ 5. Evidence: set **environment label** **`WSL`** in QA handoffs; **`REMOTE_EXECUTION=0`** is typical (no **`.cursor/remote.json`** validation required).
15
+
16
+ ## Path B — SSH to a Linux host
17
+
18
+ 1. SSH into the Linux machine; clone or sync the repo there.
19
+ 2. Run the same **`sh`/`dash`** installer and **`python`** tests as on native Linux.
20
+ 3. For **release/QA connectivity** semantics, align with **`ssh-server`** in
21
+ **`docs/engineering/release-targets.json`** (`hostEnv`, `userEnv`, `authEnv`, …).
22
+ 4. For **Cursor/dev remote** validation, set **`REMOTE_EXECUTION=1`** and
23
+ **`REMOTE_CONFIG=.cursor/remote.json`** on the merged scratchpad; run
24
+ **`python scripts/remote_config_summary.py`** — stdout must list **names only**
25
+ (no keys/passwords).
26
+ 5. Evidence: cite **`ssh:<hostEnv>`** (the **env var name**, not the host value) plus
27
+ **environment label**; never paste private key material.
28
+
29
+ ## Path C — Docker-over-SSH
30
+
31
+ 1. Follow **Path B** on the SSH host; enable **`dockerOverSsh`** patterns per
32
+ **`runtime-connectivity.md`** (**`dockerHostEnv`**, **`dockerContextEnv`**, **`composeFile`**, **`service`**).
33
+ 2. Set operator **`DOCKER_HOST`** / context per your platform; document **env names**
34
+ only in handoffs.
35
+
36
+ ## Related
37
+
38
+ - **`tests/run-tests.sh`** / **`tests/run-tests.ps1`** — **H1–H5** (**US-0084** / AC-10).
39
+ - **`python scripts/guard_installer_publish.py`** — publish-time LF + POSIX guard.
@@ -12,6 +12,7 @@ INTAKE_TEMPLATE_PAIRS: tuple[tuple[str, str], ...] = (
12
12
  ("scripts/intake_evidence_validate.py", "template/scripts/intake_evidence_validate.py"),
13
13
  ("scripts/intake_evidence_lib.py", "template/scripts/intake_evidence_lib.py"),
14
14
  ("scripts/intake_bug_routing_guard.py", "template/scripts/intake_bug_routing_guard.py"),
15
+ ("scripts/intake_bug_resume_brief_refresh.py", "template/scripts/intake_bug_resume_brief_refresh.py"),
15
16
  ("scripts/check_intake_template_parity.py", "template/scripts/check_intake_template_parity.py"),
16
17
  )
17
18
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Prepublish / CI guard: installer.sh LF + POSIX-safe startup tokens (US-0084 / AC-2)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ INSTALLER_SH = ROOT / "installer.sh"
13
+
14
+ FORBIDDEN_TOKENS = (
15
+ "set -euo",
16
+ "set -o pipefail",
17
+ "set -eu -o pipefail",
18
+ "set -o errexit",
19
+ "set -o nounset",
20
+ )
21
+
22
+
23
+ def main() -> int:
24
+ if not INSTALLER_SH.is_file():
25
+ print("guard_installer_publish: installer.sh missing", file=sys.stderr)
26
+ return 1
27
+ data = INSTALLER_SH.read_bytes()
28
+ if b"\r" in data:
29
+ print(
30
+ "guard_installer_publish: CR/LF (\\r) bytes found in installer.sh — "
31
+ "use LF only; see docs/engineering/runbook.md (US-0084).",
32
+ file=sys.stderr,
33
+ )
34
+ return 1
35
+ text = data.decode("utf-8", errors="replace")
36
+ for token in FORBIDDEN_TOKENS:
37
+ if token in text:
38
+ print(
39
+ f"guard_installer_publish: forbidden startup token {token!r} in installer.sh",
40
+ file=sys.stderr,
41
+ )
42
+ return 1
43
+ dash = shutil.which("dash")
44
+ if dash:
45
+ r = subprocess.run(
46
+ [dash, "-n", str(INSTALLER_SH)],
47
+ cwd=ROOT,
48
+ capture_output=True,
49
+ text=True,
50
+ encoding="utf-8",
51
+ errors="replace",
52
+ )
53
+ if r.returncode != 0:
54
+ print(
55
+ "guard_installer_publish: dash -n installer.sh failed:\n"
56
+ + (r.stderr or r.stdout or ""),
57
+ file=sys.stderr,
58
+ )
59
+ return 1
60
+ else:
61
+ print(
62
+ "guard_installer_publish: dash not on PATH; skipping dash -n "
63
+ "(Python CRLF + token checks still enforced).",
64
+ file=sys.stderr,
65
+ )
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ raise SystemExit(main())
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Atomic refresh of handoffs/resume_brief.md after successful /intake bug persistence (DEC-0069 / BUG-0005).
4
+
5
+ Idempotent: same inputs yield the same latest-pointer section. Uses temp file + os.replace for atomicity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import sys
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ import bug_issue_lib as bi
18
+ import bug_issue_validate as biv
19
+
20
+
21
+ def bug_status(backlog_text: str, bug_id: str) -> str | None:
22
+ section = bi.extract_bug_section(backlog_text)
23
+ if not section:
24
+ return None
25
+ for issue in bi.parse_bug_issues(section):
26
+ if issue.bug_id == bug_id:
27
+ return issue.status
28
+ return None
29
+
30
+
31
+ def upsert_latest_orchestration_pointer(full_text: str, latest_block: str) -> str:
32
+ """Replace ## Latest orchestration pointer section or insert after # Resume Brief."""
33
+ latest_block = latest_block.rstrip("\n") + "\n"
34
+ lines = full_text.splitlines(keepends=True)
35
+ if not lines:
36
+ return "# Resume Brief\n\n" + latest_block
37
+
38
+ out: list[str] = []
39
+ i = 0
40
+ replaced = False
41
+ while i < len(lines):
42
+ line = lines[i]
43
+ if line.startswith("## Latest orchestration pointer"):
44
+ out.append(latest_block)
45
+ i += 1
46
+ while i < len(lines) and not lines[i].startswith("## "):
47
+ i += 1
48
+ replaced = True
49
+ continue
50
+ out.append(line)
51
+ i += 1
52
+
53
+ if replaced:
54
+ return "".join(out)
55
+
56
+ text = "".join(lines)
57
+ stripped = text.lstrip("\n")
58
+ if stripped.startswith("# Resume Brief"):
59
+ return text.rstrip("\n") + "\n\n" + latest_block
60
+ return "# Resume Brief\n\n" + latest_block + text.lstrip("\n")
61
+
62
+
63
+ def build_latest_pointer_markdown(
64
+ *,
65
+ bug_id: str,
66
+ intake_boundary_utc: str,
67
+ orchestrator_run_id: str | None,
68
+ intake_evidence_ref: str | None,
69
+ sprint_id: str | None,
70
+ ) -> str:
71
+ orch = orchestrator_run_id if orchestrator_run_id else "(unknown)"
72
+ ev = intake_evidence_ref if intake_evidence_ref else "(none)"
73
+ spr = sprint_id if sprint_id else "(none)"
74
+ return f"""## Latest orchestration pointer — post-bug-intake (DEC-0069)
75
+
76
+ - **Boundary**: successful **`/intake bug`** persistence (**`US-0045`**) — **`intake_boundary_utc={intake_boundary_utc}`**
77
+ - **`bug_id`**: **`{bug_id}`** — must remain **`OPEN`** in **`docs/product/backlog.md`** (authority); this refresh is rejected if backlog shows **DONE**
78
+ - **Intake evidence ref**: `{ev}`
79
+ - **`orchestrator_run_id`**: `{orch}` (boundary metadata when known; optional at intake)
80
+ - **Contract**: default **`/auto`** continuation targets **`discovery`** for this OPEN bug (not a stale pre-intake **`intake`** resume target)
81
+
82
+ ## Current status
83
+
84
+ - **Active bug**: **`{bug_id}`** — **OPEN** per **`docs/product/backlog.md`** at refresh time
85
+
86
+ ## Intended resume phase
87
+
88
+ `discovery`
89
+
90
+ ## Resume target
91
+
92
+ - bug_id={bug_id}
93
+ - story_id=(none)
94
+ - sprint_id={spr}
95
+ - boundary=post-bug-intake (**DEC-0069**)
96
+
97
+ ## Latest auto breadcrumb seed
98
+
99
+ - requested_start_from=(none)
100
+ - resolved_start_phase=discovery
101
+ - resolution_source=resume_brief
102
+ - resolution_status=resolved
103
+ - stop_reason=intake_complete
104
+ - stop_phase=intake
105
+ - next_scheduled_phase=discovery
106
+ - bug_id={bug_id}
107
+ - story_id=(none)
108
+ - sprint_id={spr}
109
+ - orchestrator_run_id={orch}
110
+ - intake_boundary_utc={intake_boundary_utc}
111
+ """
112
+
113
+
114
+ def extract_brief_bug_id(brief_text: str) -> str | None:
115
+ for line in brief_text.splitlines():
116
+ s = line.strip()
117
+ m = re.match(r"^-\s*bug_id=(BUG-\d{4})\s*$", s)
118
+ if m:
119
+ return m.group(1)
120
+ return None
121
+
122
+
123
+ def validate_brief_open_bug_alignment(brief_text: str, backlog_text: str) -> list[str]:
124
+ """Writer-side guard: brief bug_id must match an OPEN row in backlog (US-0045)."""
125
+ errors: list[str] = []
126
+ bid = extract_brief_bug_id(brief_text)
127
+ if not bid:
128
+ errors.append("INTAKE_RESUME_BRIEF_PARSE_BUG_ID_MISSING")
129
+ return errors
130
+
131
+ st = bug_status(backlog_text, bid)
132
+ if st is None:
133
+ errors.append(f"INTAKE_RESUME_BRIEF_BACKLOG_BUG_UNKNOWN:{bid}")
134
+ return errors
135
+ if st != "OPEN":
136
+ errors.append(f"INTAKE_RESUME_BRIEF_BACKLOG_CONTRADICTION:{bid}:status={st}")
137
+ if "`discovery`" not in brief_text and "resolved_start_phase=discovery" not in brief_text:
138
+ errors.append("INTAKE_RESUME_BRIEF_DISCOVERY_PHASE_MISSING")
139
+ return errors
140
+
141
+
142
+ def atomic_write(path: Path, content: str) -> None:
143
+ path.parent.mkdir(parents=True, exist_ok=True)
144
+ fd, tmp = tempfile.mkstemp(
145
+ dir=path.parent,
146
+ prefix=".resume_brief_tmp_",
147
+ suffix=".md",
148
+ )
149
+ try:
150
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
151
+ f.write(content)
152
+ os.replace(tmp, path)
153
+ except Exception:
154
+ try:
155
+ os.unlink(tmp)
156
+ except OSError:
157
+ pass
158
+ raise
159
+
160
+
161
+ def _fail(code: str, detail: str = "") -> int:
162
+ msg = code
163
+ if detail:
164
+ msg += f": {detail}"
165
+ print(msg, file=sys.stderr)
166
+ return 1
167
+
168
+
169
+ def self_test() -> int:
170
+ block = build_latest_pointer_markdown(
171
+ bug_id="BUG-0999",
172
+ intake_boundary_utc="2026-04-03T12:00:00Z",
173
+ orchestrator_run_id="auto-test",
174
+ intake_evidence_ref="handoffs/intake_evidence/x.json",
175
+ sprint_id="S0001",
176
+ )
177
+ old = "# Resume Brief\n\n## Latest orchestration pointer — old\n\n- x\n\n## Checkpoint\n\nkeep\n"
178
+ new = upsert_latest_orchestration_pointer(old, block)
179
+ if "keep" not in new:
180
+ return _fail("SELFTEST_FAILED", "lost tail section")
181
+ if "BUG-0999" not in new or "resolved_start_phase=discovery" not in new:
182
+ return _fail("SELFTEST_FAILED", "missing expected content")
183
+ if "## Latest orchestration pointer — old" in new:
184
+ return _fail("SELFTEST_FAILED", "old latest not replaced")
185
+ good_backlog = """## Bug issues (canonical)
186
+
187
+ ### BUG-0999 — T
188
+ - Status: OPEN
189
+ - environment: e
190
+ - steps_to_reproduce: s
191
+ - expected: x
192
+ - actual: y
193
+ - evidence_refs: z
194
+ """
195
+ errs = validate_brief_open_bug_alignment(new, good_backlog)
196
+ if errs:
197
+ return _fail("SELFTEST_FAILED", str(errs))
198
+ bad_backlog = good_backlog.replace("OPEN", "DONE")
199
+ errs2 = validate_brief_open_bug_alignment(new, bad_backlog)
200
+ if not any("CONTRADICTION" in e for e in errs2):
201
+ return _fail("SELFTEST_FAILED", "expected contradiction on DONE")
202
+ print("[INTAKE_BUG_RESUME_BRIEF_REFRESH_OK]")
203
+ return 0
204
+
205
+
206
+ def main() -> int:
207
+ ap = argparse.ArgumentParser(description=__doc__)
208
+ ap.add_argument("--bug-id", default="", help="Persisted BUG-#### id (required unless --self-test)")
209
+ ap.add_argument("--backlog", default="docs/product/backlog.md")
210
+ ap.add_argument("--resume-brief", default="handoffs/resume_brief.md")
211
+ ap.add_argument(
212
+ "--intake-boundary-utc",
213
+ default="",
214
+ help="RFC3339 UTC timestamp for intake completion boundary (required unless --validate-file)",
215
+ )
216
+ ap.add_argument("--orchestrator-run-id", default="", help="Optional orchestrator run id")
217
+ ap.add_argument("--intake-evidence", default="", help="Optional intake evidence path or ref")
218
+ ap.add_argument("--sprint-id", default="", help="Optional sprint id")
219
+ ap.add_argument("--dry-run", action="store_true", help="Print body only; do not write")
220
+ ap.add_argument("--validate-file", action="store_true", help="Validate existing brief vs backlog; no write")
221
+ ap.add_argument("--self-test", action="store_true")
222
+ args = ap.parse_args()
223
+ if args.self_test:
224
+ return self_test()
225
+
226
+ if not args.bug_id:
227
+ return _fail("INTAKE_RESUME_BRIEF_INVALID_BUG_ID", "missing --bug-id")
228
+ if not re.fullmatch(r"BUG-\d{4}", args.bug_id):
229
+ return _fail("INTAKE_RESUME_BRIEF_INVALID_BUG_ID", args.bug_id)
230
+
231
+ try:
232
+ backlog_text = Path(args.backlog).read_text(encoding="utf-8")
233
+ except OSError as e:
234
+ return _fail("INTAKE_RESUME_BRIEF_IO_ERROR", str(e))
235
+
236
+ berr, _ = biv.validate_backlog(backlog_text)
237
+ if berr:
238
+ for e in berr:
239
+ print(e, file=sys.stderr)
240
+ return _fail("INTAKE_RESUME_BRIEF_BACKLOG_INVALID", berr[0])
241
+
242
+ resume_path = Path(args.resume_brief)
243
+ if args.validate_file:
244
+ if not resume_path.is_file():
245
+ return _fail("INTAKE_RESUME_BRIEF_MISSING", str(resume_path))
246
+ brief_text = resume_path.read_text(encoding="utf-8")
247
+ bid_file = extract_brief_bug_id(brief_text)
248
+ if bid_file != args.bug_id:
249
+ return _fail(
250
+ "INTAKE_RESUME_BRIEF_BUG_ID_MISMATCH",
251
+ f"cli={args.bug_id} brief={bid_file}",
252
+ )
253
+ verr = validate_brief_open_bug_alignment(brief_text, backlog_text)
254
+ if verr:
255
+ for e in verr:
256
+ print(e, file=sys.stderr)
257
+ return 1
258
+ print("[INTAKE_RESUME_BRIEF_VALIDATE_OK]")
259
+ return 0
260
+
261
+ if not args.intake_boundary_utc.strip():
262
+ return _fail("INTAKE_RESUME_BRIEF_BOUNDARY_UTC_REQUIRED", "supply --intake-boundary-utc")
263
+
264
+ st = bug_status(backlog_text, args.bug_id)
265
+ if st is None:
266
+ return _fail("INTAKE_RESUME_BRIEF_BUG_NOT_FOUND", args.bug_id)
267
+ if st != "OPEN":
268
+ return _fail(
269
+ "INTAKE_RESUME_BRIEF_BACKLOG_CONTRADICTION",
270
+ f"{args.bug_id} must be OPEN for discovery default; got {st}",
271
+ )
272
+
273
+ block = build_latest_pointer_markdown(
274
+ bug_id=args.bug_id,
275
+ intake_boundary_utc=args.intake_boundary_utc,
276
+ orchestrator_run_id=args.orchestrator_run_id or None,
277
+ intake_evidence_ref=args.intake_evidence or None,
278
+ sprint_id=args.sprint_id or None,
279
+ )
280
+
281
+ prior = resume_path.read_text(encoding="utf-8") if resume_path.is_file() else ""
282
+ merged = upsert_latest_orchestration_pointer(prior, block)
283
+ verr = validate_brief_open_bug_alignment(merged, backlog_text)
284
+ if verr:
285
+ for e in verr:
286
+ print(e, file=sys.stderr)
287
+ return 1
288
+
289
+ if args.dry_run:
290
+ print(merged)
291
+ return 0
292
+
293
+ try:
294
+ atomic_write(resume_path, merged)
295
+ except OSError as e:
296
+ return _fail("INTAKE_RESUME_BRIEF_WRITE_FAILED", str(e))
297
+
298
+ print("[INTAKE_BUG_RESUME_BRIEF_REFRESH_OK]")
299
+ return 0
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  """
2
2
  Deterministic intake evidence validation
3
- (US-0078 / US-0083 / DEC-0060 / DEC-0067 / R-0055).
3
+ (US-0078 / US-0083 / DEC-0060 / DEC-0067 / R-0055 / BUG-0007 / R-0066).
4
4
 
5
5
  Consumes a logical intake_evidence bundle (dict). PO workflows MUST run this
6
6
  gate before mutating backlog/acceptance; failures are fail-closed.
@@ -202,6 +202,77 @@ def _row_uses_equivalent_evidence(row: dict[str, Any]) -> bool:
202
202
  return bool(str(row.get("equivalent_evidence_ref") or "").strip())
203
203
 
204
204
 
205
+ def _norm_answer_ref_quoted_user_text(value: Any) -> str:
206
+ """Normalize quoted_user_text for BUG-0007 duplicate detection (DEC-0060 strip parity)."""
207
+ if value is None:
208
+ return ""
209
+ return str(value).strip()
210
+
211
+
212
+ def _row_exempt_from_answer_ref_topic_distinctness(row: dict[str, Any]) -> bool:
213
+ """BUG-0007: alternate satisfaction paths do not participate in answer_ref blob reuse checks."""
214
+ return _row_uses_equivalent_evidence(row)
215
+
216
+
217
+ def _validate_answer_ref_topic_distinctness(
218
+ bundle: dict[str, Any],
219
+ required: list[str],
220
+ by_key: dict[str, dict[str, Any]],
221
+ res: ValidationResult,
222
+ ) -> None:
223
+ """
224
+ BUG-0007 / R-0066: distinct required topic_key rows must not reuse the same
225
+ quoted_user_text under satisfied_by=answer_ref (normalized), except exempt rows.
226
+ """
227
+ norm_to_topics: dict[str, list[str]] = {}
228
+ for k in required:
229
+ row = by_key.get(k)
230
+ if not row:
231
+ continue
232
+ sat = (row.get("satisfied_by") or "").strip()
233
+ if sat != "answer_ref":
234
+ continue
235
+ if _row_exempt_from_answer_ref_topic_distinctness(row):
236
+ continue
237
+ irid = _row_run_id(bundle, row)
238
+ tit = _row_turn(row)
239
+ if irid is None or tit is None:
240
+ continue
241
+ qraw = row.get("quoted_user_text")
242
+ qtxt = "" if qraw is None else str(qraw)
243
+ ref = (row.get("ref") or "").strip()
244
+ if not ref or not verify_ie_ref(
245
+ ref,
246
+ intake_run_id=irid,
247
+ turn_index=int(tit),
248
+ topic_key=k,
249
+ satisfied_by=sat,
250
+ quoted_user_text=qtxt,
251
+ ):
252
+ continue
253
+ norm = _norm_answer_ref_quoted_user_text(qraw)
254
+ norm_to_topics.setdefault(norm, []).append(k)
255
+
256
+ dup_groups: list[str] = []
257
+ for norm, topics in norm_to_topics.items():
258
+ uniq = sorted(set(topics))
259
+ if len(uniq) < 2:
260
+ continue
261
+ dup_groups.append("text=" + repr(norm[:120]) + " topics=" + ",".join(uniq))
262
+
263
+ if dup_groups:
264
+ res.ok = False
265
+ res.add_code("INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT")
266
+ res.diagnostics.append(
267
+ "Remediation: distinct required topics must not reuse the same quoted_user_text "
268
+ "under satisfied_by=answer_ref (BUG-0007 / R-0066). Duplicates: "
269
+ + "; ".join(dup_groups)
270
+ + ". Use per-topic answers, or an allowed alternate path "
271
+ "(evidence_source=equivalent_evidence_ref + equivalent_evidence_ref, "
272
+ "delegation_ref per DEC-0067 / US-0083, or assumption_confirmation_ref on the row)."
273
+ )
274
+
275
+
205
276
  def _candidate_story_ids(bundle: dict[str, Any]) -> set[str]:
206
277
  out: set[str] = set()
207
278
 
@@ -527,6 +598,8 @@ def validate_intake_evidence(
527
598
  res.missing_topics.append(k)
528
599
  res.missing_topics = sorted(set(res.missing_topics))
529
600
 
601
+ _validate_answer_ref_topic_distinctness(bundle, required, by_key, res)
602
+
530
603
  # US-0081 / DEC-0064: first/new/broad intake requires complete-plan coverage contract.
531
604
  if pack == "first-intake-pack":
532
605
  _validate_plan_coverage_contract(bundle, res)
@@ -668,6 +741,32 @@ def self_test() -> None:
668
741
  assert d0.ok and d1.ok
669
742
  assert d0.primary_codes == d1.primary_codes
670
743
 
744
+ # BUG-0007: same quoted_user_text across multiple answer_ref required topics fails
745
+ dup_txt = "synthetic blob echoed for every topic"
746
+ dup_rows = []
747
+ for i, key in enumerate(small):
748
+ dup_rows.append(
749
+ {
750
+ "topic_key": key,
751
+ "satisfied_by": "answer_ref",
752
+ "quoted_user_text": dup_txt,
753
+ "intake_run_id": rid,
754
+ "turn_index": 300 + i,
755
+ "ref": build_ie_ref(rid, 300 + i, key, "answer_ref", dup_txt),
756
+ }
757
+ )
758
+ dup_bundle = {
759
+ "selected_pack": "small-intake-pack",
760
+ "intake_run_id": rid,
761
+ "asked_topics": list(small),
762
+ "missing_topics": [],
763
+ "assumptions_confirmed": "(none)",
764
+ "topic_coverage": dup_rows,
765
+ }
766
+ dup_res = validate_intake_evidence(dup_bundle)
767
+ assert not dup_res.ok
768
+ assert "INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT" in dup_res.primary_codes
769
+
671
770
  # First-intake full-plan coverage contract (US-0081 / DEC-0064)
672
771
  first = PACK_REQUIRED_KEYS["first-intake-pack"]
673
772
  first_rows = []