Voicebox documentation

Factory Findings Triage: Private Report → Public Issue → Reviewed Bead

voicebox-beads-h1u0 (child of voicebox-beads-jyj1). Implementation: scripts/factory-triage.mjs. Tests: tests/factory-triage.test.mjs. Fixtures: tests/fixtures/factory-reports/.

1. What this replaces, and why

The factory writes its full delta report with --sink file. Every other sink is a publication boundary: the factory’s embargo module holds PRIVATE_SINKS = {"file"}, and on a public repository it withholds critical and high findings from a tracker. docs/25-factory-agent-proposal.md measured the result — zero publishable bead output, with the only interesting findings vanishing into a private artefact.

The first design here kept that embargo for the top two bands: sensitive critical/high went to a private escalation log and never to the board. That is no longer the decision. By voicebox-beads-h1u0, this repository publishes every severity as a public issue, and the protection is the published view rather than the withholding. Two things follow, and they are the spine of this document:

The original “never raw secret/PoC” rule survives the change, enforced by the sanitiser instead of by an embargo: a security station’s raw candidate is never published, but the finding is never withheld either, and nothing is dropped silently — every finding the report contains appears in the plan with the reason it was or was not published.

2. The pipeline

factory station ──(--sink file)──> private report  ──> scripts/factory-triage.mjs
                                                          │
                              ┌───────────────────────────┴───────────────────────────┐
                              │                                                       │
                     --file-issues                                              --promote <n>
                              │                                              --reviewed-by <actor>
                     public GitHub issue                                            │
                     (triage record in the body,                                     bead
                      fingerprint marker, labels)                          (priority, external-ref,
                                                                            BLOCKED only if the
                                                                            change is a functionality change)

Nothing creates a bead from a report. The publisher has no such option, which is why the safety property is testable: a scan run with both gh and bd stubs never invokes bd.

3. Input contract

Input Rule
Report location $VOICEBOX_FACTORY_PRIVATE_DIR, else ~/.voicebox/factory-reports. A path inside the repository is refused with exit 1, including through a symlink.
Report naming <target>-<agent>-delta.md. The station is the longest known station suffix, so voicebox-docs-drift-delta.md is docs-drift and never drift.
Station names The set factory list reports (22 stations as of 2026-10-07). An unknown station is refused unless --agent names it explicitly.
Report form The full delta report. A reduced (step-summary) form has no snippet, so its identity cannot be recomputed: its findings are not published, and the plan says so.
Repository --repo <owner/name> or $VOICEBOX_FACTORY_REPO. Issues are published to a named repository; there is no implicit default.

Why the private path matters at all, given everything is published: the report is unredacted. It holds the credential value, the raw payload and the private evidence trail. The refusal is what stops one git add -A from committing it. Publishing the finding is the decision; publishing the raw value is never allowed.

4. What gets published

Effective severity Station class Published? Bead priority on promotion
critical any Yes — deliberately, with the raw candidate withheld for identity-critical stations P1
high any Yes — deliberately (the embargo would withhold it) P1
medium any Yes P2
low, info any Yes — every severity reaches the triage surface by default (--exclude-low opts out) P3
any any Functionality change → published, marked human-review. The promoted bead is BLOCKED only on an explicit assertion (--functionality-change, or a recorded reason) — a bare human-review flag marks the issue for verification and leaves the bead claimable P1–P2 by severity
any security station published and marked for verification (not a block): the bead stays claimable P1–P2 by severity

Severity is routed, not merely displayed:

5. What is never published, and what is never dropped

Never published: raw credential values, and the raw candidate of any identity-critical station. Every other string is masked with the same credential shapes the factory masks, and absolute home paths and the private report path are elided — on the title as well as the body, because the factory’s own sink learned that lesson and a credential recognised in the body reached the tracker through the title.

Two layers decide what “a credential finding” means, mirroring the factory: the station (secret-scan) and the rule id (any rule containing key, secret, token, credential, password or private). Either one fires, because a station that is not the credential scanner can still report a matched key. For those findings the pipeline publishes derived text only — the rule, the location, the severity, the state and a generic rotate-and-remove remediation — and the triage model’s own title, description and remediation are dropped. What is dropped is the untrusted prose, never the finding: the severity, the identity, the rule and the location all still publish, so a reader always sees that a critical credential finding exists and where. That is deliberate, not fastidious: prose cannot be checked for an echo of a value whose shape is unknown, so no regex would have caught it. The value and the model’s notes exist only in the local report — and that report is a working file, not a durable store: the published issue is the durable record, and a matched credential has to be rotated rather than archived.

