Skip to content

Commit f453b38

Browse files
authored
Merge pull request #236 from PsychQuant/idd/172-force-linked-issue-separation
refactor: spectra-archive-post-ic — force-linked-issue vs linked-issue intent separation
2 parents a67745e + 6a767fe commit f453b38

11 files changed

Lines changed: 62 additions & 12 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"issue": 172,
3+
"fetched_at": "2026-07-05T22:37:11Z",
4+
"fetched_by": "idd-diagnose",
5+
"files": []
6+
}

.claude/scripts/spectra-archive-post-ic.sh

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ OUTCOME_FILE="/tmp/spectra-archive-ic-outcome.txt"
5353
OUTCOME_FILE_EXPLICIT=0
5454
GH_REPO_ARG=""
5555
DRY_RUN=0
56+
FORCE_LINKED_ISSUE=""
5657

5758
# ── Parse args ──
5859
while [ $# -gt 0 ]; do
@@ -61,6 +62,7 @@ while [ $# -gt 0 ]; do
6162
--archive-dir) ARCHIVE_DIR="$2"; shift 2;;
6263
--spec-deltas) SPEC_DELTAS="$2"; shift 2;;
6364
--linked-issue) LINKED_ISSUE_RESOLVED="$2"; shift 2;;
65+
--force-linked-issue) FORCE_LINKED_ISSUE="$2"; shift 2;;
6466
--outcome-file) OUTCOME_FILE="$2"; OUTCOME_FILE_EXPLICIT=1; shift 2;;
6567
--gh-repo) GH_REPO_ARG="$2"; shift 2;;
6668
--dry-run) DRY_RUN=1; shift;;
@@ -78,7 +80,7 @@ done
7880
# ── Validate required args ──
7981
if [ -z "$CHANGE_NAME" ] || [ -z "$ARCHIVE_DIR" ]; then
8082
echo "ERROR: --change-name and --archive-dir are required" >&2
81-
echo "Usage: $0 --change-name <name> --archive-dir <path> [--spec-deltas <text>] [--linked-issue <N>] [--dry-run]" >&2
83+
echo "Usage: $0 --change-name <name> --archive-dir <path> [--spec-deltas <text>] [--linked-issue <N> | --force-linked-issue <N>] [--dry-run]" >&2
8284
exit 2
8385
fi
8486

@@ -180,7 +182,33 @@ detect_candidates() {
180182
}
181183

182184
# ── Resolve LINKED_ISSUE ──
183-
if [ -n "$LINKED_ISSUE_RESOLVED" ]; then
185+
# Two flags, two intents (#172): --linked-issue is the exit-75 DISAMBIGUATION
186+
# re-invoke (validated against the detected candidate set); --force-linked-issue
187+
# is the AUTHORITATIVE override (caller knows the number; detection bypassed
188+
# entirely, existence-checked instead). The old "empty candidate set turns
189+
# --linked-issue authoritative" escape hatch is REMOVED — its emptiness was
190+
# timing/context-dependent (#172 fragility 2), and conflating the intents is
191+
# what made #170's fix fragile in the first place.
192+
if [ -n "$FORCE_LINKED_ISSUE" ] && [ -n "$LINKED_ISSUE_RESOLVED" ]; then
193+
emit_outcome "(failed — pass either --linked-issue or --force-linked-issue, not both)" 0
194+
fi
195+
FORCE_NOTE=""
196+
if [ -n "$FORCE_LINKED_ISSUE" ]; then
197+
if ! [[ "$FORCE_LINKED_ISSUE" =~ ^[1-9][0-9]*$ ]]; then
198+
emit_outcome "(failed — --force-linked-issue $FORCE_LINKED_ISSUE is not a positive integer)" 0
199+
fi
200+
# Existence checkpoint (#172 direction 2): catches nonexistent numbers; a
201+
# wrong-but-existing number is inherent to an explicit override — mitigated
202+
# by the loud confirmation + the annotated anchor below.
203+
if [ "$DRY_RUN" = "0" ]; then
204+
FORCED_TITLE=$(gh issue view "$FORCE_LINKED_ISSUE" --repo "$GH_REPO" --json title -q .title 2>/dev/null) || emit_outcome "(failed — --force-linked-issue $FORCE_LINKED_ISSUE: issue not found in $GH_REPO)" 0
205+
echo "→ FORCE-LINKED to #${FORCE_LINKED_ISSUE}: ${FORCED_TITLE} (authoritative override — detection & membership bypassed)" >&2
206+
else
207+
echo "[DRY-RUN] existence check skipped for --force-linked-issue $FORCE_LINKED_ISSUE" >&2
208+
fi
209+
LINKED_ISSUE="$FORCE_LINKED_ISSUE"
210+
FORCE_NOTE=" via --force-linked-issue"
211+
elif [ -n "$LINKED_ISSUE_RESOLVED" ]; then
184212
# Validate integer FIRST (clearer error; emit_outcome exits, so this must
185213
# precede the membership check to be reachable). (#170)
186214
# `^[1-9][0-9]*$` is strictly positive — GitHub issue numbers start at 1, so
@@ -189,14 +217,15 @@ if [ -n "$LINKED_ISSUE_RESOLVED" ]; then
189217
if ! [[ "$LINKED_ISSUE_RESOLVED" =~ ^[1-9][0-9]*$ ]]; then
190218
emit_outcome "(failed — --linked-issue $LINKED_ISSUE_RESOLVED is not a positive integer)" 0
191219
fi
192-
# Membership validation applies ONLY when detection actually found candidates
193-
# (the multi-candidate disambiguation re-invoke). When the candidate set is
194-
# EMPTY, --linked-issue is the authoritative escape hatch — detection always
195-
# has gaps (prose-only #N refs etc.), so the override MUST NOT depend on
196-
# detection succeeding, or both fail together exactly when the fallback is
197-
# needed (the #170 root cause). (#170)
220+
# Pure disambiguation semantics (#172): --linked-issue is ALWAYS validated
221+
# against the candidate set. Empty set = nothing to disambiguate = this is
222+
# the wrong flag; the authoritative path is --force-linked-issue (which
223+
# existence-checks instead of membership-checks).
198224
CANDIDATES=$(detect_candidates "$ARCHIVE_DIR")
199-
if [ -n "$CANDIDATES" ] && ! echo "$CANDIDATES" | grep -qx -- "$LINKED_ISSUE_RESOLVED"; then
225+
if [ -z "$CANDIDATES" ]; then
226+
emit_outcome "(failed — --linked-issue $LINKED_ISSUE_RESOLVED given but detection found no candidates; if you know the issue number authoritatively, re-invoke with --force-linked-issue $LINKED_ISSUE_RESOLVED)" 0
227+
fi
228+
if ! echo "$CANDIDATES" | grep -qx -- "$LINKED_ISSUE_RESOLVED"; then
200229
emit_outcome "(failed — --linked-issue $LINKED_ISSUE_RESOLVED not in candidate set: $(echo "$CANDIDATES" | tr '\n' ' '))" 0
201230
fi
202231
LINKED_ISSUE="$LINKED_ISSUE_RESOLVED"
@@ -227,6 +256,7 @@ fi
227256

