its-magic 0.1.2-38 → 0.1.2-39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/installer.sh CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env sh
2
2
  set -e
3
3
 
4
+ # BUG-0004: keep startup shell options POSIX-safe for /bin/sh execution.
5
+ # Do not use bash-only "set" flags in this unconditional startup path.
6
+
4
7
  SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
5
8
  SOURCE_ROOT="$SCRIPT_DIR/template"
6
9
  MANIFEST_NAME="docs/engineering/context/installer-owned-paths.manifest"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "its-magic",
3
- "version": "0.1.2-38",
3
+ "version": "0.1.2-39",
4
4
  "description": "its-magic - AI dev team workflow for Cursor.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
 
@@ -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 = []
@@ -9,12 +9,30 @@ description: "its-magic auto: deterministic continuation orchestrator."
9
9
  - tech-lead
10
10
 
11
11
  ## Execution model
12
- - `/auto` is an orchestrator only. It must not execute phase work directly.
13
- - For each phase, spawn a fresh subagent context for that phase role.
12
+ - `/auto` is a **spawn-only orchestrator**: it schedules materialization, spawns
13
+ fresh **phase-role** subagents, and verifies phase boundaries—it **must not**
14
+ execute lifecycle phase work, perform phase-role duties, or author **phase
15
+ deliverables** in the orchestrator context.
16
+ - For each phase, **spawn a fresh subagent** for that phase’s canonical role;
17
+ phase output must arrive only via artifacts and handoff files (no in-turn
18
+ orchestrator execution of that phase).
14
19
  - Phase context transfer happens only through artifacts and handoff files.
15
20
  - Scope is process/workflow orchestration only. Do not claim runtime product
16
21
  orchestration changes.
17
22
 
23
+ ## Spawn-boundary integrity (BUG-0006)
24
+
25
+ - **Forbidden**: treating the orchestrator turn as the executor of a lifecycle
26
+ phase (for example running **`architecture`**, **`execute`**, **`qa`**, or any
27
+ other **`phase_id`** in the orchestrator instead of spawning the required
28
+ subagent).
29
+ - **Fail fast** with **`AUTO_ORCHESTRATOR_PHASE_EXECUTION`**. **Remediation**:
30
+ stop; spawn a **fresh** subagent for the canonical **`phase_id`** and **`role`**
31
+ per the phase→role matrix (**DEC-0051**); do not merge phase output into
32
+ orchestrator turns. **Distinct from** **`PHASE_CONTEXT_ISOLATION_VIOLATION`**
33
+ (wrong writer / isolation break) and **`RUNTIME_PROOF_*`** / **`PHASE_ROLE_*`**
34
+ families—do not overload those codes for a missing-spawn violation.
35
+
18
36
  ## Full specification (US-0080 / DEC-0062)
19
37
 
20
38
  Long prose, expanded mode semantics, and **Steps 1–13** detail live in
@@ -21,6 +21,7 @@ description: "its-magic intake: clarify idea and capture story + acceptance."
21
21
  - `docs/product/backlog.md`
22
22
  - `docs/product/acceptance.md`
23
23
  - `handoffs/po_to_tl.md`
24
+ - `handoffs/resume_brief.md` (required on successful **`/intake bug`** persistence — **DEC-0069** / **BUG-0005**)
24
25
  - Optional (when enabled): `docs/engineering/compatibility-report.md`
25
26
  - Optional (when enabled): `docs/engineering/component-scope.md`
26
27
 
@@ -41,7 +42,7 @@ description: "its-magic intake: clarify idea and capture story + acceptance."
41
42
  - For artifact mutations, enforce deterministic single-writer scope:
42
43
  - establish writer identity (`writer_id`) and run identity (`intake_run_id`),
43
44
  - bind writes to target artifacts (`backlog`, `acceptance`, `vision`,
44
- `po_to_tl`) for this run.
45
+ `po_to_tl`, and when persisting bugs: `resume_brief` per **DEC-0069**) for this run.
45
46
  - Drift guard semantics:
46
47
  - self-write changes from the same `(writer_id, intake_run_id)` are valid and
47
48
  must not trigger concurrent-writer blockers,
@@ -73,6 +74,7 @@ description: "its-magic intake: clarify idea and capture story + acceptance."
73
74
  - `INTAKE_REQUIRED_TOPIC_MISSING`
74
75
  - `INTAKE_REQUIRED_PACK_INCOMPLETE`
75
76
  - `INTAKE_ASSUMPTION_CONFIRMATION_REQUIRED`
77
+ - `INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT` (**BUG-0007** / **R-0066** — see **Truthfulness** below)
76
78
  - `INTAKE_PERSISTENCE_BLOCKED`
77
79
  - Remediation guidance surface (mandatory on block):
78
80
  - list `missing_topics`,
@@ -127,6 +129,24 @@ and assumption binding. **Do not** mutate `docs/product/backlog.md` or
127
129
  (**AC-5**, **AC-6**).
128
130
  - **Grandfathering**: legacy intake rows remain valid for read/display; the **next** intake-driven
129
131
  mutation must supply full **US-0078** evidence or the write is blocked (**DEC-0060** §5).