Never dropped silently: every finding parsed from the report appears in the plan, with the reason it was published or not (false positive, unchanged, below band, identity unverifiable, identity mismatch). A skip is a recorded decision, not a discard.

6. Publication is deliberately declared on the artefact

Every issue carries PUBLICATION_DECLARATION in a collapsed section: that the publisher reads an unredacted private report, that the repository’s visibility is public and declared deliberately, that it deliberately bypasses the factory’s public-sink embargo for its own findings and is not claiming a private target, why, and that no bead was created from the scan. A deliberate bypass that is not stated on the thing it affects is indistinguishable from a mistake.

7. Identity and dedupe

A finding’s identity is its fingerprint, recomputed exactly as the factory computes it: sha256(agent:rule_id:normalize_path(path):normalize_text(snippet)). The report prints only the first 16 hex characters, so the recomputed value is checked against that prefix before anything is published.

The issue body carries the fingerprint in an HTML marker (<!-- factory-fingerprint: … -->), and dedupe reads both marker shapes in the wild: this publisher’s HTML marker and the markdown line the factory’s own github-issues sink writes (the factory’s own github-issues sink writes **Fingerprint**: …). Both are the same sha256, and a dedupe that understood only its own marker would file a second public issue for one finding. Either the full digest or the 16-hex prefix the report itself prints will match. Dedupe then works off those markers:

If the issue list cannot be read, the run publishes nothing and says so. A duplicate issue is worse than no issue: it splits the triage record in two. The same rule applies to promotion, which dedupes against beads already carrying the fingerprint (either as external-ref factory:<sha256> or as a Fingerprint: line) so a finding cannot be promoted twice, and refuses outright when the issue itself already carries a <!-- factory-promoted: <id> --> marker.

8. Promotion creates the bead, and only a human starts it

node scripts/factory-triage.mjs --review <issue-number> --reviewed-by <actor> [--notes "..."]
node scripts/factory-triage.mjs --promote <issue-number> [--reviewed-by <actor>] [--apply]

Review evidence lives on the issue: --review posts a comment carrying <!-- factory-review: <actor> -->, and --promote refuses an issue that has no such record. A name typed at promotion time proves nothing about whether a review happened, so --reviewed-by at promotion is only a cross-check that must match the recorded reviewer, not the evidence itself. Promotion also refuses an issue marked <!-- factory-self-test --> (a publisher self-test is not work) and an issue that is already closed, unless --allow-closed says otherwise.

The bead body names the reviewer and the issue URL, and a comment carrying the factory-promoted marker is posted back on the issue. The bead deliberately does not copy the finding text — it points at the issue, so there is one place to correct. A functionality-changing issue produces a bead created BLOCKED with the human-review label and a comment naming the single decision that unblocks it.

8b. Promoting a reviewed, human-filed issue (read-only)

An inbound issue a person opened can become a bead, but only after it has been triaged and reviewed: --promote refuses until a review record exists. It reads the identity from the issue, and it never writes a marker into a comment — the existing-issue comment surface belongs to another lane’s commenter (bead voicebox-beads-cbxo), a script that does not live in this tree, and whose markers this script reads and never writes.

--promote <n> resolves the finding in this order:

  1. the publisher’s own <!-- factory-fingerprint: … --> in the issue body (unchanged);
  2. otherwise the triage markers another tool wrote in the comments: <!-- factory-triage-comment: <fingerprint> --> plus factory-station, factory-severity and factory-state, so the bead inherits the station, the severity (and therefore its priority) and the state instead of guessing from the title.

Only the fingerprint marker is required in that comment. The station and severity it names are inherited by the bead, and the state is recorded as-is; two fallbacks matter. An identity-critical station named by the marker marks the bead for verification even when no review flag was written — reason model-prose, the human-review label, and prose saying what needs verifying and why the bead stays claimable. A flag the writer does send wins over that inference. And with no state marker the bead records new, because a missing marker is not evidence of a regression. A station outside the identity-critical set with no flag acquires no verification marking: the inference must not manufacture a security signal for an ordinary quality finding.

If the comments describe more than one finding, promotion refuses and lists them, and --finding <fingerprint-or-prefix> says which one the bead is for. A comment can describe several findings while a bead carries one identity, so that choice is explicit rather than derived. An issue that neither its body nor its comments identify is refused by name.

A thread can carry one verdict per finding, and one bead per reviewed finding. The reviewer named in the bead is the author of the verdict that authorises that finding — reading the first verdict on the issue attributed the promotion to someone who had reviewed a different finding. The promotion receipt records which finding it covers (<!-- factory-promoted-fingerprint: … -->), so promoting the second reviewed finding from the same issue is allowed while re-promoting the first is refused by name. A record without a fingerprint predates that rule and blocks only an issue that identifies exactly one finding.

