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.
- package/installer.sh +643 -640
- package/package.json +5 -1
- package/scripts/check_intake_template_parity.py +1 -0
- package/scripts/guard_installer_publish.py +70 -0
- package/scripts/intake_evidence_lib.py +100 -1
- package/scripts/remote_config_summary.py +243 -0
- package/template/.cursor/commands/auto.md +20 -2
- package/template/.cursor/commands/execute.md +6 -0
- package/template/.cursor/commands/intake.md +29 -2
- package/template/.cursor/commands/qa.md +5 -0
- package/template/.cursor/scratchpad.md +7 -3
- package/template/docs/engineering/artifact-ownership-policy.md +1 -1
- package/template/docs/engineering/context/installer-owned-paths.manifest +6 -0
- package/template/docs/engineering/runbook.md +31 -0
- package/template/docs/engineering/runtime-connectivity.md +20 -0
- package/template/docs/engineering/us-0084-remote-e2e.md +39 -0
- package/template/scripts/check_intake_template_parity.py +1 -0
- package/template/scripts/guard_installer_publish.py +70 -0
- package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
- package/template/scripts/intake_evidence_lib.py +100 -1
- package/template/scripts/remote_config_summary.py +243 -0
|
@@ -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 = []
|