From 847b4129a53e4cb6e48a88532468ed453c9f20d2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:47:21 +0100 Subject: [PATCH 1/8] docs: plan itd-104 and write spc-18 ideate spec The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism (three host-delegated legs, binary validates cited record ids and writes the dated research verdict, survivors graduate through the existing intent create). Freshly minted id collided again (iss-80 class): renumbered to spc-18. Assisted-by: Claude:claude-fable-5 --- ...-before-it-becomes-a-record-entry-resea.md | 4 +- ...-before-it-becomes-a-record-entry-resea.md | 98 +++++++++++++++++++ .abcd/work/DECISIONS.md | 1 + 3 files changed, 101 insertions(+), 2 deletions(-) rename .abcd/development/intents/{drafts => planned}/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md (99%) create mode 100644 .abcd/development/specs/open/spc-18-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md diff --git a/.abcd/development/intents/drafts/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md b/.abcd/development/intents/planned/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md similarity index 99% rename from .abcd/development/intents/drafts/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md rename to .abcd/development/intents/planned/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md index abd2f3f..3af5963 100644 --- a/.abcd/development/intents/drafts/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md +++ b/.abcd/development/intents/planned/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md @@ -1,8 +1,8 @@ --- id: itd-104 slug: abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea -spec_id: null -kind: null +spec_id: spc-18 +kind: standalone suggested_kind: null reclassification_history: [] builds_on: [] diff --git a/.abcd/development/specs/open/spc-18-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md b/.abcd/development/specs/open/spc-18-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md new file mode 100644 index 0000000..e0d3de7 --- /dev/null +++ b/.abcd/development/specs/open/spc-18-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md @@ -0,0 +1,98 @@ +--- +id: spc-18 +slug: abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea +intent: itd-104 +--- +# abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea + +## Summary + +spc-18 delivers itd-104's idea-admission protocol: `/abcd:ideate`, an +optional three-leg gauntlet — primary-source research, a record grill, and an +independent adversarial review — whose outcome is a registered verdict with +rejected alternatives, recorded whether the idea survives or dies. Only a +survivor graduates to a draft intent. The design decisions below were settled +by the 2026-07-27 grill; this spec records them together with the mechanism — +it does not reopen them. + +## Settled constraints (from the grill) + +- **Optional verb, never a gate.** Capture friction stays at one line: no + ideate step is required or suggested as blocking by the intent or capture + verbs. The routing help carries the nudge — ideate is named for big, + unproven ideas. +- **The adversarial leg codifies fresh-context, off-policy, unknown + authorship** — the evaluator did not do the research and receives the idea + as an artefact of unknown authorship. This is the only measured debiasing + effect, per the salvaged 2026-07-14 research record, and it is the + evaluator-outside-the-loop principle applied to ideas. +- **Two load-bearing steps survive automation** (from the proven manual run): + check the record first, and open the primary source. + +## Mechanism + +### Division of labour + +Ideate follows the host-delegated pattern the disembark family proved: the +host runs the judgement legs as agents; the abcd binary is the deterministic +frame that scaffolds the run, validates the legs' outputs, and writes the +record. The binary never does LLM work; the host never writes the record +directly. + +### The three legs, in order + +1. **Primary-source research.** A host agent researches the idea's + load-bearing claims, each checked against its primary source — a claim + carries its primary URL or document reference, never only a secondary + citation. Output: a claims table (claim, primary source, verified / + falsified / unverifiable). +2. **Record grill.** A host agent reads the existing record — brief, intents + (all buckets), ADRs, principles — and answers: does an entry cover, + contradict, or supersede this idea? Every hit is cited by record id. The + binary validates at ingest that every cited id resolves in the repository + — a verdict citing a non-existent record is refused. +3. **Adversarial review.** A fresh-context agent, off-policy, receiving the + idea and the two earlier legs' outputs as artefacts of unknown authorship, + tries to kill the idea. It did not conduct the research (evaluator outside + the loop). + +### Verdict record (spec-time decision) + +The verdict is a dated research record: +`.abcd/development/research/YYYY-MM-DD-ideate-.md`, written by the +binary from a structured verdict the host supplies +(`abcd ideate record --verdict-json `). It contains the +idea as captured, the three legs' findings, the verdict +(survives / killed / reframed), the rejected alternatives, and — for record +grill hits — the cited record ids. A one-line dated entry in +`.abcd/work/DECISIONS.md` points at it. No new record family: killed ideas +are research outcomes, and the research directory is where a future session +looks before re-proposing one. A surviving idea graduates through the +existing quoted-text intent create — ideate mints no intents itself. + +### Wiring + +Both planes at delivery: the `abcd ideate` verb family (CLI) and the +`/abcd:ideate` skill (`commands/abcd/ideate.md`) that orchestrates the three +legs and feeds the verdict to the binary. The routing help in the intent and +capture surfaces names ideate as the optional path for big, unproven ideas — +a pointer, never a precondition. + +## Acceptance-criteria mapping + +- AC 1 (never blocking; routing help names it) → Settled constraints + + Wiring. +- AC 2 (three legs in order; adversarial leg fresh-context, off-policy, + unknown authorship) → The three legs. +- AC 3 (decision recorded with rejected alternatives either way; only + survivors graduate) → Verdict record. +- AC 4 (record-grill hits cited by id, validated) → The three legs + Verdict + record. + +## Out of scope + +- Consuming a lifeboat or external document as the idea source (open + question retained; the idea arrives as quoted text in this spec). +- Any change to capture or intent-create flow beyond the routing-help text. +- Automation of the legs inside the binary — the legs are host agents by + boundary; the binary validates and records. diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 7cc9a06..17998a5 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -862,3 +862,4 @@ parallel-agent merge contention bites. - 2026-07-27 — ADOPTED the canonical tagline: "A host-agnostic configuration layer for intent-driven development." Canonical identity home is the brief product chapter (01-product README, Identity section: title / tagline / pitch); README strapline, plugin manifest description, and AGENTS.md opening render from it. README's former strapline ("An opinionated, intent-driven development framework for product thinkers") is retired as a surface line; the product-thinker framing lives on in the README body. Rejected wordings: "for agent harnesses" (object shift, host/harness redundancy, plural over-promise), "over any agent harness" (drops the domain). Resolves iss-143; itd-102 generalises the drift check for managed repos. - 2026-07-27 — itd-93 parity tension resolved: Branch A. The scaffold template is the single source for release.yml/auto-release.yml; abcd-cli's live workflows are regenerated from it; the only sanctioned live diff is the additive workflow_dispatch rehearsal (trigger + non-publishing dry-run job). Rejected: substitution-gating the rehearsal off for abcd (weakens the parity guarantee on the one novel component). - 2026-07-27 — GRILLED itd-101/102/103/104 to settlement (six decisions, each now in its intent's Grill Settlements + acceptance criteria): itd-103 guard fails open loudly, blocker/warn tiers with committed-only overrides, shell-token-aware command-position matching with an itd-81-style TNR-floored corpus; itd-101 refresh is manual-with-nagging (scheduled CI later, own sign-off), staleness warns at 180d and blocks releases at 365d, human verifications age on the same clock; itd-102 identity lives in a parseable markdown block (config points at it), check is warn-tier and never rewrites; itd-104 ideate is an optional verb, never a pre-capture gate, with a fresh-context off-policy adversary. All four drafts are grill-settled and await intent plan. +- 2026-07-28: itd-104 spec minted as spc-16 (third live iss-80 instance this run: 16 sits on unmerged #156, 17 on the itd-101 branch); renumbered to spc-18 by hand — ids on this branch deliberately skip 16 and 17. From c5a42f363c0ac7036d85e8ed830106cacba7c1be Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:12:57 +0100 Subject: [PATCH 2/8] refactor: promote the untrusted-prose cleaner to termsafe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifeboat synthesis seam and the release ingest seam each carried their own copy of the same primitive — neutralise line breaks and HTML comment markers, sanitise, normalise whitespace, cap — and ingest.go's own comment asked that a third caller promote it rather than write it again. The ideate ingest seam is that third caller. termsafe is the home because the cleaner IS Sanitize plus the two neutralisations a file writer always makes and a terminal sanitiser has no reason to. Two forms are kept because the two existing callers legitimately differ: CleanProse trims, CleanProseLine collapses runs to one space for prose landing in a line-structured file. Both callers keep their exact prior behaviour. Assisted-by: Claude:claude-opus-5[1m] --- .../core/lifeboat/synthesis_principles.go | 17 ++-- internal/core/release/ingest.go | 23 ++--- internal/termsafe/prose.go | 56 +++++++++++++ internal/termsafe/prose_test.go | 84 +++++++++++++++++++ 4 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 internal/termsafe/prose.go create mode 100644 internal/termsafe/prose_test.go diff --git a/internal/core/lifeboat/synthesis_principles.go b/internal/core/lifeboat/synthesis_principles.go index 023be07..4eac9b6 100644 --- a/internal/core/lifeboat/synthesis_principles.go +++ b/internal/core/lifeboat/synthesis_principles.go @@ -34,6 +34,7 @@ import ( "github.com/REPPL/abcd-cli/internal/core/frontmatter" "github.com/REPPL/abcd-cli/internal/fsutil" + "github.com/REPPL/abcd-cli/internal/termsafe" ) // SynthesizePrinciples builds or ingests principles.json (+ principles.md) for a @@ -399,19 +400,11 @@ func oracleOwnOutput(rel string) bool { func cleanSynthProse(s string) string { return cleanSynthProseN(s, maxSynthProseBytes) } // cleanSynthProseN is cleanSynthProse with an explicit byte cap, so a longer-form -// field (the press-release body) can share one sanitiser at its own ceiling. +// field (the press-release body) can share one sanitiser at its own ceiling. The +// cleaning itself is termsafe's — the canonical home this seam and the release +// ingest seam both route through, rather than keeping divergent copies. func cleanSynthProseN(s string, capBytes int) string { - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "", "-- >") - s = sanitize(s) - s = strings.TrimSpace(s) - if len(s) > capBytes { - s = strings.ToValidUTF8(s[:capBytes], "") - s = strings.TrimSpace(s) - } - return s + return termsafe.CleanProse(s, capBytes) } // filterSynthEvidence keeps only the refs that are members of valid, deduped and diff --git a/internal/core/release/ingest.go b/internal/core/release/ingest.go index e1a01cc..1d4d7d5 100644 --- a/internal/core/release/ingest.go +++ b/internal/core/release/ingest.go @@ -551,24 +551,13 @@ func insertSection(root string, section []string) (string, error) { return content, nil } -// cleanChangelogProse sanitises one untrusted composed line. It mirrors -// lifeboat's cleanSynthProseN (the two are the same primitive at two trust -// boundaries; a THIRD copy should promote it to a shared home rather than be -// written again) and the collapse is load-bearing here in a way it is not there: -// this prose lands in a file whose line structure is machine-read, so an embedded -// newline could otherwise forge a heading, a section, or a whole release. +// cleanChangelogProse sanitises one untrusted composed line through termsafe's +// canonical prose cleaner. The LINE form is load-bearing here in a way the plain +// form is not: this prose lands in a file whose line structure is machine-read, so +// an embedded newline could otherwise forge a heading, a section, or a whole +// release. func cleanChangelogProse(s string) string { - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "", "-- >") - s = termsafe.Sanitize(s) - s = strings.Join(strings.Fields(s), " ") - if len(s) > maxEntryProseBytes { - s = strings.ToValidUTF8(s[:maxEntryProseBytes], "") - s = strings.TrimSpace(s) - } - return s + return termsafe.CleanProseLine(s, maxEntryProseBytes) } // sectionList renders the registered sections for an error message, written from diff --git a/internal/termsafe/prose.go b/internal/termsafe/prose.go new file mode 100644 index 0000000..236ca64 --- /dev/null +++ b/internal/termsafe/prose.go @@ -0,0 +1,56 @@ +package termsafe + +// prose.go is the shared home for the untrusted-prose cleaner every host-delegated +// ingest boundary needs before a model-supplied string is written into a durable +// markdown or JSON record. +// +// It sits beside Sanitize because it IS Sanitize plus the two neutralisations a +// terminal sanitiser has no reason to make, but a FILE writer always does: +// +// - line breaks become spaces, so one prose field can never forge a second line +// (a changelog bullet, a markdown table row, a list item) in the record; +// - `` are broken apart, so prose can neither open nor close an +// HTML comment and swallow — or expose — the record around it. +// +// Two forms exist because two callers legitimately want different whitespace +// handling, and the difference is visible in the record: CleanProse trims, +// CleanProseLine collapses every run to one space. A field landing in a +// line-structured file (a bullet, a table cell) wants the line form. +// +// This is the canonical home: internal/core/lifeboat and internal/core/release +// route through it rather than keep divergent copies, and a new trust boundary +// (internal/core/ideate) reuses it instead of writing a third. + +import "strings" + +// CleanProse neutralises one untrusted prose field and caps it at capBytes, +// preserving interior whitespace runs. The cap is applied last and the result is +// re-trimmed, so a cut landing mid-word leaves no dangling space; a cut landing +// mid-rune drops the partial rune rather than emitting replacement bytes. +func CleanProse(s string, capBytes int) string { + return cleanProse(s, capBytes, strings.TrimSpace) +} + +// CleanProseLine is CleanProse for a field that must occupy exactly one line: +// every whitespace run collapses to a single space before the cap is applied. Use +// it wherever the prose lands in a file whose line structure is machine-read. +func CleanProseLine(s string, capBytes int) string { + return cleanProse(s, capBytes, func(v string) string { return strings.Join(strings.Fields(v), " ") }) +} + +// cleanProse is the shared body: neutralise, sanitise, normalise whitespace the +// caller's way, then cap. Written once so the two forms can only differ in the +// one dimension they are meant to differ in. +func cleanProse(s string, capBytes int, normalise func(string) string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "", "-- >") + s = Sanitize(s) + s = normalise(s) + if len(s) > capBytes { + s = strings.ToValidUTF8(s[:capBytes], "") + s = strings.TrimSpace(s) + } + return s +} diff --git a/internal/termsafe/prose_test.go b/internal/termsafe/prose_test.go new file mode 100644 index 0000000..4934af0 --- /dev/null +++ b/internal/termsafe/prose_test.go @@ -0,0 +1,84 @@ +package termsafe + +import ( + "strings" + "testing" +) + +// TestCleanProseNeutralisesStructure proves the shared prose cleaner does the +// four things every untrusted-prose boundary needs: line breaks cannot forge +// structure, HTML comment markers cannot open or close a comment, terminal +// display attacks are masked, and the result is capped. +func TestCleanProseNeutralisesStructure(t *testing.T) { + cases := []struct { + name string + in string + cap int + want string + }{ + {"newline-to-space", "one\ntwo", 100, "one two"}, + {"carriage-return-to-space", "one\rtwo", 100, "one two"}, + {"comment-open-broken", "text text", 100, "hidden -- > text"}, + {"escape-masked", "red" + string(rune(0x1b)) + "[31m", 100, "red?[31m"}, + {"trimmed", " padded ", 100, "padded"}, + {"capped", strings.Repeat("a", 20), 8, "aaaaaaaa"}, + {"cap-trims-trailing-space", "abcdefg hijk", 8, "abcdefg"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := CleanProse(c.in, c.cap); got != c.want { + t.Errorf("CleanProse(%q, %d) = %q, want %q", c.in, c.cap, got, c.want) + } + }) + } +} + +// TestCleanProseKeepsInteriorRuns proves CleanProse leaves interior whitespace +// alone — it trims, it does not collapse. That is the difference from +// CleanProseLine, and a caller picking the wrong one would silently reflow prose. +func TestCleanProseKeepsInteriorRuns(t *testing.T) { + if got, want := CleanProse("a b\tc", 100), "a b c"; got != want { + t.Errorf("CleanProse kept-runs = %q, want %q", got, want) + } +} + +// TestCleanProseLineCollapsesWhitespace proves the line form collapses every +// whitespace run to one space, so prose landing in a line-structured file (a +// changelog bullet, a markdown table cell) occupies exactly one line. +func TestCleanProseLineCollapsesWhitespace(t *testing.T) { + cases := []struct { + name string + in string + cap int + want string + }{ + {"runs-collapsed", "a b\tc", 100, "a b c"}, + {"newline-collapsed", "one\n\ntwo", 100, "one two"}, + {"comment-open-broken", "text ` are broken apart, so prose can neither open nor close an -// HTML comment and swallow — or expose — the record around it. +// - anything that could OPEN raw HTML is broken apart — a tag, a closing tag, a +// declaration, a processing instruction, or a comment — so prose can neither +// open nor close an HTML construct and swallow the record around it. +// +// The HTML rule is not cosmetic. In CommonMark a `<` followed by a letter, `/`, +// `!`, or `?` at the start of a line begins an HTML BLOCK, and several of those +// block types run to the end of the document if their closing condition never +// arrives: one `", "< script>alert(1)< /script>"}, + {"table-tag", "
forged", "< table>< tr>< td>forged"}, + {"declaration", "", "< !DOCTYPE html>"}, + {"comment-open", "text