The verdict is tied to a finding. --review stamps the finding it is about (<!-- factory-review-fingerprint: … -->, automatic when the issue names exactly one), and promotion requires a verdict that names the identity being promoted: a verdict tied to a different finding is refused, and a thread that names several findings with a verdict that names none of them is refused as unreviewed. One verdict per finding; a review of the thread is not authorisation for any identity in it.

--comment / --issue-number were removed: this script does not comment on existing issues. A caller using them gets a named refusal that points at the other lane’s commenter, rather than a silent no-op.

9. What it never does

10. CLI

node scripts/factory-triage.mjs --report <path> [--json]            # plan only, writes nothing
node scripts/factory-triage.mjs --report <path> --file-issues       # publish (requires --repo)
node scripts/factory-triage.mjs --promote <n> --reviewed-by <who> [--apply]

Flags: --report-dir <dir>, --repo <owner/name>, --target <path>, --agent <name>, --exclude-low (skip low/info; they are published by default), --include-low (accepted and redundant), --functionality-change <rule|agent>, --json, --private-root <dir>, --review, --notes, --finding, --self-test, --allow-closed, --allow-foreign-target.

Exit codes are part of the contract: 0 a plan was produced / an issue published / a bead created; 1 a usage or policy refusal, or a write that failed — a publication where any gh issue create failed exits 1 and says how many, because a partial publication reported as success is how a CI job passes while a finding was never filed; 2 nothing actionable (nothing in band, or every finding already published).

The publication receipt is part of that contract, and every line a caller could act on is one line per finding: published: <fingerprint> -> <issue URL> for a filed issue, duplicate: ... — skipped for one that was already tracked, skipped: <fingerprint> (<reason>) for one this run dropped, and a closing issues: N published, M duplicate[, K skipped][, J FAILED]. The per-finding skipped: line exists because a consumer that summarises a factory log by key prefix (the miniapps adapter keeps published:, duplicate:, skipped: and failed: lines) never sees the closing summary, which starts with issues:. The skip count itself is load-bearing: a caller that rewrites the report artifact makes every recomputed fingerprint disagree with the declared prefix, so every finding skips, nothing is filed, and the run exits 2 — the same 2 as a report with nothing to do, distinguishable from it only by what the receipt says. The all-skipped run therefore prints those lines too, with its reasons above them in the plan summary. --json output is not augmented. A report that declares more new/regressed findings than it contains is NOT 2: it exits 1 and publishes nothing, because a killed or timed-out station run is indistinguishable from a complete one except by that declared count — record the run as UNKNOWN and re-run the station.

Two refusals exist to stop silent mis-publication:

Exit codes: 0 a plan was produced, an issue was published, or a bead was created · 1 usage or policy refusal (in-repo report, unreadable file, unreadable issue list or board) · 2 nothing actionable (no findings in band, or everything already published).

11. Verification

12. Local activation (no CI)

The factory runs on the local VM only (Paul’s decision): stations run locally, the unredacted report stays on the machine, and the publisher writes its issues and comments from there. No GitHub Actions, no runner, no CI cost.

What that requires of this CLI, and what was verified rather than assumed:

# 1. stations (unchanged; local, file sink)
factory run <station> --target ~/voicebox --sink file
# 2. inspect locally - no tracker, no token, no CI
node scripts/factory-triage.mjs --report ~/.voicebox/factory-reports/voicebox-perf-review-delta.md \
  --write-plan /tmp/triage/plan.json
# 3. publish (needs an authenticated gh and a repository)
node scripts/factory-triage.mjs --report ~/.voicebox/factory-reports/voicebox-perf-review-delta.md \
  --repo owner/name --file-issues
# 4. review on the issue, then promote
node scripts/factory-triage.mjs --review <n> --reviewed-by <actor> --repo owner/name
node scripts/factory-triage.mjs --promote <n> --repo owner/name --target ~/voicebox --apply
# 5. promote a reviewed, human-filed issue (read-only: reads the commenter's markers, writes none)
node scripts/factory-triage.mjs --promote <n> [--finding <fingerprint>] --repo owner/name --apply

A CI wiring candidate (a GitHub workflow calling this publisher) was superseded by the local-only decision. The flags above are unchanged by that, so nothing in this contract moved; what moved is where the command runs. A local adapter that re-scans an issue thread still gets the same factory-triage-comment dedupe, which is what stops it repeating itself.