@attalabs/vinaya 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/aeg-root/enforcement.md +2 -2
- package/aeg-root/roles/planner.md +2 -2
- package/aeg-root/roles/reviewer.md +1 -1
- package/aeg-root/skills/brief-authoring/SKILL.md +1 -1
- package/aeg-root/state-machine.md +6 -6
- package/aeg-root/tranche-model.md +1 -1
- package/dist/checks/bin/check-branch-topology.js +21 -5
- package/dist/checks/bin/check-brief-shape.js +21 -5
- package/dist/checks/bin/check-closes-n.js +21 -5
- package/dist/checks/bin/check-coherence.js +200 -10
- package/dist/checks/bin/check-dead-branch-push.js +21 -5
- package/dist/checks/bin/check-dispatch-readiness.js +223 -11
- package/dist/checks/bin/check-doc-coverage-push.js +21 -5
- package/dist/checks/bin/check-doc-coverage.js +55 -10
- package/dist/checks/bin/check-first-push-dispatch.js +224 -12
- package/dist/checks/bin/check-issue-assignment.js +21 -5
- package/dist/checks/bin/check-no-disk-state.js +21 -5
- package/dist/checks/bin/check-reader-resolvable-prose.js +21 -5
- package/dist/checks/bin/check-registry-gates.js +21 -5
- package/dist/checks/bin/check-review-gate.js +21 -5
- package/dist/checks/bin/check-single-plan-pr.js +21 -5
- package/dist/checks/bin/check-test-plan.js +21 -5
- package/dist/index.js +6 -2
- package/package.json +4 -4
package/aeg-root/enforcement.md
CHANGED
|
@@ -77,7 +77,7 @@ The same check implementations run at ring 0 and ring 1 — one codebase, two en
|
|
|
77
77
|
| Starting the Dig (before authoring a brief) / starting Step 0 (before executing one) / **every push on a task branch before its PR exists** | Ever started work only to find out halfway it was blocked on something else? | hook | Refuses to start work until every precondition for the task is checked live and found clear. | **`verify-dispatch` CLI** (`packages/aeg-core/bin/verify-dispatch.ts` — mandated by `roles/developer.md` and `skills/brief-authoring/SKILL.md`; **now also hook-automated** — task 25) | Every dispatch precondition is re-checked live against the forge (the task's row/Issue are derived directly from a Milestone + `vinaya/tranche:<slug>`-labeled Issues as of the forge-native migration — no `origin/main` file read involved; leftover-branch detection still fetches `origin/main` for its own commit-count comparison): the task's row and Issue exist; the Issue passes the rationale gate; every `depends-on` PR is merged and no `conflicts-with` PR is open/in-flight; every named project's prior tranche is archived. (2026-07-13: the immediately-prior task's three-predicate archival bar — the row-adjacency predicate — was removed once the provenance-posting signal it existed to protect became automated.) A `NOT READY` result is a stop condition. Every `gh` call this script makes carries an explicit `-R <owner>/<repo>` (task 23) — from a linked `git worktree add` checkout, an untargeted `gh issue list`/`gh issue view`/`gh pr list` silently scopes to the wrong repo (or returns nothing), which previously produced false prior-tranche-archival blockers. **the managed `.git/hooks/pre-push` block** now runs this gate itself on a `task/*/*` branch's first push (before a PR exists), refusing on genuine `NOT READY` and failing OPEN — loudly — when a `gh auth status` reachability probe fails or `verify-dispatch`'s own repo/token resolution fails (task 18's fail-open precedent). The gate reads only the `dispatch-readiness:` predicate, not verify-dispatch's combined exit code, which also folds in `leftover-detection` (a pre-Step-0 "safe to branch fresh" advisory that would otherwise false-block every push after the first on a task legitimately mid-flight — discovered live while building this gate). Once a PR exists, later pushes skip the gate: dispatch was already validated once. | `packages/aeg-core/bin/verify-dispatch.ts` |
|
|
78
78
|
| Opening a task PR whose surface includes real code | Ever had a PR's description quietly stop matching what the code does? | event | Refuses a code-carrying pull request whose description no longer matches what it changes. | **Brief Validation — `checkPremiseCoverage`** | The PR body carries a `Premise:` block with at least one assertion (`contains`/`absent`/`sha256`) whose path falls inside the task's surface map — re-assertable at any point via `verify-dispatch --premise` or `verify-task`. | `packages/aeg-core/src/brief-validation.ts` |
|
|
79
79
|
| Opening a task PR (final self-check before creation) | Ever opened a PR and only then discovered the tests were failing? | event | Runs the whole exit check before a pull request is created, so failures surface first. | **`verify-task` CLI** (`packages/aeg-core/bin/verify-task.ts` — mandated by `roles/developer.md`; **now also hook-automated** — task 25) | Typecheck, lint, tests, build, `verify-docs --pr`, and the premise coverage/recheck pair all pass, as one summary, against the PR's actual diff, before `open-pr.ts` is invoked. **`open-pr.ts` now runs this composite itself** for task branches (via `gatePlanForBranch`), invoked wholesale rather than partially re-implemented — non-task branches are unaffected (byte-identical gate set). `verify-docs --pr` runs twice on a task-branch PR-open as an accepted, measured overlap (~4s with a warm turbo cache). | `packages/aeg-core/bin/verify-task.ts` |
|
|
80
|
-
| Spawning a check (`vinaya check`, ring-0 pre-push AND ring-1 CI) | Ever had a check quietly read a secret it had no business seeing, because nothing scoped what it could reach? | hook | Governs which environment variables a spawned check's child process can see, instead of every check inheriting the full parent environment unconditionally. | **Env allowlist** (`CheckSpec['env']`, `apps/cli/src/checks/contract.ts`; construction: `buildCheckEnv`, `apps/cli/src/checks/runner.ts`) | **⚠️ BREAKING, live (task 3):** a spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its own `env` declaration (`true` / `{ optional: true }` / `{ anyOf: [...] }` / a literal string) explicitly forwards — **no longer the full parent environment.** An undeclared variable a check's own code reads is now genuinely invisible to it, not merely warned about; a `true` or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` and the check never spawns at all. All 15 core checks in `registry.ts` carry an audited declaration, and every core check that spawns `gh` or reaches the forge through the token-resolution chain additionally forwards `GITHUB_TOKEN`/`GH_TOKEN` as `{ optional: true }` — on a CI runner those env vars are `gh`'s only authentication path, so a check that shells out without forwarding them runs unauthenticated there (hard-failing or silently degrading, depending on the bin's failure mode) while passing locally on `gh`'s keyring; the pairing is coupling-tested (`apps/cli/tests/checks/registry-env.test.ts` detects `gh` invocations in any call shape — array-form, string-form, and template-literal — and demands both declarations); that detection is source-text pattern matching, so it does not yet catch a check that reaches the forge indirectly through `createForgeSource` (`@
|
|
80
|
+
| Spawning a check (`vinaya check`, ring-0 pre-push AND ring-1 CI) | Ever had a check quietly read a secret it had no business seeing, because nothing scoped what it could reach? | hook | Governs which environment variables a spawned check's child process can see, instead of every check inheriting the full parent environment unconditionally. | **Env allowlist** (`CheckSpec['env']`, `apps/cli/src/checks/contract.ts`; construction: `buildCheckEnv`, `apps/cli/src/checks/runner.ts`) | **⚠️ BREAKING, live (task 3):** a spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its own `env` declaration (`true` / `{ optional: true }` / `{ anyOf: [...] }` / a literal string) explicitly forwards — **no longer the full parent environment.** An undeclared variable a check's own code reads is now genuinely invisible to it, not merely warned about; a `true` or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` and the check never spawns at all. All 15 core checks in `registry.ts` carry an audited declaration, and every core check that spawns `gh` or reaches the forge through the token-resolution chain additionally forwards `GITHUB_TOKEN`/`GH_TOKEN` as `{ optional: true }` — on a CI runner those env vars are `gh`'s only authentication path, so a check that shells out without forwarding them runs unauthenticated there (hard-failing or silently degrading, depending on the bin's failure mode) while passing locally on `gh`'s keyring; the pairing is coupling-tested (`apps/cli/tests/checks/registry-env.test.ts` detects `gh` invocations in any call shape — array-form, string-form, and template-literal — and demands both declarations); that detection is source-text pattern matching, so it does not yet catch a check that reaches the forge indirectly through `createForgeSource` (`@attalabs/vinaya-sources`) with no literal `gh` call of its own — found live: `closes-n` reaches the forge this way and had shipped without either token declared, passing locally under a developer's own `gh` keyring and failing unauthenticated on every CI runner, a known gap in the coupling test's own coverage rather than in the rule it's checking. This repo's own core checks are therefore unaffected — an adopter's **custom** check that reads `process.env` directly with no `env` declared will lose that access at this minor; declare `env` on it (`vinaya.config.json`'s `checks.<name>.env`) before upgrading. `vinaya check`'s prior warn-phase print (task 2) is retired now that the behavior it warned about is live; `vinaya doctor` still carries the same missing-declaration diagnostic permanently, at `info` severity, so a custom check's gap remains visible after upgrade even though `vinaya check` no longer prints it inline. A second field, `requiresOpenPr?: boolean` (same no-privileged-field discipline as `env` — a custom check can declare it exactly like a core one), marks a check that can only evaluate meaningfully once a pull request exists; the generated `pre-commit`/`pre-push` hooks pass a new `--local` flag that skips every check declaring it, while CI (`vinaya-checks.yml`, `pull_request`-triggered) omits the flag and always runs them for real. Found live: `closes-n`/`test-plan` ran unconditionally in both hooks with no `requiresOpenPr` field to opt out on, so the first commit on a fresh task branch could never satisfy either — no PR exists yet at commit or push time, only after. Found live again, same missing-declaration class: `brief-shape` read `BRANCH` from `process.env` in its own bin without declaring it in `registry.ts`'s allowlist, so the runner stripped the variable before the child ever spawned — the non-task-branch bypass its logic depended on (skipping the `Closes #N` requirement for a standalone brief with no Issue to close) silently never fired; `registry.ts` now declares `BRANCH: { optional: true }` on that entry alongside `PR_BODY`, mirroring `test-plan`'s existing declaration for the same pair. `vinaya.config.json`'s `principals` field (the review-gate/waiver trust anchor, `apps/cli/src/lib/config.ts`'s `resolvePrincipalAllowlist`) is resolved via `loadTrustAnchorConfig()` — a `gh api` read of the repository's DEFAULT BRANCH, never local git, the PR's working tree, or any env var. Three successive attempts got this wrong before landing (all caught pre-merge): reading the PR's working tree; reading `git show ${BASE_SHA}:…` where `BASE_SHA` was an env var; and reading `git show origin/main:…`, where `origin/main` is a LOCAL remote-tracking ref the PR's own workflow can `git update-ref`. The rule they establish: **inside a `pull_request`-triggered workflow nothing on the job's own disk or environment is a trust boundary against the PR author**, because the workflow definition itself comes from the PR. `check-doc-coverage.ts`/`check-doc-coverage-push.ts` keep their own, separate, legitimately-overridable `BASE_SHA` for diff-scoping only, never reused for a trust decision. The API read raises the bar but is not itself the boundary — and the boundary is not where the earlier version of this sentence put it. **Branch protection with the check marked required is still worth enabling** — `init` prints that command and `doctor` reports its absence — but state exactly what it buys: a required status check is satisfied by a conclusion reported under its name, and it does not certify that the conclusion came from running the real check. Deleting the gate's *step* does not stop the *job* reporting green; only removing the job or the workflow outright produces the never-reports case that leaves a PR unmergeable, and a step edited to `exit 0` reports success under the required name having run nothing at all. So the rule stated in bold above extends one step further, to the verdict itself: **the trust boundary is who controls the workflow definition that produces the required check, and under a `pull_request` trigger that is the PR author — whatever the check is packaged as.** Packaging decides the blast radius, not the boundary. Where CI invokes an immutable published artifact, the invocation is the only thing the PR can rewrite. Where a repo vendors the CLI — its own workspaces declaring the published package name, which needs a build-and-run CI shape because `npx` matches on package **name** before any version spec is read and would otherwise exec an unbuilt local bin — the PR additionally controls the check sources, the build script, and the **dependency lifecycle scripts** the install step executes in a base-branch-scoped cache: the surface becomes all code the PR controls. The same widening arrives with no packaging question at all whenever a required workflow checks out the PR's head and runs a gate from that tree — this harness's own review gate does exactly that, so it sits in the wider class today by a hand-written workflow, not by any generator. Two things are therefore NOT claimed here. Not that verdict tampering is closed for an ordinary adopter: it is not, and an adopter who enables branch protection and stops there has closed merge-without-a-report and nothing else. And not that the vendored CI shape is something generated today — as of 2026-08-14 `init` writes only the published `npx` invocation, which in a vendoring repo misresolves to the unbuilt local bin and kills the job, so such a repo's required check currently yields no verdict at all rather than an untrustworthy one; that is its own governance problem, and the build-and-run generator that fixes it is not yet merged. What closes the residual gap is not a mechanism: a repo whose required checks are built from code its own pull requests can edit is governing itself, and there the last line is the reviewer — changes to check sources, the build script, or the dependency manifest are reviewed as governance changes, not as ordinary code. | `apps/cli/src/checks/runner.ts` |
|
|
81
81
|
| `vinaya check` / `vinaya check --plan` (a CLI mechanism, not a git hook — this row names no hook path) | Ever had a config entry silently double-run alongside the core check it was meant to replace, with no way to see that from the outside? | event | Resolves core-registered and config-registered checks into one deterministic table before anything runs, instead of letting a config entry run alongside the core check it collides with, unannounced. | **Checks-side resolver** (`resolveChecks`/`isValidNamespacedKey`, `apps/cli/src/checks/resolver.ts`) | An exact-key match against a core check ID is an **override** — a complete replacement of the spec, never a merge. A key containing exactly one `/`, both segments matching `[a-z0-9][a-z0-9-]*` and the `vinaya` prefix rejected as an exact segment match, is **additive**. Anything else — a bare key with no `/` matching no core ID — is a loud `FAIL_CLOSED` refusal: never silently dropped, never defaulted, never last-wins. `--plan` never resolves or prints an env *value*, only how each one resolves. **This release feeds `vinaya check --plan` / `--plan --json` only** — `vinaya check`'s real execution (`check.ts`'s flat `[...coreCheckRegistry(), ...customSpecs]` concat) is unaffected until a later task wires it in. | `apps/cli/src/checks/resolver.ts` |
|
|
82
82
|
| Pushing a task branch that matches no planned task | Ever pushed a task branch that belonged to no plan? | hook | Refuses to push a task branch whose name matches no task row derived from the forge. | **`check-branch-topology` standalone shim** (in this repo the managed pre-push hook runs the same gate as the registered `branch-topology` check — a thin adapter in `apps/cli/src/checks/bin/` over the same pure evaluator; this row names the seeded standalone CLI form) | The branch's `task/<tranche>/<n>` fields resolve against the forge-derived tranche (a Milestone plus labeled task Issues): the tranche derives, and some task row's id literal-equals `<n>`. Fail-closed — forge/network unreachability refuses the push, matching `verify-dispatch`'s precedent for hard gates, unlike the fail-open dead-branch guard below. Known prose staleness, accepted: its refusal text still names a per-tranche topology file under `aeg-root/tranches/` that post-cutover repos no longer carry — the gate's logic is forge-native and correct, but the message text is a byte-identical-compatibility contract shared with the registered adapter and asserted verbatim by tests, so rewording it is a real change to that contract, not a message tweak. | `packages/aeg-core/bin/check-branch-topology.ts` |
|
|
83
83
|
| Pushing to a branch whose pull request already resolved | Ever pushed more commits to an already-merged branch? | hook | Refuses a push to a task branch whose most recent pull request is already merged or closed. | **`check-push-target` standalone shim** (the registered `dead-branch-push` check is a thin adapter over the same `checkDeadBranchPush` evaluator; this row names the seeded standalone CLI form) | The branch's most recent pull request — one batched `gh pr list --head <branch>` call — is still `OPEN`, or none exists yet. Deliberately fail-open: any forge-reachability failure (auth, network, rate limit, malformed JSON) maps to `UNKNOWN`, treated as allow — a transient outage must never block every push. The ring-2 dead-branch-push audit below is this gate's detection backstop for writers the hook cannot reach. | `packages/aeg-core/bin/check-push-target.ts` |
|
|
@@ -105,7 +105,7 @@ Every pull request, on open and on every push, re-runs the same checks in CI:
|
|
|
105
105
|
| Brief validation | Ever seen a PR title that told you nothing about what changed? | ci | Re-checks in CI that a pull request’s title and brief sections are properly formed. | Title convention (all branches) and every required brief section — on a task branch **or any PR whose body is a brief** (`isBriefShaped`); a PR carrying no brief stays exempt, and `Closes #N` is required only on task branches. Also runnable at authoring time: `verify-brief.ts --body-file <brief.md>`. | `packages/aeg-core/bin/verify-brief.ts` |
|
|
106
106
|
| Closes linkage | Ever had a PR merge and it wasn't clear which ticket it actually closed? | ci | Re-checks that a pull request names the Issue it closes. | A task pull request names, and will close, exactly its planned Issue | `packages/aeg-core/bin/verify-coherence.ts` |
|
|
107
107
|
| Single-plan-PR guard | Ever had two people plan the same work at the same time, unknowingly? | ci | Re-checks that no two open pull requests are planning the same work at once. | The identical ring-0 predicate (`checkSinglePlanPr`) re-run forge-side against this PR's own touched files (via `gh pr view`) and every other open PR's touched files — closes the gap where a web-UI-opened plan PR bypasses `open-pr.ts` entirely. No-ops for an ordinary task-branch PR (never touches a topology file), and dormant altogether where a plan is a Milestone plus Issues rather than a file. | `packages/aeg-core/bin/verify-single-plan-pr.ts` |
|
|
108
|
-
| Coherence check | Ever found a task marked "done" that was never actually merged? | ci | Re-checks every task’s recorded state against what actually merged. | Plan↔forge consistency across the whole repository: tasks closed without a merge, archived without their audit record, orphaned Issues (T2), phantom references, unplanned placeholders in active tranches (T3), duplicate decision numbers, broken document manifests, open task Issues missing a planner-rationale field (R1 — the same grammar the ring-0 Issue gate above enforces at creation). **L5 (open-Milestone-all-closed) surfaces a tranche whose Milestone stayed open after every one of its task Issues closed — advisory (info-only), the forge-native analogue of file-based L1 for post-cutover tranches that no longer carry a topology file (drift class 2; one live incident — the forge-state tranche's Milestone left open after full archive).** **T2 and T3 are CI-scoped to the pull request's own tranche** (parsed from the branch name) — a coherence gap in an unrelated tranche's topology never blocks this pull request (task 19, closing the incident where an unrelated tranche's gap failed CI). **T2 additionally blocks CI only for a plan PR** (task 24 item 4, superseding half of task 19's placement) — the point-of-power principle: a task PR can neither cause nor cure a topology gap, so T2's findings are demoted to non-blocking for every other PR kind, `--json`/audit mode, and `daily-drift` alike (a live incident proved the old placement wrong). The full repo-wide picture (every tranche, unscoped) remains visible in `--json`/audit mode. **The job's repo-state inputs (topology files, tranche list) read from a freshly-fetched `origin/main`, not the checkout's `refs/pull/N/merge`** (task 24 item 5) — GitHub materializes that ref lazily and it can lag behind `main` (5+ false-red cycles, 2026-07-03/04); a plan PR's own topology diff still reads from its head ref. **`id`/`issue` per task now derive from the forge (`@
|
|
108
|
+
| Coherence check | Ever found a task marked "done" that was never actually merged? | ci | Re-checks every task’s recorded state against what actually merged. | Plan↔forge consistency across the whole repository: tasks closed without a merge — A1 also recognizes a task Issue hand-closed directly by a recognized Principal (`stateReason: 'completed'`) as a second, narrower "done" path alongside a merged PR, for the documented exception where a dependency's technical premise dissolved and no PR was ever going to land — archived without their audit record, orphaned Issues (T2), phantom references, unplanned placeholders in active tranches (T3), duplicate decision numbers, broken document manifests, open task Issues missing a planner-rationale field (R1 — the same grammar the ring-0 Issue gate above enforces at creation). **L5 (open-Milestone-all-closed) surfaces a tranche whose Milestone stayed open after every one of its task Issues closed — advisory (info-only), the forge-native analogue of file-based L1 for post-cutover tranches that no longer carry a topology file (drift class 2; one live incident — the forge-state tranche's Milestone left open after full archive).** **T2 and T3 are CI-scoped to the pull request's own tranche** (parsed from the branch name) — a coherence gap in an unrelated tranche's topology never blocks this pull request (task 19, closing the incident where an unrelated tranche's gap failed CI). **T2 additionally blocks CI only for a plan PR** (task 24 item 4, superseding half of task 19's placement) — the point-of-power principle: a task PR can neither cause nor cure a topology gap, so T2's findings are demoted to non-blocking for every other PR kind, `--json`/audit mode, and `daily-drift` alike (a live incident proved the old placement wrong). The full repo-wide picture (every tranche, unscoped) remains visible in `--json`/audit mode. **The job's repo-state inputs (topology files, tranche list) read from a freshly-fetched `origin/main`, not the checkout's `refs/pull/N/merge`** (task 24 item 5) — GitHub materializes that ref lazily and it can lag behind `main` (5+ false-red cycles, 2026-07-03/04); a plan PR's own topology diff still reads from its head ref. **`id`/`issue` per task now derive from the forge (`@attalabs/aeg-forge-state`, task 3b) instead of the topology file, falling back to the file if forge derivation fails; `dependsOn`/`conflictsWith` and `#TBD` rows stay file-sourced and are merged onto the forge-derived task list, but only when a topology file still exists.** As of task 7, no active tranche still carries a topology file — every one was deleted once its forge-native replacement was proven live, so `dependsOn`/`conflictsWith` is fully forge-derived for all of them, with no file fallback anywhere. | `packages/aeg-core/bin/verify-coherence.ts` |
|
|
109
109
|
| Documentation gate | Ever shipped a change and forgot to update the doc explaining it? | ci | Re-checks that a change carrying real code also updates the docs explaining it. | Tier declaration and tier-appropriate documentation, including code→document ownership | `packages/aeg-core/bin/verify-docs.ts` |
|
|
110
110
|
| Test-plan state | Ever merged a PR with an unchecked "did you test this" box? | ci | Re-checks that the pull request’s test plan is genuinely ticked. | Unticked test-plan boxes block merge readiness | `packages/aeg-core/bin/verify-test-plan.ts` |
|
|
111
111
|
| Typecheck + unit tests | Ever had a change silently break something it wasn't even touching? | ci | Re-runs the type checker and the unit tests for every package this change can reach. | Packages affected by this PR's diff, plus their full transitive-dependent set (`turbo --affected`; application builds are verified by the deployment pipeline). A diff touching no package (docs/workflow-only) runs 0 packages — sound, since repo-file gates like `verify-docs`/coherence run in their own jobs regardless (task 27) | `.github/workflows/ci.yml` |
|
|
@@ -41,7 +41,7 @@ You turn an intent and a slice of work into a whole tranche — not one task, an
|
|
|
41
41
|
|
|
42
42
|
**One altitude above the Brief Author.** The Brief Author turns one planned task into one brief; the Planner turns an intent plus a slice of tickets into a whole **tranche** — a GitHub Milestone plus a set of labeled forge Issues.
|
|
43
43
|
|
|
44
|
-
**Forge-native by default — no topology file, no plan PR, no commit.** Create a Milestone titled `<slug>` (its description is the tranche goal), then cut task Issues labeled `vinaya/tranche:<slug>` with the full Planner's rationale (see "The Planner's rationale" below) in each body. `@
|
|
44
|
+
**Forge-native by default — no topology file, no plan PR, no commit.** Create a Milestone titled `<slug>` (its description is the tranche goal), then cut task Issues labeled `vinaya/tranche:<slug>` with the full Planner's rationale (see "The Planner's rationale" below) in each body. `@attalabs/aeg-forge-state` derives topology, dependencies, and lifecycle purely from those forge objects — nothing to write to `main`, nothing for `verify-coherence` to fall back to a file for. This cutover is now complete for every active tranche. Do not create a new topology file for a new tranche; if you find yourself about to write one, stop — the forge-native path below is the whole job.
|
|
45
45
|
|
|
46
46
|
Read this with `tranche-model.md` (the model) and `coordination.md` (session start). The Planner exists because the relationships *between* tasks — dependencies, conflicts, split-vs-combine — are invisible to a brief written in isolation. Seeing them is the whole job.
|
|
47
47
|
|
|
@@ -149,7 +149,7 @@ Decide by **verification-coupling** (not by project boundaries):
|
|
|
149
149
|
- The reason is the Reviewer: the `Project(s)` list is what tells the Reviewer whose behavior to verify. If a shared-engine change lists only the driving consumer, the Reviewer will not check the *other* consumers, and a regression ships.
|
|
150
150
|
- In the rationale, state explicitly: which shared package changes, which consumers are therefore in the blast radius, and whether each consumer is expected to need **re-verification only** (the change is additive — new code paths that existing consumers don't hit) or **actual edits** (the change alters a shared contract the consumer depends on). Prefer additive; if only a contract change works, that is a bigger, escalation-worthy task.
|
|
151
151
|
|
|
152
|
-
**Enumerate consumers by command, not by memory.** Before finalizing a task's **Project(s) + blast radius**, run the consumer enumeration for every shared package the task's Boundary or Project(s) names, and paste its output into your planning pass — in this repo: `git grep -l '@
|
|
152
|
+
**Enumerate consumers by command, not by memory.** Before finalizing a task's **Project(s) + blast radius**, run the consumer enumeration for every shared package the task's Boundary or Project(s) names, and paste its output into your planning pass — in this repo: `git grep -l '@attalabs/<pkg>' -- '*/package.json'` (substitute your repo's package scope and manifest layout). The pasted list is the floor for the consumer set. A consumer list written from memory is a self-attestation, and the forgotten consumer is exactly the shipped regression this rule exists to stop. (One hit is the package's own manifest — its `name` field matches; every other hit is a consumer.)
|
|
153
153
|
|
|
154
154
|
**Mechanized.** This rule is no longer prose you have to remember. `checkBlastRadiusScope` reads the collision-domain list in `.aeg/packages` and refuses a task Issue whose **Boundary** or **Project(s) + blast radius** names a path under a domain none of its declared projects owns (ownership resolves against `.vinaya/projects.md` — a task on `Project: aeg-core` editing `packages/aeg-core` owns its surface and passes). Satisfy it by listing the consumers, which is what this rule asks for anyway; or, when one review lens genuinely suffices, by an explicit `blast-radius-ack: <why>` line, which makes the judgment reviewable instead of silent.
|
|
155
155
|
|
|
@@ -75,7 +75,7 @@ This is why the review is a separate pass and not something the Developer does t
|
|
|
75
75
|
4. **Honest tests.** Do the tests prove real behavior, or do they mock the thing under test? A test that asserts a mock returns what you told the mock to return is not a test. Flag it.
|
|
76
76
|
5. **Spot-check code quality** on 2-3 of the most substantive files: clarity, obvious bugs, error handling, dead code, accidental debug/log leftovers, traces of skipped verification hooks.
|
|
77
77
|
6. **Doc coupling.** Tier 1+ work should carry spec/skill updates. If code changed contracts but no docs moved, flag it. (`verify-docs` also gates this in CI — your job is the judgment CI cannot make: are the docs *correct*, not just *present*.) For every doc named in the brief's documentation-update list: if it is absent from the diff, that is a **BLOCKER** (the list is a definition-of-done obligation, not guidance); if it is present but incorrect, that is also a BLOCKER. Check that compliance before reviewing logic. **Coverage of the `.vinaya/doc-owners` bindings is mechanical (`verify-docs` C5).** You no longer carry the "did the right doc move?" cognitive load — CI does. Your job shrinks to **judging correctness of the covered doc**: did the update actually reflect the code change, or is it a no-op edit / a misleading rewrite that silences C5 without reflecting reality? A passing C5 plus an incorrect doc update is a **BLOCKER**. A doc-coverage waiver is no longer a mechanism you weigh: the waiver body-grammar was removed, so a `doc-owners` obligation is deferred only when a principal applies the actor-verified `vinaya/waiver:docs` label — a Developer cannot self-serve it, and there is no body field for you to judge. What is still yours, because no CI gate can check it: whether a published doc reads complete to a stranger who lands on it cold. Hold every doc the brief surfaces to the reader-readability rule — a reader must resolve every symbol on the page from the page itself. A doc update that satisfies C5 mechanically but leaves a sentence leaning on a decision id or bare section number a stranger can't resolve is a MAJOR finding.
|
|
78
|
-
7. **Multi-project reach.** If the PR's brief lists more than one `Project:`, review through each project's lens — the change's blast radius spans all of them. Confirm a shared-package change (e.g. a shared `core`/`engine` package) doesn't silently break a consumer the brief didn't mention. **Before asserting blast-radius coverage is complete** — required whenever the brief lists more than one `Project:`, or the diff touches a path under a shared collision domain (`.aeg/packages`) even on a single-project brief; the collision-domain half is dormant when `.aeg/packages` is absent, as in `contracts/planner-brief.md` — run the consumer check for each touched shared package and quote its output in your review comment: `git grep -l '@
|
|
78
|
+
7. **Multi-project reach.** If the PR's brief lists more than one `Project:`, review through each project's lens — the change's blast radius spans all of them. Confirm a shared-package change (e.g. a shared `core`/`engine` package) doesn't silently break a consumer the brief didn't mention. **Before asserting blast-radius coverage is complete** — required whenever the brief lists more than one `Project:`, or the diff touches a path under a shared collision domain (`.aeg/packages`) even on a single-project brief; the collision-domain half is dormant when `.aeg/packages` is absent, as in `contracts/planner-brief.md` — run the consumer check for each touched shared package and quote its output in your review comment: `git grep -l '@attalabs/<pkg>' -- 'package.json' '*/package.json'` (this repo's form — substitute the touched package's published name; the two pathspecs are both needed, since `'*/package.json'` alone skips a repo-root manifest; use `git grep`, not `rg`, which silently skips gitignored doc trees). A consumer list you never generated is a consumer list you guessed. This applies to these two verdict fields only — the other checks keep their existing shape; evidence-on-everything is the "flag everything, get ignored" failure in another costume.
|
|
79
79
|
8. **Register and slop, in any reader-facing prose the diff adds or edits.** Two other defects in this same family — an unresolvable citation, and a coined term used without a definition — are checked mechanically now, not by you. This one still isn't, and it is a checkable property, not a stylistic preference: does a sentence narrate the work episode instead of stating the durable fact ("this fixes the bug from the last review," "I checked every case," a first-person aside) where a stranger reading the page later has no session to place it in? Does a padding adjective ("robust," "seamless," "comprehensive") carry no concrete referent a reader could verify? Flag the sentence and say what's wrong with it, the same way you'd flag a bug — this is not the taste-based-rewrite exclusion below; it fires only on a nameable defect (narration, unearned padding), never on a phrasing you'd merely have chosen differently.
|
|
80
80
|
|
|
81
81
|
## What you do NOT do
|
|
@@ -49,7 +49,7 @@ The Brief Author's stages — name them, and say which you're in:
|
|
|
49
49
|
|
|
50
50
|
**Precondition checks — three checks, in order (check (c), prior-task coherence, is superseded — see below):**
|
|
51
51
|
|
|
52
|
-
- **(a) Row-existence.** Confirm via the forge (`tranche:<slug>`-labeled Issue titled `[<slug>] <n> — …`, and its Milestone) — not `aeg-root/tranches/<name>.md` — that this task's row exists **at all** (`findMilestoneForSlug` + `listTasksForSlug`, `@
|
|
52
|
+
- **(a) Row-existence.** Confirm via the forge (`tranche:<slug>`-labeled Issue titled `[<slug>] <n> — …`, and its Milestone) — not `aeg-root/tranches/<name>.md` — that this task's row exists **at all** (`findMilestoneForSlug` + `listTasksForSlug`, `@attalabs/aeg-forge-state`). This is distinct from and prior to check (b)'s `#TBD`/blank check: a missing row means the plan/Issue for this task has not merged/opened yet, and there is nothing to inspect — no Issue, no dependencies, no `Project(s)` value. If the row is absent: **STOP — do NOT author the brief:** *"Task <id> is not present in tranche `<name>`'s forge-derived task list (no `tranche:<name>`-labeled Issue with this task id yet) — the plan/Issue for this task hasn't merged/opened. Not authorable until it does."*
|
|
53
53
|
|
|
54
54
|
- **(b) Issue-existence.** Confirm via the forge (`tranche:<slug>`-labeled Issue titled `[<slug>] <n> — …`, and its Milestone) — not `aeg-root/tranches/<name>.md` — that the Issue carries a real GitHub Issue number, not `#TBD`, not blank. If none exists, the task has no forge Issue. **STOP — do NOT author the brief:** *"Task <id> in tranche `<name>` has no Issue (#TBD) — it is backlog, not dispatchable. The Planner must cut the Issue first."* First task ever in a brand-new tranche may lack Issues if the Planner has not finished cutting them; stop and surface the gap regardless. A brief cannot carry a `Closes #N` reference if there is no N.
|
|
55
55
|
|
|
@@ -314,8 +314,8 @@ The lowest-commitment way to run AEG: read-only over a team's existing process.
|
|
|
314
314
|
- **Planner→Brief rationale completeness (R1)** — the same coherence oracle's **R1** check re-runs `checkIssueRationale` **and `checkProjectsRegistered`** (`packages/aeg-core`) against every open task Issue's body, batched per active tranche alongside T2. A non-grandfathered Issue missing any of the eight `contracts/planner-brief.md` rationale fields — or whose `Project:` field names a project with no row in the project registry — fails CI. Paired with the ring-0 creation gate (`bin/open-issue.ts`, same tool-layer-forge-gates row above) — R1 is the continuous half, the hook is the point-of-creation half; one grammar, two enforcement points (`aeg-root/enforcement.md`). Pre- Issues are grandfathered by explicit Issue number (`R1_GRANDFATHERED_ISSUES`), reported `info`, never blocking — see Section 15b. **Real.** Moves this seam's rationale-completeness half from Trusted (below) to Enforced; the "role doc matches contract prose" half of contract conformance remains Trusted.
|
|
315
315
|
- **Review gate — code-review + security-review verdicts** — `packages/aeg-core/bin/verify-review-gate.ts`, a step of the AEG gate suite job, blocks merge on a task-branch PR unless a clean code-reviewer `APPROVE` verdict comment AND a clean security-review `PASS` verdict comment both exist — `REQUEST CHANGES`, `FAIL`, a missing verdict, or an unclear one all fail CI. Reuses `extractCodeReviewVerdict`/`extractSecurityReviewVerdict` (`verdict-extraction.ts`) — the exact detection the post-merge Archivist's provenance assembly already ran, previously advisory-only (a DANGLING note on the merged PR, never a block). A principal can waive it for one PR with an actor-verified `vinaya/waiver:review` label (the same `isWaiverLabelActorVerified` pattern, parameterized by label — see the waiver-label-actor row above); label presence alone is never sufficient. **Real, installed at `.github/workflows/forge-lifecycle.yml::aeg-gate-suite`.** Closes the gap where task PRs could previously merge with no review pass at all. **What remains Trusted, not Enforced: *dispatching* the code-reviewer/security-reviewer subagents in the first place** — see below; this gate only verifies a dispatched review's verdict is clean, it cannot make a review happen.
|
|
316
316
|
- **Registry load-bearing checks G3/G4/G5** — `packages/aeg-core/bin/verify-registry.ts`, a step of the AEG gate suite job, blocks merge on: G3 (a file making the exact class of GitHub-mutating call `check-forge-gates.sh` gates — PR/Issue create, PR/Issue body/title edit, `gh api` create/edit, raw curl/wget writes — with no guarding Ring-0 row); G4 (a `#NNN` cited anywhere in `aeg-root/enforcement.md`'s body that does not resolve to a real Issue or PR in the forge); G5 (a contract's `producer`/`consumer` that is not a real `role_id`, or a role with an empty `performs`/`refuses_when`). **G1 (implementation-exists) and G2 (no-orphan-hook/CLI) are report-only this tranche** — they run in the same step and print `info` findings but never affect the exit code, since flipping them to blocking immediately would retroactively fail in-flight work against a pre-existing orphan backlog; G1 flips to blocking in a later, separately-dispatched task. Pure evaluators in `packages/aeg-core/src/registry-checks.ts`, parsing `packages/aeg-core/src/registry-parse.ts` — same shape (pure predicate + thin I/O shim) as `coherence-checks.ts`/`verify-coherence.ts`, deliberately a separate mechanism (different registry: `enforcement.md`'s own ring tables, not the tranche/forge state coherence-checks.ts reads). `registry-parse.ts`'s `GateRow` additionally carries `summary`/`category` and `description`/`spec` — purely descriptive fields consumed by the Vinaya `/how-it-works` page, not read by any G-check; G1–G5's pass/fail behavior is unaffected. `description` is the row's plain-language sentence and is resolved by header name, not column index; `spec` is the enforcement column it must never be confused with (the "what must be true"/"Re-verifies"/"Catches" slot before `implementation`).
|
|
317
|
-
- **Canonical action set** — `packages/aeg-core/src/actions.ts` exports `ACTIONS`, the 10-entry list of every AEG action that either crosses into GitHub (`crosses: 'into-github'`) or hands work across a role-seam contract (`crosses: 'none'`) — 6 crossings plus 4 seam-only actions, with no duplicate id for the two seams already accomplished by a crossing. Pure data (zero I/O, same shape as `waiver-label.ts`); `commit-the-work` is `'none'` because `git commit` is local-only (only `git push`/`publish-the-branch` reaches the forge). G3's crossing-file detection uses the `crosses: 'into-github'` subset; the `DiagramModel` derivation (`deriveDiagramModel`, `packages/aeg-core/src/diagram-model.ts`) now exists and places each action on a ring-0 gate (via `guards` edges) or a role/contract edge (via `performs`/`produces`/`consumes`), consuming this same `ACTIONS` list plus its `CROSSING_KEYWORDS` map (promoted into `actions.ts` alongside `ACTIONS` this task) — so G3's completeness set and the diagram's edge count cannot drift apart. The derivation is pure and takes doctrine through a `DoctrineSource` seam (`packages/aeg-core/src/doctrine-source.ts`, I/O-free; file-backed adapter `createFileDoctrineSource` in `@
|
|
318
|
-
- **Code-owned state machine + label vocabulary** — the same one-list-of-pure-data discipline, applied to status derivation. `packages/aeg-core/src/state-machine-model.ts` exports three parts: `FORGE_FACT_INPUTS` (every `ForgeFacts` field and the GitHub object it is read from), `DERIVED_STATUSES` (the 9-value set; `backlog` stays a member but derivation never emits it inside a tranche), and `DERIVATION_RULES` — the ordered rule list, first match wins, each entry carrying its predicate, its prose, and the reason its position matters. `deriveStatus` (`derive-tranche.ts`) executes that list, so the rules a reader sees rendered and the rules that actually run are the same objects. The list is total (its final rule matches unconditionally), and `state-machine-model.test.ts` pins the order rule-for-rule, proves every derivable status reachable, proves no rule is shadowed by an earlier one, and asserts equivalence with the pre-refactor `if`-chain across all 432 combinations of the facts derivation reads. The label vocabulary lives in `packages/aeg-forge-state/src/labels.ts` (`LABELS` + `AEG_BLOCKED_LABEL`) — in `aeg-forge-state`, not `aeg-core`, because the dependency direction is `aeg-core → aeg-forge-state → aeg-types` and the vocabulary's first consumer, `map-forge-facts.ts`, lives there and cannot import backward. Each label records the single orthogonal fact it carries; there is deliberately no `status:*` label, since status is derived and never written. `state-machine-model.ts` does **not** import that vocabulary: derivation works on `ForgeFacts`, never on label strings, and importing `@
|
|
317
|
+
- **Canonical action set** — `packages/aeg-core/src/actions.ts` exports `ACTIONS`, the 10-entry list of every AEG action that either crosses into GitHub (`crosses: 'into-github'`) or hands work across a role-seam contract (`crosses: 'none'`) — 6 crossings plus 4 seam-only actions, with no duplicate id for the two seams already accomplished by a crossing. Pure data (zero I/O, same shape as `waiver-label.ts`); `commit-the-work` is `'none'` because `git commit` is local-only (only `git push`/`publish-the-branch` reaches the forge). G3's crossing-file detection uses the `crosses: 'into-github'` subset; the `DiagramModel` derivation (`deriveDiagramModel`, `packages/aeg-core/src/diagram-model.ts`) now exists and places each action on a ring-0 gate (via `guards` edges) or a role/contract edge (via `performs`/`produces`/`consumes`), consuming this same `ACTIONS` list plus its `CROSSING_KEYWORDS` map (promoted into `actions.ts` alongside `ACTIONS` this task) — so G3's completeness set and the diagram's edge count cannot drift apart. The derivation is pure and takes doctrine through a `DoctrineSource` seam (`packages/aeg-core/src/doctrine-source.ts`, I/O-free; file-backed adapter `createFileDoctrineSource` in `@attalabs/vinaya-sources`), never `aeg-root/` paths directly. `actions.test.ts` cross-checks every `into-github` action against a real Ring-0 gate row in `enforcement.md` and every `performedBy` against a real `role_id`. Each entry also carries a `summary` question and a **required** `description` — required, so a new action cannot be added without one; `deriveDiagramModel` threads both onto the action's node, along with its `crosses`, which a client renderer cannot read off `ACTIONS` itself (importing it drags `node:child_process` into the browser bundle).
|
|
318
|
+
- **Code-owned state machine + label vocabulary** — the same one-list-of-pure-data discipline, applied to status derivation. `packages/aeg-core/src/state-machine-model.ts` exports three parts: `FORGE_FACT_INPUTS` (every `ForgeFacts` field and the GitHub object it is read from), `DERIVED_STATUSES` (the 9-value set; `backlog` stays a member but derivation never emits it inside a tranche), and `DERIVATION_RULES` — the ordered rule list, first match wins, each entry carrying its predicate, its prose, and the reason its position matters. `deriveStatus` (`derive-tranche.ts`) executes that list, so the rules a reader sees rendered and the rules that actually run are the same objects. The list is total (its final rule matches unconditionally), and `state-machine-model.test.ts` pins the order rule-for-rule, proves every derivable status reachable, proves no rule is shadowed by an earlier one, and asserts equivalence with the pre-refactor `if`-chain across all 432 combinations of the facts derivation reads. The label vocabulary lives in `packages/aeg-forge-state/src/labels.ts` (`LABELS` + `AEG_BLOCKED_LABEL`) — in `aeg-forge-state`, not `aeg-core`, because the dependency direction is `aeg-core → aeg-forge-state → aeg-types` and the vocabulary's first consumer, `map-forge-facts.ts`, lives there and cannot import backward. Each label records the single orthogonal fact it carries; there is deliberately no `status:*` label, since status is derived and never written. `state-machine-model.ts` does **not** import that vocabulary: derivation works on `ForgeFacts`, never on label strings, and importing `@attalabs/aeg-forge-state` would drag its `node:child_process` into any browser bundle rendering the model — the same hazard the `ACTIONS` note above records.
|
|
319
319
|
|
|
320
320
|
### Trusted (agent discipline — no CI enforcement in V0)
|
|
321
321
|
|
|
@@ -513,14 +513,14 @@ Sibling to `verify-docs.ts`, both in `packages/aeg-core/bin/`. Runnable as CLI (
|
|
|
513
513
|
|
|
514
514
|
**Three inputs:**
|
|
515
515
|
1. **Forge facts** — GitHub Issue state + PR merge events via `fetch-forge-facts.ts` (`@octokit/graphql` + `timelineItems(CLOSED_EVENT)`). Same adapter the Studio uses; not forked.
|
|
516
|
-
2. **Tranche topology** — `loadTrancheFiles` (`verify-coherence.ts`). For every tranche file NOT touched by the current PR's own diff (the repo-state side of every comparison — a plan PR's own topology diff still reads from its head ref, per the T2 point-of-power relocation note below), `id`/`issue` per task are derived from the forge (`@
|
|
516
|
+
2. **Tranche topology** — `loadTrancheFiles` (`verify-coherence.ts`). For every tranche file NOT touched by the current PR's own diff (the repo-state side of every comparison — a plan PR's own topology diff still reads from its head ref, per the T2 point-of-power relocation note below), `id`/`issue` per task are derived from the forge (`@attalabs/aeg-forge-state`'s `deriveTrancheFromForge` — a Milestone + `vinaya/tranche:<slug>`-labeled Issues), falling back to `aeg-root/tranches/*.md` via `parseTranche` (`@attalabs/aeg-core`) if forge derivation fails. `dependsOn`/`conflictsWith` and `#TBD` rows (no Issue cut yet — structurally invisible to forge derivation) still come from the topology table **when that table exists** and are merged onto the forge-derived task list. **No active tranche carries a topology file at all anymore** — every remaining topology file was backfilled onto the forge and deleted — so `dependsOn`/`conflictsWith` is now genuinely forge-native for every active tranche; the file-merge code path is kept but permanently dormant. **The general (unscoped) sweep additionally enumerates every open AND closed Milestone** (`listActiveTrancheSlugs`/`listArchivedTrancheSlugs`) not already found via directory listing — with the directory now permanently empty of active files, this closes the gap that would otherwise make the sweep blind to every tranche, silently. A task Issue whose `vinaya/tranche:<slug>` label was never removed after being dropped/consolidated out of the topology table reappears via forge derivation even though its row is gone — the fix is removing the stale label at the source, not filtering in code. (`readFromHead`'s guard inside `loadTrancheFiles` was rewritten from `prContext !== null && prContext.touchedFiles.has(relPath)` to `prContext?.touchedFiles.has(relPath) ?? false` for `noOptionalChain` lint hygiene — same pattern as the internal-organization note above: behavior is unchanged, both forms are `boolean`-typed and identical on every input, so this binding is satisfied by this note rather than a behavioral rewrite.)
|
|
517
517
|
3. **Decision logs** — consulted via N/M checks (delegated to T2).
|
|
518
518
|
|
|
519
519
|
**Check catalog:**
|
|
520
520
|
|
|
521
521
|
| Check | Fail class | What it asserts |
|
|
522
522
|
|---|---|---|
|
|
523
|
-
| A1 | `closed-without-merge` | Every closed task-Issue has a merged closing PR, excluding `stateReason: 'not_planned'` closes (`dropped` — a valid terminal state, not a failure) |
|
|
523
|
+
| A1 | `closed-without-merge` | Every closed task-Issue has a merged closing PR, excluding `stateReason: 'not_planned'` closes (`dropped` — a valid terminal state, not a failure) and excluding an Issue hand-closed directly by a recognized Principal with `stateReason: 'completed'` — a second, narrower "done" path for a dependency whose technical premise dissolved |
|
|
524
524
|
| A2 | `archived-without-provenance` | That closing PR carries an Archivist `### AEG provenance` comment |
|
|
525
525
|
| A3 | `auto-close-misfire` | Every Issue whose closing PR merged is itself closed (**the headline check** this oracle was built to catch) |
|
|
526
526
|
| T1 | `phantom-issue-ref` | Every topology row's Issue ref resolves to a real Issue |
|
|
@@ -618,7 +618,7 @@ bun packages/aeg-core/bin/verify-dispatch.ts <tranche> <n> --check-baseline <fil
|
|
|
618
618
|
|
|
619
619
|
**Default mode composes three checks:**
|
|
620
620
|
|
|
621
|
-
1. **`checkDispatchReadiness` (`src/dispatch-gate.ts`)** — one `{ ready, blockers }` verdict from: Issue-existence + phantom-reference detection; the Planner-rationale gate (`checkIssueRationale`, reused, not re-implemented); every `depends-on` edge's PR-merged state; every `conflicts-with` edge's open/in-flight state; and, per project named in the task's `Project(s)`, whether a prior active (non-`completed/`) tranche for that project is fully closed but unarchived. (2026-07-13: the immediately-prior task's three-predicate row-adjacency archival bar — Issue closed, PR merged, provenance block present — was removed as a blocking predicate; automated post-merge provenance posting made the signal it protected moot. `DispatchGateInput.priorTask` still exists as a dormant field for caller compatibility, but the gate no longer reads it.) As of the forge-native cutover, the task's own row/Issue is derived live from the forge (`@
|
|
621
|
+
1. **`checkDispatchReadiness` (`src/dispatch-gate.ts`)** — one `{ ready, blockers }` verdict from: Issue-existence + phantom-reference detection; the Planner-rationale gate (`checkIssueRationale`, reused, not re-implemented); every `depends-on` edge's PR-merged state — OR a dependency Issue hand-closed directly by a recognized Principal (`PRINCIPAL_ALLOWLIST`/an adopter's configured `principals`), with `stateReason: 'completed'` — a second, narrower "done" path for the documented exception where a dependency's technical premise dissolved and no PR was ever going to land; every `conflicts-with` edge's open/in-flight state; and, per project named in the task's `Project(s)`, whether a prior active (non-`completed/`) tranche for that project is fully closed but unarchived. (2026-07-13: the immediately-prior task's three-predicate row-adjacency archival bar — Issue closed, PR merged, provenance block present — was removed as a blocking predicate; automated post-merge provenance posting made the signal it protected moot. `DispatchGateInput.priorTask` still exists as a dormant field for caller compatibility, but the gate no longer reads it.) As of the forge-native cutover, the task's own row/Issue is derived live from the forge (`@attalabs/aeg-forge-state`'s `deriveTrancheFromForge` — a Milestone + `vinaya/tranche:<slug>`-labeled Issues), not read from `aeg-root/tranches/<slug>.md` on a freshly-fetched `origin/main` — there is no separate "fetched" copy of the forge to go stale.
|
|
622
622
|
2. **`classifyLeftover` (`src/leftover-detection.ts`)** — `clean | resume | stop` from the task branch's remote existence, local worktree existence, and commits already ahead of `origin/main` (this is the one remaining check in this seam that genuinely needs a freshly-fetched `origin/main` — `verify-dispatch` still runs `git fetch origin main --quiet` for it). `stop` blocks dispatch — Step 0 never creates a commit, so any commit ahead of main is real prior work that re-running Step 0 would orphan.
|
|
623
623
|
3. **`captureBaseline`/`compareToBaseline` (`src/baseline-capture.ts`)** — the current `verify-docs --full` and `verify-coherence` finding counts, printed as an **informational** capture, never a blocking "must be green" bar (live-fire: a brief once asserted "verify-docs full mode must be green" as a pre-flight condition without ever running it — full mode carried 44 pre-existing, unrelated findings and had never been green). The standing contract this seam establishes is **"no worse than the captured baseline,"** re-checkable later via `--check-baseline <file>`.
|
|
624
624
|
|
|
@@ -632,7 +632,7 @@ Exit 0 iff `checkDispatchReadiness` reports `ready: true` AND the leftover verdi
|
|
|
632
632
|
|
|
633
633
|
### `verify-task.ts` — the Developer's exit composite
|
|
634
634
|
|
|
635
|
-
`packages/aeg-core/bin/verify-task.ts` wraps the Developer's own pre-PR verification list (`roles/developer.md` § Verification before reporting done) into one command and one summary: typecheck, lint, tests, build (all scoped to `@
|
|
635
|
+
`packages/aeg-core/bin/verify-task.ts` wraps the Developer's own pre-PR verification list (`roles/developer.md` § Verification before reporting done) into one command and one summary: typecheck, lint, tests, build (all scoped to `@attalabs/aeg-core` — a full monorepo application build is the deployment pipeline's job, per `enforcement.md` Ring 1), `verify-docs --pr`, and — against the PR's actual diff — both `checkPremiseCoverage` (a real code surface with zero premise coverage fails) and a premise re-check. No check here is a second implementation; every step shells out to the same command CI runs, or calls the same pure `@attalabs/aeg-core` evaluator directly.
|
|
636
636
|
|
|
637
637
|
### Forcing mechanisms
|
|
638
638
|
|
|
@@ -239,7 +239,7 @@ Every role that runs in a tranche reports its **token spend and cost**; the per-
|
|
|
239
239
|
|
|
240
240
|
**Historically**, one sibling file per tranche at `aeg-root/tranches/<name>.tokens.md`, next to the topology file. Both went with the forge-native cutover; the four that remain sit in `completed/` and are read, never written.
|
|
241
241
|
|
|
242
|
-
The sibling form was chosen over an inline `## Token ledger` section for a reason worth keeping: two roles appending rows to one file at the same time is exactly the merge-collision the topology file's "Planner-only at plan time" rule existed to avoid. A ledger in its own append-only file meant a Planner editing topology and a Developer reporting a turn-end never touched the same bytes. `@
|
|
242
|
+
The sibling form was chosen over an inline `## Token ledger` section for a reason worth keeping: two roles appending rows to one file at the same time is exactly the merge-collision the topology file's "Planner-only at plan time" rule existed to avoid. A ledger in its own append-only file meant a Planner editing topology and a Developer reporting a turn-end never touched the same bytes. `@attalabs/aeg-core`'s `parseLedger` still reads both forms, which is what keeps the archived ledgers legible.
|
|
243
243
|
|
|
244
244
|
### Format
|
|
245
245
|
|
|
@@ -1218,7 +1218,8 @@ function mapForgeFacts(raw) {
|
|
|
1218
1218
|
reviewDecision: mapReviewDecision(raw.pullRequest?.reviewDecision),
|
|
1219
1219
|
stateReason: mapStateReason(raw.issue.stateReason),
|
|
1220
1220
|
closedAt: raw.issue.closedAt ?? null,
|
|
1221
|
-
mergedAt: raw.pullRequest?.mergedAt ?? null
|
|
1221
|
+
mergedAt: raw.pullRequest?.mergedAt ?? null,
|
|
1222
|
+
closedByActor: raw.closedByActor ?? null
|
|
1222
1223
|
};
|
|
1223
1224
|
}
|
|
1224
1225
|
function mapStateReason(reason) {
|
|
@@ -1311,6 +1312,7 @@ function buildBatchQuery(tranche, tasks) {
|
|
|
1311
1312
|
timelineItems(last: 1, itemTypes: [CLOSED_EVENT]) {
|
|
1312
1313
|
nodes {
|
|
1313
1314
|
... on ClosedEvent {
|
|
1315
|
+
actor { login }
|
|
1314
1316
|
closer {
|
|
1315
1317
|
... on PullRequest {
|
|
1316
1318
|
number
|
|
@@ -1348,7 +1350,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1348
1350
|
const issue = repository[`${alias}_issue`];
|
|
1349
1351
|
const ref = repository[`${alias}_ref`];
|
|
1350
1352
|
const prs = repository[`${alias}_prs`];
|
|
1351
|
-
const
|
|
1353
|
+
const timelineNode = issue?.timelineItems?.nodes?.[0];
|
|
1354
|
+
const rawCloser = timelineNode?.closer ?? null;
|
|
1352
1355
|
const closingPr = rawCloser && typeof rawCloser.number === "number" && typeof rawCloser.url === "string" ? rawCloser : null;
|
|
1353
1356
|
const branchPr = prs && prs.nodes.length > 0 && prs.nodes[0] ? prs.nodes[0] : null;
|
|
1354
1357
|
return {
|
|
@@ -1360,7 +1363,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1360
1363
|
labels: issue.labels.nodes.map((n) => n.name)
|
|
1361
1364
|
} : null,
|
|
1362
1365
|
refExists: Boolean(ref && ref.name.length > 0),
|
|
1363
|
-
pullRequest: closingPr ?? branchPr
|
|
1366
|
+
pullRequest: closingPr ?? branchPr,
|
|
1367
|
+
closedByActor: timelineNode?.actor?.login ?? null
|
|
1364
1368
|
};
|
|
1365
1369
|
}
|
|
1366
1370
|
function describeError(err) {
|
|
@@ -1498,6 +1502,11 @@ var FORGE_FACT_INPUTS = [
|
|
|
1498
1502
|
fact: "mergedAt",
|
|
1499
1503
|
readsFrom: "PullRequest.mergedAt",
|
|
1500
1504
|
meaning: "Timestamp for the coherence oracle grandfather cutoff. No derivation rule reads it."
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
fact: "closedByActor",
|
|
1508
|
+
readsFrom: "ClosedEvent.actor.login (most recent timeline CLOSED_EVENT)",
|
|
1509
|
+
meaning: "GitHub login that hand-closed the Issue. No task-status derivation rule reads it — it is consumed by dispatch-gate.ts/coherence-checks.ts A1 to recognize a hand-closed dependency as resolved (task vinaya-engine-v1 21, #99), not by this status model."
|
|
1501
1510
|
}
|
|
1502
1511
|
];
|
|
1503
1512
|
var DERIVED_STATUSES = [
|
|
@@ -1977,7 +1986,7 @@ function isGrandfathered(isoDate) {
|
|
|
1977
1986
|
return isoDate.slice(0, 10) < COHERENCE_ENFORCED_FROM;
|
|
1978
1987
|
}
|
|
1979
1988
|
var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
|
|
1980
|
-
function checkA1(entries) {
|
|
1989
|
+
function checkA1(entries, principalAllowlist = PRINCIPAL_ALLOWLIST) {
|
|
1981
1990
|
const failures = [];
|
|
1982
1991
|
for (const e of entries) {
|
|
1983
1992
|
if (!e.facts)
|
|
@@ -1985,6 +1994,9 @@ function checkA1(entries) {
|
|
|
1985
1994
|
if (e.facts.stateReason === "not_planned")
|
|
1986
1995
|
continue;
|
|
1987
1996
|
if (e.facts.issueState === "closed" && e.facts.prState !== "merged") {
|
|
1997
|
+
const handClosed = e.facts.stateReason === "completed" && isPrincipal(e.facts.closedByActor, principalAllowlist);
|
|
1998
|
+
if (handClosed)
|
|
1999
|
+
continue;
|
|
1988
2000
|
failures.push({
|
|
1989
2001
|
issue: e.task.issue,
|
|
1990
2002
|
tranche: e.trancheSlug,
|
|
@@ -2674,9 +2686,13 @@ function parseGlossaryTerms(glossaryContent) {
|
|
|
2674
2686
|
return terms;
|
|
2675
2687
|
}
|
|
2676
2688
|
// ../../packages/aeg-core/src/dispatch-gate.ts
|
|
2689
|
+
function isHandClosedByRecognizedPrincipal(dep, principalAllowlist) {
|
|
2690
|
+
return dep.issueState === "closed" && dep.stateReason === "completed" && isPrincipal(dep.closedByActor ?? null, principalAllowlist);
|
|
2691
|
+
}
|
|
2677
2692
|
function checkDispatchReadiness(input) {
|
|
2678
2693
|
const { trancheSlug, task } = input;
|
|
2679
2694
|
const taskLabel = `task ${task.id} (tranche ${trancheSlug})`;
|
|
2695
|
+
const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST;
|
|
2680
2696
|
const blockers = [];
|
|
2681
2697
|
if (task.issue === null) {
|
|
2682
2698
|
blockers.push(`dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`);
|
|
@@ -2687,7 +2703,7 @@ function checkDispatchReadiness(input) {
|
|
|
2687
2703
|
blockers.push(`dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`);
|
|
2688
2704
|
}
|
|
2689
2705
|
for (const dep of input.dependsOn) {
|
|
2690
|
-
if (!dep.merged) {
|
|
2706
|
+
if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
|
|
2691
2707
|
const issueStr = dep.issue !== null ? ` (#${dep.issue})` : "";
|
|
2692
2708
|
blockers.push(`dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`);
|
|
2693
2709
|
}
|
|
@@ -1215,7 +1215,8 @@ function mapForgeFacts(raw) {
|
|
|
1215
1215
|
reviewDecision: mapReviewDecision(raw.pullRequest?.reviewDecision),
|
|
1216
1216
|
stateReason: mapStateReason(raw.issue.stateReason),
|
|
1217
1217
|
closedAt: raw.issue.closedAt ?? null,
|
|
1218
|
-
mergedAt: raw.pullRequest?.mergedAt ?? null
|
|
1218
|
+
mergedAt: raw.pullRequest?.mergedAt ?? null,
|
|
1219
|
+
closedByActor: raw.closedByActor ?? null
|
|
1219
1220
|
};
|
|
1220
1221
|
}
|
|
1221
1222
|
function mapStateReason(reason) {
|
|
@@ -1308,6 +1309,7 @@ function buildBatchQuery(tranche, tasks) {
|
|
|
1308
1309
|
timelineItems(last: 1, itemTypes: [CLOSED_EVENT]) {
|
|
1309
1310
|
nodes {
|
|
1310
1311
|
... on ClosedEvent {
|
|
1312
|
+
actor { login }
|
|
1311
1313
|
closer {
|
|
1312
1314
|
... on PullRequest {
|
|
1313
1315
|
number
|
|
@@ -1345,7 +1347,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1345
1347
|
const issue = repository[`${alias}_issue`];
|
|
1346
1348
|
const ref = repository[`${alias}_ref`];
|
|
1347
1349
|
const prs = repository[`${alias}_prs`];
|
|
1348
|
-
const
|
|
1350
|
+
const timelineNode = issue?.timelineItems?.nodes?.[0];
|
|
1351
|
+
const rawCloser = timelineNode?.closer ?? null;
|
|
1349
1352
|
const closingPr = rawCloser && typeof rawCloser.number === "number" && typeof rawCloser.url === "string" ? rawCloser : null;
|
|
1350
1353
|
const branchPr = prs && prs.nodes.length > 0 && prs.nodes[0] ? prs.nodes[0] : null;
|
|
1351
1354
|
return {
|
|
@@ -1357,7 +1360,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1357
1360
|
labels: issue.labels.nodes.map((n) => n.name)
|
|
1358
1361
|
} : null,
|
|
1359
1362
|
refExists: Boolean(ref && ref.name.length > 0),
|
|
1360
|
-
pullRequest: closingPr ?? branchPr
|
|
1363
|
+
pullRequest: closingPr ?? branchPr,
|
|
1364
|
+
closedByActor: timelineNode?.actor?.login ?? null
|
|
1361
1365
|
};
|
|
1362
1366
|
}
|
|
1363
1367
|
function describeError(err) {
|
|
@@ -1495,6 +1499,11 @@ var FORGE_FACT_INPUTS = [
|
|
|
1495
1499
|
fact: "mergedAt",
|
|
1496
1500
|
readsFrom: "PullRequest.mergedAt",
|
|
1497
1501
|
meaning: "Timestamp for the coherence oracle grandfather cutoff. No derivation rule reads it."
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
fact: "closedByActor",
|
|
1505
|
+
readsFrom: "ClosedEvent.actor.login (most recent timeline CLOSED_EVENT)",
|
|
1506
|
+
meaning: "GitHub login that hand-closed the Issue. No task-status derivation rule reads it — it is consumed by dispatch-gate.ts/coherence-checks.ts A1 to recognize a hand-closed dependency as resolved (task vinaya-engine-v1 21, #99), not by this status model."
|
|
1498
1507
|
}
|
|
1499
1508
|
];
|
|
1500
1509
|
var DERIVED_STATUSES = [
|
|
@@ -1974,7 +1983,7 @@ function isGrandfathered(isoDate) {
|
|
|
1974
1983
|
return isoDate.slice(0, 10) < COHERENCE_ENFORCED_FROM;
|
|
1975
1984
|
}
|
|
1976
1985
|
var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
|
|
1977
|
-
function checkA1(entries) {
|
|
1986
|
+
function checkA1(entries, principalAllowlist = PRINCIPAL_ALLOWLIST) {
|
|
1978
1987
|
const failures = [];
|
|
1979
1988
|
for (const e of entries) {
|
|
1980
1989
|
if (!e.facts)
|
|
@@ -1982,6 +1991,9 @@ function checkA1(entries) {
|
|
|
1982
1991
|
if (e.facts.stateReason === "not_planned")
|
|
1983
1992
|
continue;
|
|
1984
1993
|
if (e.facts.issueState === "closed" && e.facts.prState !== "merged") {
|
|
1994
|
+
const handClosed = e.facts.stateReason === "completed" && isPrincipal(e.facts.closedByActor, principalAllowlist);
|
|
1995
|
+
if (handClosed)
|
|
1996
|
+
continue;
|
|
1985
1997
|
failures.push({
|
|
1986
1998
|
issue: e.task.issue,
|
|
1987
1999
|
tranche: e.trancheSlug,
|
|
@@ -2671,9 +2683,13 @@ function parseGlossaryTerms(glossaryContent) {
|
|
|
2671
2683
|
return terms;
|
|
2672
2684
|
}
|
|
2673
2685
|
// ../../packages/aeg-core/src/dispatch-gate.ts
|
|
2686
|
+
function isHandClosedByRecognizedPrincipal(dep, principalAllowlist) {
|
|
2687
|
+
return dep.issueState === "closed" && dep.stateReason === "completed" && isPrincipal(dep.closedByActor ?? null, principalAllowlist);
|
|
2688
|
+
}
|
|
2674
2689
|
function checkDispatchReadiness(input) {
|
|
2675
2690
|
const { trancheSlug, task } = input;
|
|
2676
2691
|
const taskLabel = `task ${task.id} (tranche ${trancheSlug})`;
|
|
2692
|
+
const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST;
|
|
2677
2693
|
const blockers = [];
|
|
2678
2694
|
if (task.issue === null) {
|
|
2679
2695
|
blockers.push(`dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`);
|
|
@@ -2684,7 +2700,7 @@ function checkDispatchReadiness(input) {
|
|
|
2684
2700
|
blockers.push(`dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`);
|
|
2685
2701
|
}
|
|
2686
2702
|
for (const dep of input.dependsOn) {
|
|
2687
|
-
if (!dep.merged) {
|
|
2703
|
+
if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
|
|
2688
2704
|
const issueStr = dep.issue !== null ? ` (#${dep.issue})` : "";
|
|
2689
2705
|
blockers.push(`dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`);
|
|
2690
2706
|
}
|
|
@@ -1218,7 +1218,8 @@ function mapForgeFacts(raw) {
|
|
|
1218
1218
|
reviewDecision: mapReviewDecision(raw.pullRequest?.reviewDecision),
|
|
1219
1219
|
stateReason: mapStateReason(raw.issue.stateReason),
|
|
1220
1220
|
closedAt: raw.issue.closedAt ?? null,
|
|
1221
|
-
mergedAt: raw.pullRequest?.mergedAt ?? null
|
|
1221
|
+
mergedAt: raw.pullRequest?.mergedAt ?? null,
|
|
1222
|
+
closedByActor: raw.closedByActor ?? null
|
|
1222
1223
|
};
|
|
1223
1224
|
}
|
|
1224
1225
|
function mapStateReason(reason) {
|
|
@@ -1311,6 +1312,7 @@ function buildBatchQuery(tranche, tasks) {
|
|
|
1311
1312
|
timelineItems(last: 1, itemTypes: [CLOSED_EVENT]) {
|
|
1312
1313
|
nodes {
|
|
1313
1314
|
... on ClosedEvent {
|
|
1315
|
+
actor { login }
|
|
1314
1316
|
closer {
|
|
1315
1317
|
... on PullRequest {
|
|
1316
1318
|
number
|
|
@@ -1348,7 +1350,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1348
1350
|
const issue = repository[`${alias}_issue`];
|
|
1349
1351
|
const ref = repository[`${alias}_ref`];
|
|
1350
1352
|
const prs = repository[`${alias}_prs`];
|
|
1351
|
-
const
|
|
1353
|
+
const timelineNode = issue?.timelineItems?.nodes?.[0];
|
|
1354
|
+
const rawCloser = timelineNode?.closer ?? null;
|
|
1352
1355
|
const closingPr = rawCloser && typeof rawCloser.number === "number" && typeof rawCloser.url === "string" ? rawCloser : null;
|
|
1353
1356
|
const branchPr = prs && prs.nodes.length > 0 && prs.nodes[0] ? prs.nodes[0] : null;
|
|
1354
1357
|
return {
|
|
@@ -1360,7 +1363,8 @@ function extractRawFromResponse(repository, alias) {
|
|
|
1360
1363
|
labels: issue.labels.nodes.map((n) => n.name)
|
|
1361
1364
|
} : null,
|
|
1362
1365
|
refExists: Boolean(ref && ref.name.length > 0),
|
|
1363
|
-
pullRequest: closingPr ?? branchPr
|
|
1366
|
+
pullRequest: closingPr ?? branchPr,
|
|
1367
|
+
closedByActor: timelineNode?.actor?.login ?? null
|
|
1364
1368
|
};
|
|
1365
1369
|
}
|
|
1366
1370
|
function describeError(err) {
|
|
@@ -1498,6 +1502,11 @@ var FORGE_FACT_INPUTS = [
|
|
|
1498
1502
|
fact: "mergedAt",
|
|
1499
1503
|
readsFrom: "PullRequest.mergedAt",
|
|
1500
1504
|
meaning: "Timestamp for the coherence oracle grandfather cutoff. No derivation rule reads it."
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
fact: "closedByActor",
|
|
1508
|
+
readsFrom: "ClosedEvent.actor.login (most recent timeline CLOSED_EVENT)",
|
|
1509
|
+
meaning: "GitHub login that hand-closed the Issue. No task-status derivation rule reads it — it is consumed by dispatch-gate.ts/coherence-checks.ts A1 to recognize a hand-closed dependency as resolved (task vinaya-engine-v1 21, #99), not by this status model."
|
|
1501
1510
|
}
|
|
1502
1511
|
];
|
|
1503
1512
|
var DERIVED_STATUSES = [
|
|
@@ -1977,7 +1986,7 @@ function isGrandfathered(isoDate) {
|
|
|
1977
1986
|
return isoDate.slice(0, 10) < COHERENCE_ENFORCED_FROM;
|
|
1978
1987
|
}
|
|
1979
1988
|
var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
|
|
1980
|
-
function checkA1(entries) {
|
|
1989
|
+
function checkA1(entries, principalAllowlist = PRINCIPAL_ALLOWLIST) {
|
|
1981
1990
|
const failures = [];
|
|
1982
1991
|
for (const e of entries) {
|
|
1983
1992
|
if (!e.facts)
|
|
@@ -1985,6 +1994,9 @@ function checkA1(entries) {
|
|
|
1985
1994
|
if (e.facts.stateReason === "not_planned")
|
|
1986
1995
|
continue;
|
|
1987
1996
|
if (e.facts.issueState === "closed" && e.facts.prState !== "merged") {
|
|
1997
|
+
const handClosed = e.facts.stateReason === "completed" && isPrincipal(e.facts.closedByActor, principalAllowlist);
|
|
1998
|
+
if (handClosed)
|
|
1999
|
+
continue;
|
|
1988
2000
|
failures.push({
|
|
1989
2001
|
issue: e.task.issue,
|
|
1990
2002
|
tranche: e.trancheSlug,
|
|
@@ -2674,9 +2686,13 @@ function parseGlossaryTerms(glossaryContent) {
|
|
|
2674
2686
|
return terms;
|
|
2675
2687
|
}
|
|
2676
2688
|
// ../../packages/aeg-core/src/dispatch-gate.ts
|
|
2689
|
+
function isHandClosedByRecognizedPrincipal(dep, principalAllowlist) {
|
|
2690
|
+
return dep.issueState === "closed" && dep.stateReason === "completed" && isPrincipal(dep.closedByActor ?? null, principalAllowlist);
|
|
2691
|
+
}
|
|
2677
2692
|
function checkDispatchReadiness(input) {
|
|
2678
2693
|
const { trancheSlug, task } = input;
|
|
2679
2694
|
const taskLabel = `task ${task.id} (tranche ${trancheSlug})`;
|
|
2695
|
+
const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST;
|
|
2680
2696
|
const blockers = [];
|
|
2681
2697
|
if (task.issue === null) {
|
|
2682
2698
|
blockers.push(`dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`);
|
|
@@ -2687,7 +2703,7 @@ function checkDispatchReadiness(input) {
|
|
|
2687
2703
|
blockers.push(`dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`);
|
|
2688
2704
|
}
|
|
2689
2705
|
for (const dep of input.dependsOn) {
|
|
2690
|
-
if (!dep.merged) {
|
|
2706
|
+
if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
|
|
2691
2707
|
const issueStr = dep.issue !== null ? ` (#${dep.issue})` : "";
|
|
2692
2708
|
blockers.push(`dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`);
|
|
2693
2709
|
}
|