228257
# ── Step B: Idempotent guard (per-archive sentinel) ──
229258
SENTINEL="auto-posted by spectra-archive for ${ARCHIVE_BASENAME}"
259+
ANCHOR_SUFFIX="${FORCE_NOTE:-}"
230260

231261
if [ "$DRY_RUN" = "0" ]; then
232262
ALREADY_POSTED=$(gh issue view "$LINKED_ISSUE" --repo "$GH_REPO" --json comments \
@@ -274,6 +304,7 @@ ARCHIVE_DATE=$(date -u +%Y-%m-%d)
274304
# context or execute code.
275305
export CHECKLIST_BODY_ENV="$CHECKLIST_BODY"
276306
export ARCHIVE_BASENAME_ENV="$ARCHIVE_BASENAME"
307+
export FORCE_NOTE_ENV="${FORCE_NOTE:-}"
277308
export CHANGE_NAME_ENV="$CHANGE_NAME"
278309
export ARCHIVE_DIR_ENV="$ARCHIVE_DIR"
279310
export SPEC_DELTAS_ENV="$SPEC_DELTAS"
@@ -283,7 +314,7 @@ export BODY_FILE_ENV="$BODY_FILE"
283314
python3 <<'PYEOF'
284315
import os, sys
285316
286-
template = """## Implementation Complete (auto-posted by spectra-archive for {basename})
317+
template = """## Implementation Complete (auto-posted by spectra-archive for {basename}{force_note})
287318
288319
> Auto-posted by `/spectra-archive` after archiving `{change_name}`. This comment is the canonical Implementation Complete anchor for `/idd-close` Step 0 supersession gate.
289320
@@ -304,6 +335,7 @@ template = """## Implementation Complete (auto-posted by spectra-archive for {ba
304335
305336
body = template.format(
306337
basename=os.environ.get("ARCHIVE_BASENAME_ENV", ""),
338+
force_note=os.environ.get("FORCE_NOTE_ENV", ""),
307339
change_name=os.environ.get("CHANGE_NAME_ENV", ""),
308340
archive_dir=os.environ.get("ARCHIVE_DIR_ENV", ""),
309341
spec_deltas=os.environ.get("SPEC_DELTAS_ENV", ""),
@@ -317,7 +349,7 @@ with open(os.environ["BODY_FILE_ENV"], "w") as f:
317349
PYEOF
318350

319351
PY_EXIT=$?
320-
unset CHECKLIST_BODY_ENV ARCHIVE_BASENAME_ENV CHANGE_NAME_ENV ARCHIVE_DIR_ENV SPEC_DELTAS_ENV ARCHIVE_DATE_ENV BODY_FILE_ENV
352+
unset CHECKLIST_BODY_ENV ARCHIVE_BASENAME_ENV FORCE_NOTE_ENV CHANGE_NAME_ENV ARCHIVE_DIR_ENV SPEC_DELTAS_ENV ARCHIVE_DATE_ENV BODY_FILE_ENV
321353

322354
if [ "$PY_EXIT" -ne 0 ]; then
323355
rm -f "$BODY_FILE"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
https://github.com/test/repo/issues/555#issuecomment-DRY-RUN
1+
(failed — --linked-issue 555 given but detection found no candidates; if you know the issue number authoritatively, re-invoke with --force-linked-issue 555)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## Why
2+
A change with no detectable GitHub tracker anywhere in its artifacts.
3+
- [x] Apply spec changes
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--change-name test-override-empty --archive-dir __FIXTURE_PATH__/archive --force-linked-issue 555 --dry-run --gh-repo test/repo
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
https://github.com/test/repo/issues/555#issuecomment-DRY-RUN
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## Why
2+
A change with no detectable GitHub tracker anywhere in its artifacts.
3+
- [x] Apply spec changes
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--change-name test-override-empty --archive-dir __FIXTURE_PATH__/archive --linked-issue 555 --force-linked-issue 556 --dry-run --gh-repo test/repo
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0

0 commit comments

Comments
 (0)