132
+ - Truthfulness / anti-echo (**BUG-0007** / **R-0066**): `INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT` when
133
+ the same normalized `quoted_user_text` is reused across distinct required `topic_key` rows under
134
+ `satisfied_by=answer_ref` without an exempt path (`evidence_source=equivalent_evidence_ref` +
135
+ `equivalent_evidence_ref`, `delegation_ref` per **DEC-0067** / **US-0083**, or
136
+ `assumption_confirmation_ref` on the row). Canonical **`ie:`** integrity (**DEC-0060**) does not
137
+ prove a topic was actually elicited.
138
+
139
+ ## Truthfulness: `asked_topics` and `topic_coverage` (BUG-0007 / US-0083 / DEC-0060 / DEC-0067)
140
+
141
+ - **`asked_topics`** may list a required `topic_key` only when a **user-visible question** was posed
142
+ **or** a **DEC-0060**-allowed alternate applies: **`delegation_ref`** (**DEC-0067**, **US-0083**),
143
+ **`evidence_source=equivalent_evidence_ref`** with **`equivalent_evidence_ref`**, or
144
+ **`assumption_confirmation_ref`** (row-level and/or bundle-level assumption binding per the gate
145
+ contract above).
146
+ - **Forbidden**: fabricating **`topic_coverage`** by echoing **one** user or bug-report blob into
147
+ **`quoted_user_text`** on **every** required key as **`answer_ref`** solely to satisfy structure.
148
+ The validator rejects that pattern under **`INTAKE_ANSWER_REF_NOT_TOPIC_DISTINCT`** (see
149
+ **`docs/engineering/architecture.md`** **`# BUG-0007`**).
130
150
 
131
151
  ## Bug issue routing (US-0079 / DEC-0061)
132
152
 
@@ -138,6 +158,12 @@ and assumption binding. **Do not** mutate `docs/product/backlog.md` or
138
158
  - Append **`### BUG-#### — Title`** under **`## Bug issues (canonical)`** with **Status**, **`environment`**, **`steps_to_reproduce`**, **`expected`**, **`actual`**, **`evidence_refs`**.
139
159
  - Add matching **`- [ ]` / `- [x]`** row under **`## Bug acceptance (canonical)`**, sorted by id.
140
160
  - Run **`python scripts/bug_issue_validate.py --backlog docs/product/backlog.md --check-acceptance`** before completing the handoff.
161
+ - **Resume brief refresh (DEC-0069 / BUG-0005)**: immediately after successful bug persistence and backlog/acceptance validation **PASS**, run the atomic writer (temp file + replace — idempotent latest-pointer upsert):
162
+ - `python scripts/intake_bug_resume_brief_refresh.py --bug-id BUG-#### --backlog docs/product/backlog.md --resume-brief handoffs/resume_brief.md --intake-boundary-utc <RFC3339Z>`
163
+ - Optional: `--orchestrator-run-id`, `--intake-evidence handoffs/intake_evidence/....json`, `--sprint-id` when known.
164
+ - Exit non-zero → **`INTAKE_RESUME_BRIEF_*`** family — do not claim intake complete; fix backlog/brief contradiction or supply valid boundary UTC.
165
+ - Post-condition: **`intended_resume_phase` / `resolved_start_phase` = `discovery`**, **`resolution_source=resume_brief`**, **`bug_id`** matches persisted row, so **`/auto`** without **`start-from`** does not false-trigger **`RESUME_BRIEF_STALE`** for a stale pre-intake **`intake`** target.
166
+ - Optional audit: `python scripts/intake_bug_resume_brief_refresh.py --bug-id BUG-#### --backlog docs/product/backlog.md --resume-brief handoffs/resume_brief.md --validate-file` (no write).
141
167
 
142
168
  ## Steps
143
169
  1. Determine intake mode from `.cursor/scratchpad.md`:
@@ -283,7 +309,8 @@ and assumption binding. **Do not** mutate `docs/product/backlog.md` or
283
309
  - Intake mutations must also comply with
284
310
  `docs/engineering/artifact-ownership-policy.md`.
285
311
  - Intake may mutate only intake-owned scopes (`vision`, `backlog`, `acceptance`,
286
- `po_to_tl`) for target story context.
312
+ `po_to_tl`, and **`handoffs/resume_brief.md`** only via the **DEC-0069** bug-intake
313
+ completion path / `intake_bug_resume_brief_refresh.py`) for target story context.
287
314
  - Any attempted delete/rewrite of non-intake-owned sections fails closed with
288
315
  `PHASE_OWNERSHIP_VIOLATION`.
289
316
  - If an override-authorized path is configured for an artifact but required
@@ -23,7 +23,7 @@ artifacts. Ordering policy and ownership policy are complementary:
23
23
  | `handoffs/release_queue.md` | target sprint row only | `release` | none |
24
24
  | `handoffs/release_notes.md` | latest pointer section | `release`, `refresh-context` | none |
25
25
  | `docs/engineering/state.md` | append-bottom checkpoints only | all delivery phases | none |
26
- | `handoffs/resume_brief.md` | current status/next-actions sections | `pause`, `resume`, `refresh-context`, `release` | none |
26
+ | `handoffs/resume_brief.md` | current status/next-actions sections; latest-pointer upsert on **`/intake bug`** completion (**DEC-0069**) | `intake` (bug persistence path only), `pause`, `resume`, `refresh-context`, `release` | none |
27
27
 
28
28
  ## Non-destructive mutation rules
29
29
 
@@ -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,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 = []