diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c67637e..11958ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to codexclaw are documented here. The format follows ## [Unreleased] +### Added + +- Lane council: `check-lane-packet.mjs --council` gives every dispatch lane of a packet set four deterministic role stances before a fan-out, after [Quorum](https://github.com/tryquorum/quorum). The Reviewer rejects an objective another lane already has and holds a reworded or contained one; the CSO holds a lane whose objective or criteria ask for merge, push, a PR, deploy, release, publish, send, destroy or pay without the packet declaring it, in English or Korean, with negation such as "do not merge" scoped to the verb it governs; the CEO holds a lane with no objective. Exit 0 pass, 2 hold, 1 invalid or rejected; `--json` carries the stances with stable rule ids. Packets gain an optional `authority.outbound` list for the actions without a field of their own. `lane-dispatch.md` documents the step as LANE-COUNCIL-01. (#338) + ## [0.2.46] - 2026-10-31 ### Fixed diff --git a/devlog/_plan/261109_quorum_lane_council/000_plan.md b/devlog/_plan/261109_quorum_lane_council/000_plan.md new file mode 100644 index 00000000..579de90d --- /dev/null +++ b/devlog/_plan/261109_quorum_lane_council/000_plan.md @@ -0,0 +1,176 @@ +# 261109 Lane council: role stances before a lane fan-out (Quorum) + +A coordinator that fans work out across lanes today learns only whether each packet is +coherent: lane id, branch and write-scope collisions, loop and merge authority. Nothing +asks whether two lanes chase the same objective under different branches, or whether a +lane's objective tells it to deploy, publish, send, destroy or pay when nobody declared +that action. This unit adds an opt-in `--council` preflight to +`check-lane-packet.mjs`, after [Quorum](https://github.com/tryquorum/quorum): four +deterministic role stances (CEO, Reviewer, QA, CSO) give each dispatch packet a +pass, hold or reject before creation. Coordinators get one list of lanes to rewrite and +one question for the user instead of discovering the overlap or the outbound action +after lanes have run. + +## Loop spec + +- **Loop archetype:** satisfy-spec, single work-phase (C3: public script contract plus a + skill reference rule). +- **Trigger:** owner request (2026-11-09) to contribute Quorum's council idea to + codexclaw with one issue and one implementation PR to `dev`. +- **Goal:** `node plugins/codexclaw/scripts/check-lane-packet.mjs --council` + prints a per-lane verdict with role stances and exits 0 (all pass), 2 (some hold) or + 1 (invalid or some reject). +- **Non-goals:** verdict memory reuse (codexclaw approvals are per person and per + session; a fuzzy match must never stand in for one); LLM critic dispatch (A-phase and + C-phase reviewers already own judgment); intercepting `create_thread` (no hook); + merging, releasing, deploying. +- **Verifier:** `node --test plugins/codexclaw/test/lane-council.test.mjs` red before + the module exists, green after; `node --test plugins/codexclaw/test/lane-packet.test.mjs` + unchanged green; `npm run build`, `npm test`, `node plugins/codexclaw/scripts/gate.mjs`, + `node plugins/codexclaw/scripts/check-prompt-architecture.mjs`. +- **Stop condition:** PR open against `dev` with the issue linked, reviewer round folded + in, exact-head CI observed; merge stays with the maintainer. +- **Memory artifact:** this unit (`devlog/_plan/261109_quorum_lane_council/`), the issue + and the PR description. +- **Expected terminal outcomes:** DONE with PR URL and head; NEEDS_HUMAN if CI fails for a + reason outside this change. +- **Escalation condition:** any change to existing validator exit codes or accepted + packets; anything that would need a hook. + +## Placement + +`plugins/codexclaw/scripts/check-lane-packet.mjs:1-21` already runs at the fan-out +point and `validateLanePacketSet` (`:184-207`) is the only place that sees every lane +together. `lane-dispatch.md` LANE-PACKET-01 documents it and its "Known limitation" +(`:153-156`): it neither intercepts dispatch nor enforces authorization. The council +keeps that tier (E7/E8, `structure/40_enforcement_methods.md`): an advisory preflight +the coordinator runs and follows. + +## Design decisions (architect proposal 01a1be8f, dispositions) + +| ID | Decision | Disposition | +|---|---|---| +| D1 | Pure module `scripts/lane-council.mjs` (`councilLane`, `councilLanes`; the CLI entry is `councilLanePackets` in check-lane-packet.mjs), no I/O; CLI validates first, invalid exits 1 before the council | accept | +| D2 | Only `dispatch` packets receive verdicts; `pending`/`bound` lanes already exist and are reported `not-evaluated`, but they are Reviewer peers: a dispatch lane is compared with every created lane and with the dispatch lanes before it | accept (audit M2 wording) | +| D3 | `{lane, decision, stances[{role, stance, rule, reason}]}` plus `rulesVersion`; stable rule ids | accept | +| D4 | `authority.outbound` is validated by `validateLanePacket`: known categories only; `merge`, `push`, `pr` refused there because `authority.merge/push/openPr` own them | accept | +| D5 | Wording says *declared*, never *granted*; pass means "no hold found" | accept | +| D6 | any cut -> reject, else any escalate -> hold, else pass; the set reports all holds together so the user is asked once | accept | +| D7 | exit 0/1 unchanged; 2 only with `--council` and a hold | accept | +| D8 | Reviewer compares within the set: exact normalized objective -> cut; near -> escalate | amend: threshold 0.8 plus containment (see below), not 0.9 | +| D9 | unclear outbound wording escalates | accept | + +Architect conflicts folded: CEO and QA never cut a packet the contract accepts (non-loop +lanes need no objective or criteria, `lane-dispatch.md:32,47`): CEO escalates a missing +objective on a non-loop dispatch lane; QA keeps and names the field that defines done. +Destruction categories follow cxc-dev Safety Rules (file deletion, drops, resets, cache +clearing, force push), and push/PR wording is checked against `authority.push`/`openPr`. + +D8 amendment, measured with Quorum's `similarity` (character-bigram Dice on the +normalized text): distinct slices scored 0.69-0.72 ("Fix Windows CI for recall" / +"for state-cli"), 0.625, 0.852 ("link-criterion" / "add-criterion"); reworded copies +0.825, 0.892, 0.923. No lexical threshold separates them cleanly. 0.8 holds every measured +copy and one distinct pair (0.852), which costs a coordinator one rewrite; a hold is cheap +and a missed duplicate is the failure this exists for. (The first draft said 0.85, which +would have let the 0.825 copy through.) Containment (one normalized +objective inside the other, shorter >= 12 chars) also holds ("Update README badges" / +"... for test count"). Semantic duplicates below that (0.58, 0.765) remain for reviewers. + +## Rules (RULES_VERSION 1) + +- **CEO** — `CEO-NO-OBJECTIVE` escalate: dispatch lane without `work.objective`. + Otherwise keep. +- **Reviewer** — `REVIEWER-DUPLICATE` cut: normalized objective (NFC, lowercase, + whitespace/punctuation/symbols removed, >= 4 chars) equals an earlier lane's. + `REVIEWER-NEAR-DUPLICATE` escalate: Dice >= 0.8 or containment. Otherwise keep. +- **QA** — keep; reason names `work.criteria` when present, else `reporting.evidence` + (the validator already refuses a loop without criteria and any lane without evidence). +- **CSO** — scan `work.objective` and `work.criteria`, code spans included ("run + `npm publish`" is an instruction; the architect suggested skipping them, rejected). + Categories and their declaration: + `merge` (authority.merge), `push` (authority.push), `pr` (authority.openPr), + `deploy`, `release`, `publish`, `send`, `destroy`, `pay` (authority.outbound). + Undeclared match -> `CSO-OUTBOUND-` escalate. English negation directly + before the verb ("do not", "never", "without", ...) and Korean negation directly after + it ("하지 마", "금지", "않") suppress that one match only; any other un-negated match in + the same text still escalates. Word boundaries keep camelCase identifiers out. + +## File map + +| Path | Change | +|---|---| +| `plugins/codexclaw/scripts/lane-council.mjs` | new pure module | +| `plugins/codexclaw/scripts/check-lane-packet.mjs` | `authority.outbound` validation; `--council` flag, exit 2, text/json output | +| `plugins/codexclaw/test/lane-council.test.mjs` | new tests (red first) | +| `plugins/codexclaw/skills/loop/references/lane-dispatch.md` | LANE-COUNCIL-01 section, `authority.outbound` field row, limitation update | +| `structure/INDEX.md` | one sentence on the council next to the validator paragraph | +| `CHANGELOG.md` | Unreleased / Added | + +Field chain for `authority.outbound`: creation = coordinator writes the packet JSON; +serialization = packet file / create_thread prompt; deserialization = `JSON.parse` in the +CLI; consumers = `validateLanePacket` (type/known-category check) and `councilLane` +(declared categories). No other consumer. + +Enforcement bypass fields: tier E7/E8 advisory script; executing surface = coordinator +runs the CLI; known bypass = not running it, or wording the prompt outside the packet; +residual risk = lexical false negatives/positives listed above; wording downgrade = the +reference says "preflight", never "gate" or "only survivors run"; final enforcement layer: +none. + + +## Architect reflection (same architect 01a1be8f): ALIGNED, gaps and dispositions + +1. `authority.outbound` is validated without `--council` too. Intended: the field is new, + so no existing packet carries it; a malformed one should fail like any other malformed + authority field. Packets without it are unchanged. +2. Mixed sets: `pending` and `bound` lanes are Reviewer peers (a dispatch lane repeating + a created lane is the main duplicate case) but are themselves `notEvaluated`. +3. Single packet with `--council`: same `council` shape with one verdict; Reviewer keeps + with rule `REVIEWER-NO-PEERS`. +4. Word lists pinned in tests per category, EN+KO, plus false-positive cases: "release + notes", `mergeLanes`, "merge conflicts", "결제 모듈 리팩터". Korean nouns followed by a + code noun (모듈, 기능, 화면, 로직, 코드, 버그, 테스트, 페이지, 버튼, API ...) are not actions. +5. Negation is per match: "배포하지 않고 머지" negates deploy and still escalates merge. + +## Audit round 1 (hyds/deepseek-v4.1-flash-1m, GOAT task quorum-council-plan-audit-1): GO-WITH-FIXES (blockers=11) + +| # | Finding | Disposition | +|---|---|---| +| H1 | Quorum's OUTBOUND also covers submit, apply, transfer, dm, 제출, 신청 | folded: `send` adds submit (not "submit a PR", which is `pr`), "apply for", dm, 제출, 신청; `pay` adds "transfer funds/money/payment". Bare "apply" is dropped on purpose: "apply the patch" is everyday lane wording | +| H2 | false-positive exclusions not specified | folded, see Matching contract | +| H3 | `authority.outbound` schema unspecified | folded: optional array of nonempty strings, each one of deploy, release, publish, send, destroy, pay; merge/push/pr refused with the field that owns them; an empty array is valid; not added to `resolved` (its shape is pinned by an existing deepEqual) | +| H4 | no CLI-level verification | folded: spawn cases in `lane-council.test.mjs` for exit 0/2/1, `--json` shape, duplicate `--council`, invalid set, and unchanged output without the flag | +| M1 | CEO hold on every non-loop lane without an objective | rebutted: the council is opt-in and exists to compare objectives; a lane without one cannot be compared, so a hold is the honest answer. It is a coordinator fix (write the objective), not a user question; the reference says so | +| M2 | D2 vs reflection 2 | folded into D2 | +| M3 | serialization stage missing | folded: LANE-PACKET-01's field table gains `authority.outbound`, so the field travels in the creation prompt like the other authority fields | +| M4 | output contract | folded, see Output contract | +| M5 | negation window | folded, see Matching contract | +| L1 | usage/header | folded: header and usage string list `--council` and exit 2 | +| L2 | rule-id placement | folded: LANE-COUNCIL-01 is defined once, as a heading in `lane-dispatch.md` | + +Main judgment: near-pass. Every High finding is folded as a concrete contract below; the one +rebuttal is Medium with its basis stated. + +### Matching contract + +- Text is NFC-normalized. English patterns use word boundaries, so camelCase identifiers + (`mergeLanes`, `publishConfig`) never match; verbs that double as nouns carry + negative lookaheads ("merge conflicts/sort/queue/base", "push notifications"); "release" + matches only after cut/publish/tag/create/draft/make, or as `git tag` / "tag v1". +- A Korean action noun followed by a code noun (모듈, 기능, 화면, 로직, 코드, 버그, 테스트, + 페이지, 버튼, 흐름, 폼, 내역, 로그, 설정, 스크립트, 단계, 노트, 문서, 알림, API) is not an action. +- Negation is per match. English: the 40 characters before the match must end in a + negation (do/does/must/should/will/can not, don't, never, not, without, no, avoid) followed + only by whitespace. Korean: the 24 characters after the match must start with an optional + particle and 하지/지 + 마/말/않, 금지, 안 함/해/한다, or 하면/해서는 안 돼. A category escalates + when any one of its matches is not negated. + +### Output contract + +- Without `--council`: byte-identical output and exit codes. +- With `--council`: validation runs first (honouring `--mode`); invalid -> errors on + stderr, exit 1, no council. Valid -> the existing JSON envelope (`{ok, errors, lanes}` or + `{ok, errors, resolved}`) plus `council: {rulesVersion, verdicts[{lane, decision, + stances[{role, stance, rule, reason, ...}]}], pass[], hold[], reject[], notEvaluated[]}`. + Text mode prints a summary line and one line per non-pass lane on stdout. Exit 1 on any + reject, else 2 on any hold, else 0. diff --git a/devlog/_plan/261109_quorum_lane_council/010_review_round1.md b/devlog/_plan/261109_quorum_lane_council/010_review_round1.md new file mode 100644 index 00000000..3b36c55f --- /dev/null +++ b/devlog/_plan/261109_quorum_lane_council/010_review_round1.md @@ -0,0 +1,72 @@ +# 261109 Lane council: PR review round 1 fixes + +Previous D (090_done.md): council shipped in PR #339 at 84982d3, CI green; the open risk +was lexical precision. The fresh PR reviewer (GOAT task quorum-council-pr-review-1, +hyds/deepseek-v4.1-flash-1m) confirmed it: GO-WITH-FIXES (blockers=2), both in the CSO +word lists. This cycle folds the findings; direction unchanged. + +## Loop spec + +- Archetype: satisfy-spec repair, one work-phase. Trigger: PR review findings 1-14. +- Goal: everyday dev wording from the review no longer holds; the missed destructive and + outbound wording holds; both pinned as adversarial tests. +- Non-goals: semantic matching, LLM critics, new categories. +- Verifier: `node --test plugins/codexclaw/test/lane-council.test.mjs` (new adversarial + tests red on 84982d3, green after), `lane-packet.test.mjs`, `gate.mjs`, + `check-prompt-architecture.mjs`, hosted CI on the new head. +- Stop: fixes pushed to PR #339, same reviewer re-checks closure. Memory: this doc. +- Escalation: any change to exit codes or the non-council output. + +## Dispositions + +| # | Finding | Disposition | +|---|---|---| +| 1 (High) | FPs: delete endpoint/button/key, send a request, send email validation, publish type definitions/eslint config, pay attention, ship the feature flag code, merge the two arrays, push the button, submit button, bare dm, 게시판, 전송 속도 | fold: an English code-object guard after the ambiguous verbs (merge, push, deploy/ship, publish, send, submit, destroy/delete, pay): the next one to three words must not reach a code noun (endpoint, button, key, handler, request, validation, array, type, config, attention, flag ...); dm needs a target; KO guard gains 판, 물, 속도, 목록, 상태 ... and tolerates ":" between the words | +| 1 | "clear the npm cache" holds | rebut: cxc-dev Safety Rules list cache clearing as Destruction (ESCALATE) — `skills/dev/SKILL.md` "Destruction (ESCALATE): explicit approval for file deletion/table drops/state resets/cache clearing" | +| 1 | "ship a feature" holds | keep: shipping a feature to users is a release; the guard covers "feature flag code" | +| 2 (High) | FNs: destroy, sent, emailed, gh pr create, gh release create, land the changes, upload to prod | fold: destroy verb forms, sent, emailed, `gh pr create/merge`, `gh release create`, land + changes/commits, upload/release ... to prod/production/staging/npm | +| 3 | negation precision | fold: "not to", apostrophe-less contractions (dont, cant, wont), post-positive "is not allowed/forbidden/out of scope"; double negation left as is | +| 4 | tests mirror patterns | fold: adversarial lists from findings 1-3 pinned both ways | +| 5 | containment length and Quorum threshold undocumented | fold into the lane-dispatch table | +| 6 | `in FIELD_DECLARED` walks the prototype | fold: `Object.hasOwn` | +| 7 | councilLane TypeError on a packet without lane | fold: explicit contract error | +| 8 | re-validation per lane | rebut: `validateLanePacketSet` does not expose per-lane `resolved.mode`; calling the same pure validator keeps one source of truth | +| 9 | plan D1 names councilLaneSet | fold | +| 10 | force-push gives push and destroy | rebut: both declarations are needed (cxc-dev lists push and force push separately); one question per set is unchanged | +| 12 | "결제: 모듈" | fold (colon tolerated) | +| 13 | duplicate outbound entries | fold: refused | +| 11, 14 | QA inert; contractions | 11 already documented; 14 folded under 3 | + + +## Architect and audit + +No design decision (D1-D9: module role, data shape, interface, flow) changes; this cycle +tunes rule data and adds tests, so the architect is not reinvoked (phase-audit.md, +"Architect recheck after audit amendments"). The audit input is the PR review itself; +its closure is re-checked by the same reviewer after the fixes (DISPATCH-ACTOR-01). + +## Result + +Red first: the four new tests failed on 84982d3 (16 tests, 4 fail). Green at 0cedf470: +`lane-council.test.mjs` 16/16, `gate.mjs` OK, `check-prompt-architecture.mjs` OK, smoke set +unchanged (exit 2, same two holds). One adjustment during B: `step` left the code-noun list +because it suppressed "never skip the deploy step", and `submit` uses a next-word guard so +"submit the application form" still holds while "submit button" does not. + +Follow-up (C1, same unit): the first guard list held nouns a real action also takes as its +object, so "send the error report to the vendor" passed. Report, results, list, form, +error and similar left the list; a test pins five such sentences as holds (red on 0cedf470, +17/17 green after). Still true: matching is lexical, and a real action whose object is a +code-only noun within two words ("send the config to the vendor") passes. Closure goes back +to the same reviewer. + +## Round 2 (same reviewer, continueFrom): GO-WITH-FIXES (blockers=0) + +Blockers 1 and 2 confirmed closed; rebuttals on cache clearing, re-validation and +force-push accepted. Of the two new Mediums, re-probing showed part of the cited phrases +already held ("deploy the form", "delete the cache file") and duplicate outbound entries +are refused (finding 13 is closed; a test pins it). The class was real: "deploy the script +to staging", "deploy the hooks to production", "delete the helpers directory" and "destroy +the components" passed. Deploy and delete/destroy now use the next-word guard only (the +modifier use, "the delete endpoint"), pinned by a test that failed first (18 tests, 1 fail) +and passes now (18/18). "삭제: 버튼" not holding is the intended KO guard behaviour. diff --git a/devlog/_plan/261109_quorum_lane_council/090_done.md b/devlog/_plan/261109_quorum_lane_council/090_done.md new file mode 100644 index 00000000..0f558bc9 --- /dev/null +++ b/devlog/_plan/261109_quorum_lane_council/090_done.md @@ -0,0 +1,37 @@ +# 261109 Lane council: done record + +## Conclusion + +`check-lane-packet.mjs --council` gives every dispatch lane of a valid packet set four +deterministic role stances (CEO, Reviewer, QA, CSO) and a pass/hold/reject verdict. Issue +#338. One work-phase, single PABCD cycle. + +## Evidence (head 96e8d27a plus this record) + +- Red first: `node --test plugins/codexclaw/test/lane-council.test.mjs` failed with + `ERR_MODULE_NOT_FOUND` for `scripts/lane-council.mjs` before the module existed. +- Green: the same file, 12 tests, 12 pass, exit 0. +- `node plugins/codexclaw/scripts/gate.mjs` OK; `check-prompt-architecture.mjs` OK; + `sync-readme-badges.mjs` ok on all three READMEs; `npm run build` exit 0. +- `npm test`: 3890 tests, 3883 pass, 5 skipped, 2 fail. Both failures belong to this + machine: `gui/test/router.test.ts` cannot resolve `react` (GUI dependencies not + installed in this worktree), and `lane-packet.test.mjs` "a drifted value is reported + as drift" fails identically on `origin/dev` with this change stashed (it reads a + local host artifact). Hosted CI is the authority for both. +- CLI smoke on a four-lane set: exit 2, holds the reworded CI-flake lane (83% similar) + and the docs lane that says "deploy" without declaring it; "do not merge" in the same + objective is ignored; the release lane that declares release and publish passes. The + same set without `--council` prints OK and exits 0. + +## What did not improve, and what would refute the direction + +- Lexical similarity cannot see semantic duplicates: two measured pairs that describe the + same investigation in different words scored 0.58 and 0.765 and pass. If coordinators + mostly duplicate work in different words, the Reviewer stance adds little and an LLM + critic (out of scope here) is the next step. +- The CSO reads packet fields only. Outbound wording placed elsewhere in a creation + prompt escapes it; that is documented under Known limitation. +- If real fan-outs show frequent false holds on code wording ("fix the delete endpoint", + "payment form"), the category lists need a narrower verb grammar; the stable rule ids + and `RULES_VERSION` exist so that change is visible. + diff --git a/plugins/codexclaw/scripts/check-lane-packet.mjs b/plugins/codexclaw/scripts/check-lane-packet.mjs index 670282c1..a94f2289 100644 --- a/plugins/codexclaw/scripts/check-lane-packet.mjs +++ b/plugins/codexclaw/scripts/check-lane-packet.mjs @@ -16,13 +16,18 @@ * an explicit mode; legacy packets without it retain their address-based default. * * Usage: - * node check-lane-packet.mjs [--mode dispatch|pending|bound] [--json] + * node check-lane-packet.mjs [--mode dispatch|pending|bound] [--json] [--council] * A packet set is { "lanes": [ , ... ] } and is additionally checked for * overlapping write scopes. Exit 0 = valid, 1 = invalid or unreadable. + * + * --council (LANE-COUNCIL-01) adds role stances on the dispatch lanes of a valid packet + * or set (lane-council.mjs, after Quorum): exit 1 when a lane is rejected, 2 when one + * holds, 0 when every evaluated lane passes. It is advisory like the rest of this file. */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { councilLanes, FIELD_DECLARED, OUTBOUND_LIST_CATEGORIES } from "./lane-council.mjs"; const text = (v) => typeof v === "string" && v.trim().length > 0; const list = (v) => Array.isArray(v) && v.length > 0 && v.every(text); @@ -155,6 +160,24 @@ export function validateLanePacket(packet, { mode } = {}) { const openPr = authority.openPr === true; if (openPr && !push) errors.push("authority.openPr without authority.push: a pull request needs a pushed branch"); if (merge && !push) errors.push("authority.merge without authority.push: a lane that cannot push cannot land its branch"); + // Outbound actions without a field of their own. Like the fields above, a listed + // category records what the packet says; it grants nothing. + if (authority.outbound !== undefined) { + if (!Array.isArray(authority.outbound) || !authority.outbound.every(text)) { + errors.push("authority.outbound must be a list of action categories when present: " + OUTBOUND_LIST_CATEGORIES.join(", ")); + } else { + const listed = new Set(); + for (const raw of authority.outbound) { + const category = raw.trim(); + if (listed.has(category)) errors.push("authority.outbound lists " + category + " twice"); + listed.add(category); + if (Object.hasOwn(FIELD_DECLARED, category)) + errors.push("authority.outbound cannot list " + category + ": " + FIELD_DECLARED[category] + " owns it"); + else if (!OUTBOUND_LIST_CATEGORIES.includes(category)) + errors.push("authority.outbound has unknown category " + category + "; known: " + OUTBOUND_LIST_CATEGORIES.join(", ")); + } + } + } const reporting = packet.reporting; if (!object(reporting)) { @@ -198,13 +221,41 @@ export function validateLanePacketSet(set, options = {}) { return { ok: errors.length === 0, errors, lanes: set.lanes.length }; } +/** + * Validate, then run the council on the dispatch lanes. An invalid packet or set never + * reaches the council. The result is the validator's own envelope plus council. + */ +export function councilLanePackets(parsed, options = {}) { + if (object(parsed) && Array.isArray(parsed.lanes)) { + const result = validateLanePacketSet(parsed, options); + if (!result.ok) return result; + const entries = parsed.lanes.map((packet) => ({ packet, mode: validateLanePacket(packet, options).resolved.mode })); + return { ...result, council: councilLanes(entries) }; + } + const result = validateLanePacket(parsed, options); + if (!result.ok) return result; + return { ...result, council: councilLanes([{ packet: parsed, mode: result.resolved.mode }]) }; +} + +const councilExit = (council) => (council.reject.length ? 1 : council.hold.length ? 2 : 0); + +function councilText(council) { + const lines = ["[codexclaw lane-council] rules v" + council.rulesVersion + ": pass=" + council.pass.length + " hold=" + council.hold.length + " reject=" + council.reject.length + " not-evaluated=" + council.notEvaluated.length]; + for (const v of council.verdicts) { + if (v.decision !== "hold" && v.decision !== "reject") continue; + for (const s of v.stances.filter((s) => s.stance !== "keep")) + lines.push(" " + v.decision.toUpperCase() + " " + v.lane + ": " + s.rule + " (" + s.role + ") " + s.reason); + } + return lines.join("\n"); +} + // Windows argv carries backslashes, so comparing basenames split on "/" silently decides // the CLI was never invoked and the process exits 0 with no output. Compare URLs instead. const invoked = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; if (invoked) { const args = process.argv.slice(2); try { - let file, mode, json = false; + let file, mode, json = false, council = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--mode") { @@ -214,6 +265,9 @@ if (invoked) { } else if (arg === "--json") { if (json) throw new Error("duplicate --json option"); json = true; + } else if (arg === "--council") { + if (council) throw new Error("duplicate --council option"); + council = true; } else if (arg.startsWith("-")) { throw new Error("unknown option: " + arg); } else { @@ -221,13 +275,21 @@ if (invoked) { file = arg; } } - if (!file) throw new Error("usage: node check-lane-packet.mjs [--mode dispatch|pending|bound] [--json]"); + if (!file) throw new Error("usage: node check-lane-packet.mjs [--mode dispatch|pending|bound] [--json] [--council]"); const parsed = JSON.parse(readFileSync(file, "utf8")); + if (council) { + const result = councilLanePackets(parsed, { mode }); + if (json) console.log(JSON.stringify(result, null, 2)); + else if (!result.ok) console.error("[codexclaw lane-packet] FAIL\n" + result.errors.map((e) => " - " + e).join("\n")); + else console.log(councilText(result.council)); + process.exitCode = result.ok ? councilExit(result.council) : 1; + } else { const result = Array.isArray(parsed?.lanes) ? validateLanePacketSet(parsed, { mode }) : validateLanePacket(parsed, { mode }); if (json) console.log(JSON.stringify(result, null, 2)); else if (result.ok) console.log("[codexclaw lane-packet] OK" + (result.resolved ? " mode=" + result.resolved.mode : "")); else console.error("[codexclaw lane-packet] FAIL\n" + result.errors.map((e) => " - " + e).join("\n")); process.exitCode = result.ok ? 0 : 1; + } } catch (error) { console.error("[codexclaw lane-packet] " + error.message); process.exitCode = 1; diff --git a/plugins/codexclaw/scripts/lane-council.mjs b/plugins/codexclaw/scripts/lane-council.mjs new file mode 100644 index 00000000..ace0ff95 --- /dev/null +++ b/plugins/codexclaw/scripts/lane-council.mjs @@ -0,0 +1,284 @@ +/** + * lane-council.mjs — role stances on dispatch packets before a fan-out (LANE-COUNCIL-01). + * + * After Quorum (https://github.com/tryquorum/quorum: src/engine/rules.ts decide(), + * src/engine/dedup.ts, src/engine/roles.ts). Quorum asks four role critics about every + * proposal before anything runs: CEO (is it needed), Reviewer (duplicate or bug), QA (can + * it be verified) and CSO (risk). Outbound actions hold for a person and near-duplicate + * proposals collapse. This module is the deterministic part of that council, applied to + * the packets a coordinator is about to dispatch. + * + * Advisory, like the packet validator it extends: nothing intercepts create_thread. A + * "pass" means no hold was found. It never means anybody approved the lane, and a field + * that declares an action records what the packet says; it is not a grant. + * + * Pure: no I/O, no clock, no memory. Callers validate packets first. + */ + +/** Bump when a rule changes, so a recorded verdict names the rules that produced it. */ +export const RULES_VERSION = 1; + +/** + * Character-bigram Dice on normalized objectives at or above this holds a lane. Quorum + * uses 0.6 on short Korean titles. English lane objectives share more bigrams by default: + * distinct slices measured 0.63-0.72 ("Fix Windows CI for recall" / "... for state-cli"), + * reworded copies 0.825-0.923. One distinct pair scored 0.852 ("link-criterion" / + * "add-criterion"), so a hold costs a coordinator one rewrite; a missed duplicate is the + * failure this exists for. + */ +export const NEAR_DUPLICATE = 0.8; +/** Normalized objectives shorter than this are never compared (Quorum SAME_TITLE_MIN). */ +export const SAME_OBJECTIVE_MIN = 4; +/** One objective inside another is a near duplicate when the shorter has this many chars. */ +export const CONTAINMENT_MIN = 12; + +/** Actions with their own authority field; authority.outbound may not restate them. */ +export const FIELD_DECLARED = { merge: "authority.merge", push: "authority.push", pr: "authority.openPr" }; +/** Actions declared only through authority.outbound. */ +export const OUTBOUND_LIST_CATEGORIES = ["deploy", "release", "publish", "send", "destroy", "pay"]; +export const OUTBOUND_CATEGORIES = [...Object.keys(FIELD_DECLARED), ...OUTBOUND_LIST_CATEGORIES]; + +export const normObjective = (s) => String(s).normalize("NFC").toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ""); + +function bigrams(s) { + const m = new Map(); + for (let i = 0; i < s.length - 1; i++) { + const g = s.slice(i, i + 2); + m.set(g, (m.get(g) || 0) + 1); + } + return m; +} + +/** 0..1 character-bigram Dice coefficient on normalized text (Quorum dedup.ts similarity). */ +export function similarity(a, b) { + const x = normObjective(a), y = normObjective(b); + if (!x || !y) return 0; + if (x === y) return 1; + if (x.length < 2 || y.length < 2) return 0; + const gx = bigrams(x), gy = bigrams(y); + let both = 0; + for (const [g, n] of gx) both += Math.min(n, gy.get(g) || 0); + return (2 * both) / (x.length - 1 + (y.length - 1)); +} + +// A Korean action noun followed by a code noun names code, not an action: "결제 모듈 +// 리팩터" refactors the payment module, "게시판" is a board, "전송 속도" is a speed; "결제" +// alone pays. A colon or dash between the two words does not change that. +const KO_NOT_ACTION = "(?![\\s:·-]*(?:모듈|기능|화면|로직|코드|버그|테스트|페이지|버튼|흐름|폼|내역|로그|설정|스크립트|단계|노트|문서|알림|판|물|속도|목록|상태|이력|방식|형식|실패|오류|에러|함수|핸들러|엔드포인트|큐|API|api))"; +const ko = (...words) => new RegExp("(?:" + words.join("|") + ")" + KO_NOT_ACTION, "gu"); +const en = (source) => new RegExp(source, "giu"); + +// English verbs that double as code words ("the delete endpoint", "send a request", +// "publish the type definitions", "merge the two arrays", "pay attention") are not actions +// when a code noun follows within two words. Deploy targets (docs, site, page, app) and +// "step" are left off this list on purpose: "deploy the docs site" is a deploy, and "never +// skip the deploy step" asks for one. +// Nouns a real action also takes as its object (report, results, list, form, error) stay +// off: "send the error report to the vendor" must hold. +const CODE_NOUNS = "endpoints?|buttons?|keys?|handlers?|requests?|validation|validators?|events?|methods?|functions?|routes?|quer(?:y|ies)|flags?|logic|modals?|dialogs?|fields?|icons?|hooks?|utils?|helpers?|types?|typings|definitions?|configs?|attention|arrays?|objects?|maps?|dicts?|components?|scripts?|sort|conflicts?|queues?|base|notifications?"; +const NOT_CODE = "(?![-\\s]+(?:[\\w'-]+\\s+){0,2}?(?:" + CODE_NOUNS + ")\\b)"; +const verb = (forms) => en("\\b(?:" + forms + ")\\b" + NOT_CODE); +// Submitting reaches a form through its object ("submit the application form"), so only a +// code noun right after it ("submit button") makes it code. +const NOT_CODE_NEXT = "(?![-\\s]+(?:" + CODE_NOUNS + ")\\b)"; +// Deploying or deleting code is still a deploy or a deletion ("deploy the script to +// staging", "delete the helpers directory"); only the modifier use right before a code noun +// ("the delete endpoint", "a deploy script") names code. +const modifierVerb = (forms) => en("\\b(?:" + forms + ")\\b" + NOT_CODE_NEXT); + +/** + * Wording that asks a lane to act outside its checkout. Word boundaries keep camelCase + * identifiers (mergeLanes, publishConfig) out. Code spans are scanned too, because "run + * npm publish" in backticks is still an instruction. Unclear wording errs toward a hold. + */ +const PATTERNS = { + merge: [ + verb("merge|merges|merged|merging"), + en("\\bgh\\s+pr\\s+merge\\b"), + en("\\bland(?:s|ed|ing)?\\s+(?:the\\s+|this\\s+|that\\s+|these\\s+|a\\s+)?(?:branch(?:es)?|prs?|pull\\s+requests?|changes?|commits?|fix(?:es)?|patch(?:es)?|work)\\b"), + ko("머지", "병합"), + ], + push: [verb("push|pushes|pushed|pushing"), ko("푸시")], + pr: [ + en("\\b(?:open|opens|opened|opening|create|creates|created|creating|file|raise|submit)\\s+(?:a\\s+|an\\s+|the\\s+)?(?:draft\\s+)?(?:pr|pull\\s+request)s?\\b"), + en("\\bgh\\s+pr\\s+create\\b"), + new RegExp("\\bPR\\s*(?:을|를)?\\s*(?:올리|올려|열|생성)", "giu"), + ], + deploy: [ + modifierVerb("deploy|deploys|deployed|deploying|deployment"), + en("\\broll(?:s|ed|ing)?[- ]?out\\b"), + en("\\bgo(?:es)?\\s+live\\b"), + verb("ship|ships|shipped|shipping"), + en("\\bupload(?:s|ed|ing)?\\b[^.;\\n]{0,30}?\\bto\\s+(?:prod|production|staging|live|s3|npm|pypi|the\\s+(?:server|store))\\b"), + en("\\breleas(?:e|es|ed|ing)\\s+(?:it\\s+|this\\s+|them\\s+|the\\s+[\\w-]+\\s+)?to\\s+(?:prod|production|staging|users|customers)\\b"), + ko("배포"), + ], + release: [ + en("\\b(?:cut|cuts|publish|tag|create|draft|make)\\s+(?:a\\s+|an\\s+|the\\s+)?(?:new\\s+)?release\\b"), + en("\\bgh\\s+release\\s+create\\b"), + en("\\bgit\\s+tag\\b"), + en("\\btag(?:s|ged|ging)?\\s+v?\\d"), + ko("릴리스", "릴리즈"), + ], + publish: [verb("publish|publishes|published|publishing"), ko("게시", "발행")], + send: [ + verb("send|sends|sending|sent"), + en("\\be-?mail(?:s|ed|ing)?\\s+(?:to|the|a|an|them|him|her)\\b"), + en("\\be-?mailed\\b"), + en("\\bpost(?:s|ed|ing)?\\b[^.;\\n]{0,40}?\\b(?:to|on|in)\\s+(?:slack|discord|teams|twitter|x|linkedin|reddit|github|the\\s+(?:channel|issue|pr|pull\\s+request|forum|thread))\\b"), + en("\\bapprov(?:e|es|ed|ing)\\s+(?:the\\s+|this\\s+|a\\s+)?(?:pr|pull\\s+request)\\b"), + en("\\bcomment(?:s|ed|ing)?\\s+on\\s+(?:the\\s+|this\\s+|a\\s+)?(?:pr|pull\\s+request|issue)\\b"), + // Quorum also holds submit/apply/dm. "submit a PR" is the pr category, and bare + // "apply" is left out: "apply the patch" is everyday lane wording. + en("\\bsubmit(?:s|ted|ting)?\\b(?!\\s+(?:a\\s+|an\\s+|the\\s+)?(?:draft\\s+)?(?:pr|pull\\s+request)s?\\b)" + NOT_CODE_NEXT), + en("\\bappl(?:y|ies|ied|ying)\\s+for\\b"), + en("\\bdms?\\s+(?:the\\s+|a\\s+|an\\s+)?@?\\w"), + ko("보내", "보낸", "전송", "발송", "제출", "신청"), + ], + destroy: [ + modifierVerb("delete|deletes|deleted|deleting|destroy|destroys|destroyed|destroying"), + en("\\bdrop(?:s|ped|ping)?\\s+(?:the\\s+)?(?:\\w+\\s+)?(?:table|database|schema|column|collection)s?\\b"), + en("\\brm\\s+-[a-z]*r"), + en("\\b(?:wipe|wipes|wiped|wiping|purge|purges|purged|purging)\\b"), + en("\\breset\\s+--hard\\b|\\b(?:hard|state)\\s+reset\\b|\\breset\\s+(?:the\\s+)?(?:state|database|db)\\b"), + // cxc-dev Safety Rules list cache clearing as Destruction (ESCALATE). + en("\\bclear(?:s|ed|ing)?\\s+(?:the\\s+)?(?:\\w+\\s+)?caches?\\b"), + en("\\bforce[- ]?push(?:es|ed|ing)?\\b|\\bpush\\s+(?:--force|-f)\\b"), + ko("삭제", "초기화", "지우", "지워"), + ], + pay: [ + verb("pay|pays|paid|paying|purchase|purchases|purchased|purchasing|buy|buys|bought|buying"), + en("\\btransfer(?:s|red|ring)?\\s+(?:the\\s+)?(?:funds|money|payments?)\\b"), + ko("결제", "구매", "송금"), + ], +}; + +// Negation counts only when it governs this one verb: directly before it in English +// ("do not merge", "never deploy", "make sure not to deploy", "dont publish"), directly +// after it in English ("merge is not allowed") and Korean ("병합은 하지 마요", "배포 금지"). +// "never skip the deploy step" and "do not merge until CI passes, then merge" still hold, +// because another mention stands un-negated. +const EN_NEGATION_BEFORE = /(?:\b(?:do|does|did|must|should|shall|will|can|could|may)\s+not|\b(?:don['’]?t|doesn['’]?t|didn['’]?t|mustn['’]?t|shouldn['’]?t|won['’]?t|can['’]?t|cannot|never|not|without|no)|\bavoid)\s+(?:to\s+)?$/iu; +const EN_NEGATION_AFTER = /^\s+(?:is|are|was|stays?|remains?)\s+(?:not\s+allowed|forbidden|prohibited|out\s+of\s+scope|not\s+in\s+scope|off[- ]limits)\b/iu; +const KO_NEGATION_AFTER = /^\s*(?:은|는|을|를|도|만)?\s*(?:(?:하지|지)\s*(?:마|말|않)|금지|안\s*(?:함|해|한다|하)|(?:하면|해서는)\s*안\s*(?:돼|되|됨|된다))/u; + +function negated(text, start, end) { + const after = text.slice(end, end + 32); + return EN_NEGATION_BEFORE.test(text.slice(Math.max(0, start - 40), start)) || EN_NEGATION_AFTER.test(after) || KO_NEGATION_AFTER.test(after); +} + + +/** Un-negated outbound mentions in text: one entry per category, first match kept. */ +export function outboundMentions(value) { + const text = String(value ?? "").normalize("NFC"); + const out = []; + for (const category of OUTBOUND_CATEGORIES) { + for (const pattern of PATTERNS[category]) { + const hit = [...text.matchAll(pattern)].find((m) => !negated(text, m.index, m.index + m[0].length)); + if (hit) { out.push({ category, match: hit[0].trim() }); break; } + } + } + return out; +} + +const filled = (v) => typeof v === "string" && v.trim().length > 0; + +/** Whether the packet declares a category. Declared is a record, never a grant. */ +export function declares(packet, category) { + const authority = packet?.authority && typeof packet.authority === "object" ? packet.authority : {}; + if (category === "merge") return authority.merge === true; + if (category === "push") return authority.push === true; + if (category === "pr") return authority.openPr === true; + return Array.isArray(authority.outbound) && authority.outbound.some((c) => typeof c === "string" && c.trim() === category); +} + +const declaringField = (category) => FIELD_DECLARED[category] ?? "authority.outbound"; + +function ceo(packet) { + if (filled(packet.work?.objective)) return [{ role: "ceo", stance: "keep", rule: "CEO-OBJECTIVE", reason: "work.objective states what the lane is for" }]; + return [{ role: "ceo", stance: "escalate", rule: "CEO-NO-OBJECTIVE", reason: "no work.objective: nobody can tell whether this lane is needed or repeats another" }]; +} + +function reviewer(packet, peers) { + const mine = packet.work?.objective; + if (!filled(mine)) return [{ role: "reviewer", stance: "keep", rule: "REVIEWER-NO-OBJECTIVE", reason: "no objective to compare" }]; + const comparable = peers.filter((p) => filled(p.objective)); + if (comparable.length === 0) return [{ role: "reviewer", stance: "keep", rule: "REVIEWER-NO-PEERS", reason: "no other lane to compare" }]; + const key = normObjective(mine); + if (key.length >= SAME_OBJECTIVE_MIN) { + const same = comparable.find((p) => normObjective(p.objective) === key); + if (same) return [{ role: "reviewer", stance: "cut", rule: "REVIEWER-DUPLICATE", reason: "same objective as lane " + same.lane + ": fold the work into that lane or split the objective", peer: same.lane }]; + for (const p of comparable) { + const other = normObjective(p.objective); + if (other.length < SAME_OBJECTIVE_MIN) continue; + const [shorter, longer] = key.length <= other.length ? [key, other] : [other, key]; + if (shorter.length >= CONTAINMENT_MIN && longer.includes(shorter)) + return [{ role: "reviewer", stance: "escalate", rule: "REVIEWER-NEAR-DUPLICATE", reason: "objective overlaps lane " + p.lane + " (one contains the other): confirm they are separate slices", peer: p.lane }]; + const score = similarity(mine, p.objective); + if (score >= NEAR_DUPLICATE) + return [{ role: "reviewer", stance: "escalate", rule: "REVIEWER-NEAR-DUPLICATE", reason: "objective is " + Math.round(score * 100) + "% similar to lane " + p.lane + ": confirm they are separate slices", peer: p.lane, score: Math.round(score * 1000) / 1000 }]; + } + } + return [{ role: "reviewer", stance: "keep", rule: "REVIEWER-DISTINCT", reason: "no other lane has a matching objective" }]; +} + +function qa(packet) { + if (Array.isArray(packet.work?.criteria) && packet.work.criteria.some(filled)) + return [{ role: "qa", stance: "keep", rule: "QA-CRITERIA", reason: "work.criteria define done" }]; + return [{ role: "qa", stance: "keep", rule: "QA-EVIDENCE", reason: "reporting.evidence names what comes back" }]; +} + +function cso(packet) { + const work = packet.work ?? {}; + const texts = [work.objective, ...(Array.isArray(work.criteria) ? work.criteria : [])].filter(filled); + const found = new Map(); + for (const t of texts) for (const m of outboundMentions(t)) if (!found.has(m.category)) found.set(m.category, m.match); + const stances = []; + for (const [category, match] of found) { + if (declares(packet, category)) continue; + stances.push({ role: "cso", stance: "escalate", rule: "CSO-OUTBOUND-" + category, reason: "\"" + match + "\" asks for " + category + ", which " + declaringField(category) + " does not declare: the user decides before dispatch", category }); + } + if (stances.length) return stances; + const declared = [...found.keys()]; + return [{ role: "cso", stance: "keep", rule: declared.length ? "CSO-DECLARED" : "CSO-NONE", reason: declared.length ? "outbound wording matches declared " + declared.join(", ") + " (a record, not a grant)" : "no outbound wording found" }]; +} + +/** Quorum decide() order adapted: any cut rejects, else any escalate holds, else pass. */ +export function decide(stances) { + if (stances.some((s) => s.stance === "cut")) return "reject"; + if (stances.some((s) => s.stance === "escalate")) return "hold"; + return "pass"; +} + +/** + * Stances and verdict for one validated dispatch packet. peers are the other lanes it + * must not repeat: [{ lane, objective }]. + */ +export function councilLane(packet, peers = []) { + if (!filled(packet?.lane)) throw new TypeError("councilLane needs a validated packet with a lane id; run validateLanePacket first"); + const stances = [...ceo(packet), ...reviewer(packet, peers), ...qa(packet), ...cso(packet)]; + return { lane: packet.lane.trim(), decision: decide(stances), stances }; +} + +/** + * Council over validated lanes. Each entry is { packet, mode }. Only dispatch lanes are + * evaluated: a pending or bound lane already exists, so holding it changes nothing, but it + * is still a peer a dispatch lane must not repeat. A dispatch lane is compared with every + * created lane and with the dispatch lanes before it, so the first of two copies survives. + */ +export function councilLanes(entries) { + const verdicts = []; + const created = entries.filter((e) => e.mode !== "dispatch").map((e) => ({ lane: e.packet.lane.trim(), objective: e.packet.work?.objective })); + const earlier = []; + for (const { packet, mode } of entries) { + if (mode !== "dispatch") { + verdicts.push({ lane: packet.lane.trim(), decision: "not-evaluated", reason: mode + " lane already exists", stances: [] }); + continue; + } + verdicts.push(councilLane(packet, [...created, ...earlier])); + earlier.push({ lane: packet.lane.trim(), objective: packet.work?.objective }); + } + const ids = (d) => verdicts.filter((v) => v.decision === d).map((v) => v.lane); + return { rulesVersion: RULES_VERSION, verdicts, pass: ids("pass"), hold: ids("hold"), reject: ids("reject"), notEvaluated: ids("not-evaluated") }; +} + diff --git a/plugins/codexclaw/skills/loop/references/lane-dispatch.md b/plugins/codexclaw/skills/loop/references/lane-dispatch.md index 1ce402ca..be7cee9f 100644 --- a/plugins/codexclaw/skills/loop/references/lane-dispatch.md +++ b/plugins/codexclaw/skills/loop/references/lane-dispatch.md @@ -50,6 +50,7 @@ coordinator's files or inherited history. Its creation prompt or fork follow-up | `authority.loop` | Default false. False means work and report | | `authority.push`, `authority.openPr` | Default false. A pull request needs a pushed branch, and merge needs both | | `authority.merge`, `authority.mergeTarget` | Default false. See below | +| `authority.outbound[]` | Optional. Actions outside the checkout the user approved for this lane: `deploy`, `release`, `publish`, `send`, `destroy`, `pay`. Merge, push and PR keep their own fields | | `reporting.evidence[]`, `reporting.onBlocked` | What must come back, and what to do instead of guessing | A packet has three states. `dispatch` is before creation and cannot carry creation @@ -150,7 +151,39 @@ Managed-worktree retention is in the host envelope: archive cleanup may delete or transfer a checkout. Preserve work through an authorized landing or push; never rely on an unowned worktree as the only copy. +## LANE-COUNCIL-01 (DEFAULT) — role stances before a fan-out + +Before creating two or more lanes, run the packet set through +`check-lane-packet.mjs --council` (after +[Quorum](https://github.com/tryquorum/quorum)). Every `dispatch` lane gets four +deterministic stances; `pending` and `bound` lanes are not evaluated but count as +lanes a new one must not repeat. + +| Role | Holds or rejects when | +|---|---| +| CEO | hold: no `work.objective`, so nobody can tell whether the lane is needed | +| Reviewer | reject: the objective equals another lane's after case, spacing and punctuation are removed; hold: it is 80% similar by character bigrams (Quorum uses 60% on short Korean titles; English objectives share more bigrams) or one contains the other and the shorter has at least 12 characters | +| QA | never; it names the field that defines done | +| CSO | hold: the objective or criteria ask for merge, push, a PR, deploy, release, publish, send, destroy or pay, and the packet does not declare it | + +Dispatch pass lanes. Rewrite a rejected lane: fold it into the lane it repeats or split +the objective. A CEO or Reviewer hold is the coordinator's to settle by writing or +sharpening the objective. A CSO hold is the user's: ask once for every held lane in the +set, then record the answer in `authority` and run the council again. Declaring an action +records the user's answer; the council never treats its own pass as approval. Exit 0 = +all evaluated lanes pass, 2 = some hold, 1 = invalid or some rejected; `--json` adds +`council` with `rulesVersion` and a stable `rule` id per stance. + +Matching is lexical, English and Korean. A negation that governs the verb ("do not +merge", "병합은 하지 마요", "merge is not allowed") suppresses that one mention only. A verb +followed within two words by a code noun ("the delete endpoint", "send a request", +"결제 모듈") names code, not an action. Unclear wording holds; the cost of a false hold is one +rewrite or question. + ## Known limitation The packet validator checks record coherence; it neither intercepts dispatch nor -enforces prose authorization at the orchestration boundary. +enforces prose authorization at the orchestration boundary. The council is an advisory +preflight of the same kind: it reads only packet fields, so wording placed elsewhere in a +creation prompt escapes it, and semantic duplicates below the similarity line need a +reviewer. diff --git a/plugins/codexclaw/test/lane-council.test.mjs b/plugins/codexclaw/test/lane-council.test.mjs new file mode 100644 index 00000000..26719506 --- /dev/null +++ b/plugins/codexclaw/test/lane-council.test.mjs @@ -0,0 +1,250 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { validateLanePacket, validateLanePacketSet, councilLanePackets } from "../scripts/check-lane-packet.mjs"; +import { councilLane, outboundMentions, similarity, NEAR_DUPLICATE, RULES_VERSION } from "../scripts/lane-council.mjs"; + +// LANE-COUNCIL-01: role stances on dispatch packets before a fan-out, after Quorum +// (tryquorum/quorum rules.ts decide(), dedup.ts). These pin the verdicts a coordinator acts +// on: which lanes to rewrite, which to ask the user about, which to dispatch. +const here = dirname(fileURLToPath(import.meta.url)); +const script = resolve(here, "..", "scripts", "check-lane-packet.mjs"); + +let n = 0; +const lane = (objective, extra = {}) => { + n += 1; + return { + lane: "lane-" + n, + mode: "dispatch", + work: { objective, criteria: ["focused test passes"], writeScope: ["scope/" + n], base: "dev", branch: "codex/lane-" + n }, + reporting: { evidence: ["branch head sha"], onBlocked: "report and stop" }, + ...extra, + }; +}; +const council = (lanes) => { + const r = councilLanePackets({ lanes }); + assert.equal(r.ok, true, r.errors.join("; ")); + return r.council; +}; +const verdict = (c, id) => c.verdicts.find((v) => v.lane === id); +const rules = (v) => v.stances.map((s) => s.rule); + +test("distinct dispatch lanes pass with one stance from each role", () => { + const a = lane("Fix Windows CI for recall"), b = lane("Fix Windows CI for state-cli"), c = lane("Port the WCAG owner to noslopdesign"); + const out = council([a, b, c]); + assert.equal(out.rulesVersion, RULES_VERSION); + assert.deepEqual(out.pass, [a.lane, b.lane, c.lane]); + for (const v of out.verdicts) assert.deepEqual(v.stances.map((s) => s.role), ["ceo", "reviewer", "qa", "cso"]); + assert.ok(similarity(a.work.objective, b.work.objective) < NEAR_DUPLICATE); +}); + +test("an exact duplicate objective rejects the later lane and keeps the first", () => { + const a = lane("Fix the Windows CI flake in state sync."), b = lane("fix the windows CI flake in state-sync"); + const out = council([a, b]); + assert.equal(verdict(out, a.lane).decision, "pass"); + assert.equal(verdict(out, b.lane).decision, "reject"); + assert.ok(rules(verdict(out, b.lane)).includes("REVIEWER-DUPLICATE")); + assert.deepEqual(out.reject, [b.lane]); +}); + +test("a reworded or contained objective holds; the measured distinct slice stays below the line", () => { + const a = lane("Fix the Windows CI flake in state sync"), b = lane("Fix the flaky Windows CI run in state sync"); + const c = lane("Update README badges"), d = lane("Update README badges for test count"); + const out = council([a, b, c, d]); + for (const id of [b.lane, d.lane]) { + assert.equal(verdict(out, id).decision, "hold", id); + assert.ok(rules(verdict(out, id)).includes("REVIEWER-NEAR-DUPLICATE")); + } + assert.deepEqual(out.hold, [b.lane, d.lane]); +}); + +test("created lanes are peers but are never evaluated themselves", () => { + const created = lane("Implement goalplan link-criterion verb"); + created.mode = "bound"; + created.address = { threadId: "01a0b875-9bb1", hostId: "local" }; + const pending = lane("Deploy the docs site"); + pending.mode = "pending"; + pending.creation = { provisionalId: "queued-1", hostId: "local", requestedAt: "2026-11-09T00:00:00Z" }; + const repeat = lane("Implement goalplan link-criterion verb"); + const out = council([repeat, created, pending]); + assert.equal(verdict(out, created.lane).decision, "not-evaluated"); + assert.equal(verdict(out, pending.lane).decision, "not-evaluated", "a created lane is not held for its wording"); + assert.equal(verdict(out, repeat.lane).decision, "reject", "a dispatch lane repeating a created lane is the main duplicate case"); + assert.deepEqual(out.notEvaluated, [created.lane, pending.lane]); +}); + +test("a single packet has no peers and a non-loop lane without an objective holds", () => { + const solo = councilLanePackets(lane("Add the council flag")); + assert.equal(solo.ok, true); + assert.equal(solo.council.verdicts[0].decision, "pass"); + assert.ok(rules(solo.council.verdicts[0]).includes("REVIEWER-NO-PEERS")); + const p = lane(undefined); + delete p.work.objective; + delete p.work.criteria; + const v = councilLanePackets(p).council.verdicts[0]; + assert.equal(v.decision, "hold"); + assert.ok(rules(v).includes("CEO-NO-OBJECTIVE")); + assert.ok(rules(v).includes("QA-EVIDENCE"), "QA never cuts a packet the contract accepts"); +}); + +// Each category: English and Korean wording that must hold, and the declaration that clears it. +const CATEGORIES = { + merge: { words: ["merge the PR into dev", "land the branch after CI", "머지까지 진행", "dev에 병합"], declare: (p) => { p.authority = { push: true, merge: true, mergeTarget: p.work.branch }; } }, + push: { words: ["push the branch", "git push origin", "브랜치 푸시"], declare: (p) => { p.authority = { push: true }; } }, + pr: { words: ["open a PR against dev", "create a draft pull request", "PR 올리기", "PR을 생성", "submit a pull request"], declare: (p) => { p.authority = { push: true, openPr: true }; } }, + deploy: { words: ["deploy to production", "roll out the change", "go live tonight", "ship it", "스테이징에 배포"], declare: (p) => { p.authority = { outbound: ["deploy"] }; } }, + release: { words: ["cut a release", "git tag the head", "tag v1.2.0", "릴리스 진행", "릴리즈 만들기"], declare: (p) => { p.authority = { outbound: ["release"] }; } }, + publish: { words: ["publish the package", "run \u0060npm publish\u0060", "블로그에 게시", "뉴스레터 발행"], declare: (p) => { p.authority = { outbound: ["publish"] }; } }, + send: { words: ["send the summary to the team", "email the customer", "post the summary to Slack", "approve the PR", "comment on the issue", "메일 보내기", "알림 전송", "공지 발송", "submit the application form", "apply for the grant", "DM the maintainer", "서류 제출", "휴가 신청"], declare: (p) => { p.authority = { outbound: ["send"] }; } }, + destroy: { words: ["delete the stale branches", "drop the users table", "rm -rf build", "wipe the state dir", "git reset --hard", "clear the npm cache", "force-push the branch", "오래된 브랜치 삭제", "상태 초기화"], declare: (p) => { p.authority = { push: true, outbound: ["destroy"] }; } }, + pay: { words: ["pay the invoice", "purchase a license", "buy credits", "결제하기", "라이선스 구매", "송금 처리", "transfer funds to the vendor"], declare: (p) => { p.authority = { outbound: ["pay"] }; } }, +}; + +test("outbound wording holds per category until the packet declares it", () => { + for (const [category, { words, declare }] of Object.entries(CATEGORIES)) { + for (const phrase of words) { + assert.ok(outboundMentions(phrase).some((m) => m.category === category), category + ": " + phrase); + const v = council([lane(phrase)]).verdicts[0]; + assert.equal(v.decision, "hold", phrase); + assert.ok(rules(v).includes("CSO-OUTBOUND-" + category), phrase + " -> " + rules(v).join(",")); + const cleared = lane(phrase); + declare(cleared); + const w = council([cleared]).verdicts[0]; + assert.equal(rules(w).includes("CSO-OUTBOUND-" + category), false, "declared " + category + ": " + phrase); + } + } +}); + +test("outbound wording in criteria counts as much as in the objective", () => { + const p = lane("Fix the flaky sync test"); + p.work.criteria = ["test passes", "deploy the fix to staging"]; + assert.ok(rules(council([p]).verdicts[0]).includes("CSO-OUTBOUND-deploy")); +}); + +test("code nouns, identifiers and governed negation do not hold", () => { + for (const phrase of ["write release notes", "refactor mergeLanes", "resolve merge conflicts", "fix publishConfig parsing", "결제 모듈 리팩터", "배포 스크립트 정리", "push notifications badge", "do not merge", "Never deploy from a lane", "Don’t publish anything", "without sending email", "병합은 하지 마요", "머지 금지", "배포하지 말 것", "apply the patch"]) + assert.deepEqual(outboundMentions(phrase), [], phrase); +}); + +test("negation suppresses only the verb it governs", () => { + assert.deepEqual(outboundMentions("do not merge until CI passes, then merge").map((m) => m.category), ["merge"]); + assert.deepEqual(outboundMentions("배포하지 않고 머지").map((m) => m.category), ["merge"]); + assert.deepEqual(outboundMentions("never skip the deploy step").map((m) => m.category), ["deploy"]); + assert.deepEqual(outboundMentions("submit a PR").map((m) => m.category), ["pr"], "a PR submission is the pr category, not send"); +}); + +test("authority.outbound is a packet field: known list categories only", () => { + const ok = lane("x"); ok.authority = { outbound: ["deploy", "send"] }; + assert.equal(validateLanePacket(ok).ok, true); + const empty = lane("x"); empty.authority = { outbound: [] }; + assert.equal(validateLanePacket(empty).ok, true); + for (const outbound of ["deploy", [1], [""], ["teleport"], ["merge"], ["push"], ["pr"], null, {}]) { + const p = lane("x"); p.authority = { outbound }; + const r = validateLanePacket(p); + assert.equal(r.ok, false, JSON.stringify(outbound)); + assert.match(r.errors.join(" "), /authority\.outbound/); + } +}); + +test("an invalid set never reaches the council", () => { + const a = lane("one"), b = lane("two"); + b.work.branch = a.work.branch; + const r = councilLanePackets({ lanes: [a, b] }); + assert.equal(r.ok, false); + assert.equal(r.council, undefined); +}); + +test("CLI: --council exits 0 pass, 2 hold, 1 reject or invalid; without it nothing changes", () => { + const dir = mkdtempSync(join(tmpdir(), "lane-council-cli-")); + try { + const file = join(dir, "set.json"); + const invoke = (args) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 20000 }); + const write = (lanes) => writeFileSync(file, JSON.stringify({ lanes })); + + write([lane("Fix Windows CI for recall"), lane("Port the WCAG owner")]); + let r = invoke([file, "--council"]); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /pass=2 hold=0 reject=0/); + + const held = lane("deploy the docs site"); + write([held]); + r = invoke(["--council", file, "--json"]); + assert.equal(r.status, 2, r.stderr); + const json = JSON.parse(r.stdout); + assert.deepEqual(json.council.hold, [held.lane]); + assert.equal(json.council.rulesVersion, RULES_VERSION); + assert.match(invoke([file, "--council"]).stdout, new RegExp("HOLD " + held.lane + ".*CSO-OUTBOUND-deploy")); + assert.equal(invoke([file]).status, 0, "without --council a hold is not an error"); + assert.deepEqual(JSON.parse(invoke([file, "--json"]).stdout), validateLanePacketSet({ lanes: [held] })); + + write([lane("Same objective"), lane("same objective")]); + r = invoke([file, "--council"]); + assert.equal(r.status, 1); + assert.match(r.stdout, /REJECT lane-\d+.*REVIEWER-DUPLICATE/); + + const a = lane("one"), b = lane("two"); + b.work.branch = a.work.branch; + write([a, b]); + r = invoke([file, "--council"]); + assert.equal(r.status, 1); + assert.match(r.stderr, /same branch/); + + assert.equal(invoke([file, "--council", "--council"]).status, 1); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + + +// PR review round 1 (#339): everyday dev wording must not hold, and the destructive and +// outbound wording the first lists missed must. +const MUST_PASS = [ + "fix the delete endpoint", "add a delete button", "delete key handler", "send a request to the server", + "send email validation", "publish the type definitions", "publish eslint config", "pay attention to errors", + "ship the feature flag code", "merge the two arrays in the util", "push the button in the UI", "submit button", + "dm", "게시판 글 목록", "전송 속도 개선", "결제: 모듈 정리", "make sure not to deploy", "dont merge", + "cant publish yet", "merge is not allowed", "deploy is out of scope", +]; +const MUST_HOLD = { + destroy: ["destroy the environment", "destroyed caches", "clear the npm cache"], + send: ["sent the summary", "emailed summary"], + pr: ["gh pr create --fill"], + merge: ["gh pr merge 12", "land the changes", "do not forget to merge the PR"], + release: ["gh release create v2"], + deploy: ["upload to prod", "release it to production"], +}; + +test("everyday dev wording does not hold", () => { + for (const phrase of MUST_PASS) assert.deepEqual(outboundMentions(phrase), [], phrase); +}); + +test("destructive and outbound wording the first lists missed holds", () => { + for (const [category, phrases] of Object.entries(MUST_HOLD)) + for (const phrase of phrases) + assert.ok(outboundMentions(phrase).some((m) => m.category === category), category + ": " + phrase + " -> " + JSON.stringify(outboundMentions(phrase))); +}); + +test("authority.outbound refuses inherited names and repeated entries", () => { + for (const outbound of [["toString"], ["constructor"], ["deploy", "deploy"]]) { + const p = lane("x"); p.authority = { outbound }; + const r = validateLanePacket(p); + assert.equal(r.ok, false, JSON.stringify(outbound)); + assert.doesNotMatch(r.errors.join(" "), /native code/); + } +}); + +test("councilLane refuses a packet without a lane id with a contract error", () => { + assert.throws(() => councilLane({ work: { objective: "x" } }), /validated packet/); +}); + +test("the code-object guard does not swallow real objects", () => { + for (const phrase of ["send the error report to the vendor", "send the results to the client", "send the list to the team", "publish the results", "delete the form submissions"]) + assert.ok(outboundMentions(phrase).length > 0, phrase); +}); + +test("deploy and destroy stay actions when their object is code", () => { + for (const [phrase, category] of [["deploy the script to staging", "deploy"], ["deploy the hooks to production", "deploy"], ["delete the helpers directory", "destroy"], ["destroy the components", "destroy"]]) + assert.ok(outboundMentions(phrase).some((m) => m.category === category), phrase); +}); diff --git a/structure/INDEX.md b/structure/INDEX.md index 02f5f391..be92fa8b 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -377,3 +377,9 @@ The lane packet validator distinguishes pre-creation `dispatch`, observed creati with unresolved canonical ID (`pending`), and canonical `bound` addresses. Pending records retain creation metadata but cannot supply a tool address or authorize a second creation. The validator is a local contract check, not a host ID resolver. + +With `--council` (LANE-COUNCIL-01, `scripts/lane-council.mjs`, after Quorum) the same +script gives each `dispatch` lane of a valid set deterministic CEO, Reviewer, QA and CSO +stances: duplicate objectives reject, near duplicates and undeclared outbound actions +(`authority.merge/push/openPr`, `authority.outbound`) hold. It is an advisory preflight, +the same tier as the validator.