@sabaiway/agent-workflow-kit 14.12.0 → 14.14.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 14.14.0 — the Recommendations screen lists each step of the reference setup you have not taken or declined, with its preview command or the reason it has none (AD-160)
8
+
9
+ **`/agent-workflow-kit recommendations` now shows what your project still lacks of the full flow.** Until 14.14.0
10
+ only the upgrade's `tier` step judged the reference profile: the Recommendations screen, run on its own or at the end
11
+ of an upgrade, read no profile, and a fresh bootstrap was never shown the offer. 14.14.0 puts each remaining difference
12
+ the kit can detect on that screen, and bootstrap now shows the screen too. Story S6 of the epic
13
+ `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`, its last; contract `docs/ai/specs/kit/tier/gap-screen/` (new).
14
+
15
+ - **One optional item per gap.** For every entry of the profile-gap registry that your project lacks, has not
16
+ declined at the current lineage and can close with a command, the screen shows a `profile-gap` item: the step's id
17
+ and the profile's own sentence for it, and as its apply the same preview line the upgrade's `tier` step prints. A present step, or one you declined, shows
18
+ no item. A step the screen cannot close with a command — the session-close rules in an older wording, which the
19
+ upgrade's `lens` step refreshes, or a rules region you customized — is a stated skip with its reason, so the
20
+ screen does not claim `flow optimal` while it stands.
21
+ - **Consent takes two steps.** The mode doc gains one posture note: the confirmed pick runs the preview and your
22
+ agent relays its lines; the same line with `--apply` runs only on a second yes. Items whose apply lines are equal
23
+ share one writer and one answer. A decline runs `tier-preview.mjs --decline` only when you refuse every item the tier
24
+ preview then reports offered, and a declined item stays off the screen until a lineage step changes the profile.
25
+ - **Bootstrap renders the screen.** Step 11 of bootstrap runs the advisor and presents its section before the report
26
+ footer, as the upgrade does, so a new project is offered the same steps through the same writers. The accelerators
27
+ block after the footer keeps its entries; the gates bullet now points at the screen's `gates-declaration` item.
28
+ - **The offer and the screen judge the same way.** `tools/profile-gaps.mjs` now exports the judgement the `tier`
29
+ step used privately, and both the offer and the new read-only module `tools/profile-gap-screen.mjs` call it, so the
30
+ two cannot disagree. Every line the `tier` step prints is unchanged. The `4.0.0` migration note now says an item
31
+ left `offered` stays on the Recommendations section that ends the upgrade, rather than waiting for the next one.
32
+
33
+
34
+ ## 14.13.0 — every session close tells you what was done and what comes next, and a project without a work queue is offered one (AD-159)
35
+
36
+ **Your agent now ends a session with two short blocks written for you, and the handover keeps them.** Until 14.13.0
37
+ the rules file did not ask for these two blocks, no memory template had a place for them, and a
38
+ project with no `docs/plans/queue.md` had to write one by hand. 14.13.0 adds the rule, the slot, a checker for the
39
+ slot, and a queue seed on the upgrade's existing one yes. Story S5 of the epic `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`;
40
+ contract `docs/ai/specs/kit/tier/session-rules/` (new).
41
+
42
+ - **Two rules in the rules template.** §2.5 Communication gains the bullet `Two blocks for the user`: at a plan's
43
+ approval, at a release and at every session close the message body carries *What was done, and for what* and
44
+ *What is next, and why*, in the dialogue language, with no internal ids and no review-round tallies. §2.7 Story
45
+ sessions gains one sentence: inside the tests and code sessions each task runs as its own carrier session. The
46
+ upgrade's `lens` step refreshes a region still equal to the text an earlier kit shipped, without an ask; a region
47
+ you edited is kept and flagged, as before.
48
+ - **A `For the user` slot.** The handover template gains a `## For the user` section with the two labels, and the
49
+ changelog template's entry a `**For the user:**` line after its Goal. A project bootstrapped from now on starts with
50
+ them.
51
+ - **`tools/session-close-check.mjs --check [--cwd <project>]`.** Read-only: it judges the `## For the user` section of
52
+ `docs/ai/handover.md`. Exit 0 prints one accept line; 1 prints every refusal found, one per line, each by its name; 2 is a usage error or a state it cannot judge,
53
+ named. It checks that both labels carry text, never what language the text is in or what it says. It is not
54
+ offered as a gate: a session close is not a commit.
55
+ - **The upgrade offers a work queue.** The `tier` step's registry gains two entries after `checker-gates-declared`.
56
+ `named-queue-row-seed` offers to seed a missing `docs/plans/queue.md`; on the same one yes,
57
+ `tools/tier-preview.mjs --apply` writes it, create-only, from the kit's `references/authoring/QUEUE_TEMPLATE.md`:
58
+ the named-row rule, the purge archive, priority order and the check line, and four empty buckets. An existing queue
59
+ is never touched, and `queue-audit-cli`'s `--require-names` stays opt-in. `session-close-rules` only reports whether
60
+ both rules are current in your rules file; it never offers a write.
61
+
7
62
  ## 14.12.0 — the upgrade offers the kit's own checkers as gates of your project, each only where it can run (AD-158)
8
63
 
9
64
  **The kit's own checkers can now be declared as gates of your project without a hand edit.** Until 14.12.0 no
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow". New to the epic, story and task tier? Run `/agent-workflow-kit tier`.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.12.0'
6
+ version: '14.14.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "14.12.0",
6
+ "version": "14.14.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
@@ -16,7 +16,8 @@ store `docs/ai/epics/`.
16
16
  "Before you upgrade" paragraph of the installed kit's `CHANGELOG.md` entry 14.2.0.
17
17
  2. **The offer:** it already ran as this upgrade's step-3 `tier` item. Re-run the preview
18
18
  `node <installed-kit>/tools/tier-preview.mjs --cwd <project>` to verify, and relay its lines;
19
- never ask again. An item still `offered` waits for the next upgrade.
19
+ never ask again here. An item still `offered` stays on the Recommendations section that ends this
20
+ upgrade, under that item's two-step consent.
20
21
  3. **Re-stamp:** the upgrade re-stamps `docs/ai/.workflow-version` to 4.0.0.
21
22
 
22
23
  ## Verification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "14.12.0",
3
+ "version": "14.14.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -0,0 +1,19 @@
1
+ # Work queue
2
+
3
+ > This file names work; it never holds the analysis of it. A row opens with one plain sentence
4
+ > saying what the work is for, then its ALL-CAPS id, then a short body.
5
+ > A row that goes terminal leaves the queue in the change that mints its closing artifact; where
6
+ > this file is gitignored, first snapshot the section to a purge archive with
7
+ > `queue-purge-cli.mjs snapshot`, because git history keeps no tombstone.
8
+ > Order inside a bucket is priority.
9
+ > Check: `node <kit>/tools/queue-audit-cli.mjs --check docs/plans/queue.md --require-names`
10
+
11
+ ## Queue
12
+
13
+ ### Now
14
+
15
+ ### Next
16
+
17
+ ### Later
18
+
19
+ ### Frozen
@@ -43,11 +43,11 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
43
43
  independent axes: a packaging-only release bumps the package but leaves the lineage head until a
44
44
  migration actually changes the deployed `docs/ai` structure. A stamp greater than the head →
45
45
  STOP (never downgrade).
46
- 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`). **After the footer, present ONE compact optional-accelerators block** — a one-line why each; every writer previews first, and nothing runs without a yes:
46
+ 11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then render the mandatory Recommendations section: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; present-even-when-empty and verdict-first; then OFFER the consent-gated applies per `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`; then print the **report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`). **After the footer, present ONE compact optional-accelerators block** — a one-line why each; every writer previews first, and nothing runs without a yes:
47
47
  - `/agent-workflow-kit tier` — read-only: explains the epic, story and task tier and names the next step of the walk;
48
48
  - `/agent-workflow-kit velocity` — routine read-only commands stop prompting (incl. the `--kit-tools` tier for the kit's own read-only tools; `${CLAUDE_SKILL_DIR}/references/modes/velocity.md`);
49
49
  - `/agent-workflow-kit agents` — cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage; `${CLAUDE_SKILL_DIR}/references/modes/agents.md`);
50
- - **gates seeding + hook** — offer to seed `docs/ai/gates.json` from the commands recon already recorded (step 1), via the consent-gated preview in `${CLAUDE_SKILL_DIR}/references/modes/gates.md` — this block is where that offer fires; then, once gates are declared, `/agent-workflow-kit hook` auto-approves exactly those declared commands (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`);
50
+ - **gates seeding + hook** — offer to seed `docs/ai/gates.json` from the commands recon already recorded (step 1), via the consent-gated preview in `${CLAUDE_SKILL_DIR}/references/modes/gates.md` — the Recommendations section's `gates-declaration` item renders that offer first; then, once gates are declared, `/agent-workflow-kit hook` auto-approves exactly those declared commands (`${CLAUDE_SKILL_DIR}/references/modes/hook.md`);
51
51
  - `/agent-workflow-kit set-recipe` — put a ready review backend to work on plans and diffs (`${CLAUDE_SKILL_DIR}/references/modes/set-recipe.md`).
52
52
  Then **ask before committing** — never auto-commit.
53
53
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md
6
6
 
7
- The **read-only deployment advisor** — the deterministic section every `upgrade` run ends with, also invocable on its own. It computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, sandbox unavailable, gates undeclared, a declared gate matrix that verifies nothing, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe, a configured `subagent` carrier whose executor vehicle cannot carry it) and renders **verdict-first**: one composed verdict line, then each item as **{severity · what is sub-optimal · the benefit in ONE plain line · an optional `recipe:` line (the `sandbox-lane` live recipe — egress hosts + resolved writable dirs — the `worktrees-dir` hand-apply-first grant advice, or the `agents` / `executor-vehicle` hidden-mode reconcile follow-up) · the exact consent-gated apply one-liner}**. The tool computes deterministic English DATA; **you PRESENT the section in the user's conversational language** — every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings stay **byte-exact**; show the raw tool block on request (the AD-032 report-contract lane — the tool cannot know the dialogue language, so the language rendering is your presentation layer).
7
+ The **read-only deployment advisor** — the deterministic section every `upgrade` run ends with, also invocable on its own. It computes what in THIS deployment is configured sub-optimally (allowlist not seeded, autonomy render drifted, sandbox unavailable, gates undeclared, a declared gate matrix that verifies nothing, bridge friction, sandbox-mask clutter, an unacknowledged sandbox recipe, a configured `subagent` carrier whose executor vehicle cannot carry it, a difference from the reference profile the project has not declined) and renders **verdict-first**: one composed verdict line, then each item as **{severity · what is sub-optimal · the benefit in ONE plain line · an optional `recipe:` line (the `sandbox-lane` live recipe — egress hosts + resolved writable dirs — the `worktrees-dir` hand-apply-first grant advice, or the `agents` / `executor-vehicle` hidden-mode reconcile follow-up) · the exact consent-gated apply one-liner}**. The tool computes deterministic English DATA; **you PRESENT the section in the user's conversational language** — every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings stay **byte-exact**; show the raw tool block on request (the AD-032 report-contract lane — the tool cannot know the dialogue language, so the language rendering is your presentation layer).
8
8
 
9
9
  **Live host/session facts are tool-composed only.** Every fact this section states about the current
10
10
  host or session — prompts fired, sandbox scope, whether a bypass was needed, network reachability,
@@ -37,6 +37,7 @@ Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root> [--
37
37
  - `mcp-channel` — the offer is to REGISTER the kit's read-only stdio MCP server in this project, which is a different kind of consent from the other velocity items: a registration is a command your MCP client will RUN, so the apply here is the mode's own **flagless preview**, which prints the exact entry and writes nothing — the `--apply` that follows is a SEPARATE step you run after reading that entry, and the consent flow never carries it. What the registration buys: path questions and literal searches become typed tool calls whose arguments are named JSON fields instead of a string handed to a shell, so a pipe, a redirect or a quote inside a pattern or a path stays DATA and is never interpreted — they are perfectly legal bytes to search for, there is simply no shell to read them as operators. Posture: the server is a **read-only child of your client** (path/type/size/line facts and literal search over this project root; no write and no exec API), and like the client itself it runs **outside the Bash sandbox** — the sandbox is not what bounds it, the server's own root containment is. The two allow rules it adds make those two tools promptless and nothing else. Two arms are **HAND-APPLY** and never run for you: **`.differing`** — an `agent-workflow` entry already stands in `.mcp.json` and **structurally differs** from what this kit copy would write (another kit copy, a hand-edited path, an added `env`; the comparison ignores key order, so re-serialized identical bytes are the same registration), and silently changing what an MCP server launches is exactly what consent must not slide past, so the remedy is your edit; and the **masked** arm, where an OS sandbox hides `.mcp.json` behind a device node — the kit cannot write there, so it hands you the text to paste from outside the sandbox. When the file is masked but the settings half is already complete, the item **does not render at all**: what is unobservable becomes a stated SKIP, so optimality is withheld rather than a registration you already made being offered again. Stated limit: this item does **not** detect a `disabledMcpjsonServers` veto, so a converged `mcp-channel` means *what the mode writes is in place*, not *the client will load it* — see `${CLAUDE_SKILL_DIR}/references/modes/mcp.md` for why that check was subtracted rather than half-built. Risk profile: a new read-only channel your client will launch; no write or exec exposure, and no existing declaration is touched.
38
38
  - ADDITIONAL `gates-inert` arms (the third outcomes) — two further arms, and they differ in whether anything is BROKEN. **`producer-unrecognized`** — a checker with no producer anywhere in the declaration, on a tracked tree the changed-line coverage domain cannot reach (`.ts`/`.tsx`/`.jsx`/`.mts`/`.cts` strictly outnumber `.mjs`/`.cjs`/`.js`): the dead pair is real, so this arm is **HAND-APPLY** and the two remedies are marking the real producer with `"lcovProducer": true` or dropping the checker. Never a `node --test` prescription over a project that has no such suite, never the fill preview, and never an acknowledgement — a dead pair is broken, not narrow, and removing a producer after an acknowledgement lands right back in this arm. **`coverage-domain-narrow`** — the producer/checker pair IS live and the tree is still dominated by what the domain excludes: nothing is broken, and the honest sentence is that certification covers the assessable minority. Its apply is the consent-gated **ack writer** preview (a NEUTRAL fingerprint into `docs/ai/acks.json` as `coverageDomainAck`, never a security key); after the SAME confirmation you run the `--apply` it prints. The fingerprint binds the FACT — the verdict plus the unsupported extensions present, never the file counts — so an acknowledged project stays quiet as it grows and re-fires when a new unsupported language arrives or the verdict flips. The census reads the TRACKED tree with a read-only `git ls-files`; a tree it cannot read (a non-git deployment) becomes a stated skip, so optimality is withheld rather than assumed. Risk profile: no enforcement change of any kind — one acknowledgement recorded in a family-owned file, and one hand edit that stays the maintainer's.
39
39
  - `spec-adoption` — the feature-spec layer's ADOPTION STATE, judged from `docs/ai/specs/` alone through the same census `spec-check --all` runs, so a store the checker would refuse to observe is never counted here either. Both arms are OFFERS (`optional`) — the layer is opt-in, and `attention` stays reserved for a configured declaration that is broken. **`not-adopted`** (no store): every plan then cites zero governing specs by default and nothing says adoption never started; its apply is the spec-layer ensure one-liner (`ensure-configs --reconcile --only specs`, the same seed `upgrade` runs — it writes the reader/checker pairs and the store root, create-only, and skips with a stated reason where no Node evidence exists), and its `recipe:` line carries a NAMED **HAND-APPLY alternative** — the DECLINE preview (`ack-write --lane spec-adoption`, a dry-run that prints the exact `--apply`), which records `specAdoptionAck` in the family-owned `docs/ai/acks.json`. **The two are exclusive, and the order is part of the contract:** ask which the maintainer wants BEFORE the confirmation; on "seed" run the apply; on "decline" run NOTHING — hand over the preview line and let them run it and its printed `--apply`; never run the seed after a decline was chosen, and never run the decline preview for them. **`adopting`** (a store with no live contract): an optional item naming the draft count; its apply is that same decline preview (a dry-run; after the SAME confirmation you run the `--apply` it prints), and the honest remedy is authoring a contract from the skill's `SPEC_TEMPLATE.md`. A recorded decline silences BOTH arms and shows on `status` as ` — declined`; one live contract makes the decline moot. An unreadable store is a stated skip, never `flow optimal`. Risk profile: the seed writes only create-only files under `scripts/` and `docs/ai/specs/`; the decline writes one key into the ack store; nothing here authors a spec or blocks a plan.
40
+ - `profile-gap` — each item's apply is a PREVIEW that writes nothing, and consent takes two steps: the confirmed pick runs the preview and you relay every line it prints (the environment-resolved slot values, each checker `entry:` line with its trust-chain disclosure, a Communication region the insert plans in the same run); the same line with ` --apply` appended runs only on a SECOND yes over those lines, never carried by the first. Items whose apply lines are byte-equal are one writer and take one answer: the tier preview's `--apply` writes the absent slots, the store seed and the queue seed together; the checker verb writes only its own entries; the insert writes every region it plans, all or nothing, so a `communication-absent` skip beside a declined `story-sessions-section` gets its own ask after the insert's full preview. A no records nothing and the item stays on the screen. A DECLINE is asked apart from later, before anything runs, and runs `node ${CLAUDE_SKILL_DIR}/tools/tier-preview.mjs --decline --cwd <project-root>` only when the user refuses EVERY item the tier preview then reports offered: it records each of them at the shipped lineage, and a declined item leaves the screen until a lineage step changes the profile. An undecidable entry is a stated skip naming its reason, the remedy by reason: a prior → the upgrade's `lens` reconcile; an absent region → the insert; a customized region → a hand edit only (the lens preserves it), so that skip stands and withholds `flow optimal`. Risk profile: the tier preview's `--apply` rewrites `docs/ai/orchestration.json` in canonical form with the `_README` note and creates `docs/ai/epics/.gitkeep` and `docs/plans/queue.md` create-only; the insert adds regions to `docs/ai/agent_rules.md`; the checker verb adds entries to the PRIVILEGED `docs/ai/gates.json`; the decline writes `docs/ai/profile-declines.json`; nothing here commits.
40
41
  - `adr-store-migration` — other items write project files too; what is unique here is that the crossing **overwrites and deletes files the project already has**: it replaces the deployed enforcement scripts in `scripts/` (the directional subset — only basenames the project already has; a locally-edited copy is snapshotted first, never silently clobbered) and, where a retired archive file exists, DELETES it once conservation has been proven. That is why it is **HAND-APPLY** and why the command shown in the apply slot is a **`--dry-run`** — it writes nothing and prints the whole plan. `--apply` is a SEPARATE step, run only after that plan has been shown and **fresh consent** obtained for it; the consent flow executes only the apply slot, so an item that needs consent AFTER its preview cannot use that lane at all. Every write is idempotent and the run is re-runnable to completion after any interruption, so a re-run repairs rather than double-applies. It never commits. Risk profile: overwrite + delete of existing project files, gated on a preview you have actually read.
41
42
 
42
43
  **Sandbox lanes (what to DO with the `sandbox-lane` recipe, per host class):**
@@ -2,6 +2,7 @@
2
2
 
3
3
  <!-- opt-in-capability: family-freshness -->
4
4
  <!-- opt-in-capability: spec-adoption -->
5
+ <!-- opt-in-capability: full-flow-profile -->
5
6
 
6
7
  Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKILL_DIR}/references/shared/composition-handoff.md · ${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md · ${CLAUDE_SKILL_DIR}/references/shared/command-shapes.md
7
8
 
@@ -73,6 +73,7 @@ Apply this as part of §2 before any user-facing summary:
73
73
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.
74
74
  - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
75
75
  - **A skip that contradicts the tree is a finding.** A tool-composed `skipped-*` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.
76
+ - **Two blocks for the user.** At a plan's approval, at a release and at every session close, the message BODY — above the closing state block — carries two short blocks in the dialogue language. *What was done, and for what*: per item the user-facing problem, then what now exists; a command name is allowed, an internal id is not. *What is next, and why*: each proposal names its benefit, plus the one hand step if any. No internal ids, no review-round tallies. The same two blocks are the handover's `## For the user` section, which the kit's `session-close-check` checker judges at the close, where the kit is installed.
76
77
 
77
78
  ### 2.6. Planning, review & process-fidelity invariants
78
79
  Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
@@ -93,7 +94,7 @@ Apply these when authoring a plan, reviewing, folding a finding, or editing code
93
94
  - **Cost lanes.** Route every step to the **cheapest adequate executor** — L0 deterministic script (the batched gate matrix over `gates.json`, the rotation `--check`s) · L1 cheap subagent (extraction/drafting only; the orchestrator verifies) · L2 subscription bridge · L3 frontier judgment. A step with **no named guardrail does not move down** a lane, and the **red lines never move down** (council review models · real code · ADR/handover/changelog-entry wording · persuasive copy · go/no-go · the approval asks). Own-error repair: salvage recorded state first (L0/L1, batched), never frontier re-derivation. **Prompt economy:** read-only fan-out (research/sweeps/extraction) runs ONLY on restricted-tool vehicles — a full-tool subagent for read-only work is a forbidden lane downgrade (invisible prompt-flood + blast radius), and a subagent is never told to shell out for facts obtainable read-only; the orchestrator's own shell form is ONE plain pipeline per call (a `;`/`&&` chain or env-prefixed invocation never matches a prefix allow rule); a fan-out launcher that gates per call yields to the agent-spawn lane — capability-gated: without restricted-tool vehicles (generic full-tool spawning does not count), read-only research stays in the orchestrator's own context, never a vehicle mandate a host cannot satisfy. Judgment, code, synthesis stay at the frontier lane (a task that genuinely runs/writes keeps a full-tool subagent); honest limit: no deterministic gate classifies a dispatch — canon at the point of use + placed vehicles + the retro loop. **Writer economy:** a stage's repeated WRITER commands batch — evidence declarations ride consecutive plain invocations of ONE allow-listed tool, other stage writers combine via one launcher per stage; never an unbatched writer scatter (each gated write is its own prompt).
94
95
 
95
96
  ### 2.7. Story sessions
96
- A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under `docs/ai/specs/`, drafted and reviewed on the `plan-authoring` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the `plan-execution` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup.
97
+ A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under `docs/ai/specs/`, drafted and reviewed on the `plan-authoring` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the `plan-execution` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup. Inside the tests and code sessions it is one task per session: each task runs as its own carrier session — one brief, one run, never one run for a whole wave — and the orchestrator's session briefs, checks, verifies and folds.
97
98
 
98
99
  ---
99
100
 
@@ -17,6 +17,7 @@ maxLines: 700
17
17
  ## {{DATE}} — Bootstrap
18
18
 
19
19
  **Goal:** Initialise the AI-agent memory system.
20
+ **For the user:** what was done, and for what — the project now has an AI-agent memory, so each session starts from the last one's record; what is next, and why — the first real task, whose entry says what it did for you.
20
21
  **Changes:**
21
22
  - Created `AGENTS.md` (entry point) + `CLAUDE.md` symlink.
22
23
  - Created `docs/ai/` with the spec files + `pages/`.
@@ -16,6 +16,14 @@ maxLines: 80
16
16
  **Branch:** {{BRANCH}}
17
17
  **Active recipes:** not recorded yet — paste the configured-recipe line (composed from `docs/ai/orchestration.json`) and refresh it whenever the config changes.
18
18
 
19
+ ## For the user
20
+
21
+ **What was done, and for what:**
22
+ - Bootstrap — the project now has an AI-agent memory, so each session starts from what the last one left.
23
+
24
+ **What is next, and why:**
25
+ - The first real task — its close replaces these two lines with what it did for you and what comes next.
26
+
19
27
  ## What was done last session
20
28
 
21
29
  - Bootstrap — AI-agent memory system initialised.
@@ -69,6 +69,7 @@ export const LIBRARY_ONLY_MODULES = Object.freeze({
69
69
  'reference-profile.mjs': '/agent-workflow-kit upgrade',
70
70
  // The read half of the checker-gates verb: the same step-3 `tier` item runs the verb that uses it.
71
71
  'checker-gates-read.mjs': '/agent-workflow-kit upgrade',
72
+ 'profile-gap-screen.mjs': '/agent-workflow-kit recommendations',
72
73
  });
73
74
 
74
75
  // The frozen refusal line. One line, names the module, names the command.
@@ -0,0 +1,42 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { refuseDirectRun } from './direct-run.mjs';
3
+ import { GAP_STATES, NO_COMMAND_SENTENCE, STORY_REGION_ABSENT, judgeProfileGaps } from './profile-gaps.mjs';
4
+ import { readDeclines, readShippedProfile } from './reference-profile.mjs';
5
+
6
+ const SESSION_RULES_ID = 'session-close-rules';
7
+ const NOT_IN_PROFILE = 'not in the shipped profile';
8
+
9
+ const refused = (reason) => ({ gaps: [], skips: [reason] });
10
+
11
+ const disposeOf = ({ entry, state, reason }, root, hasById) => {
12
+ if (state === GAP_STATES.present || state === GAP_STATES.declined) return {};
13
+ if (state === GAP_STATES.undecidable) {
14
+ if (entry.id === SESSION_RULES_ID && reason === STORY_REGION_ABSENT) return {};
15
+ return { skip: `${entry.id}: ${reason}` };
16
+ }
17
+ const has = hasById.get(entry.id);
18
+ if (has === undefined) return { skip: `${entry.id}: ${NOT_IN_PROFILE}` };
19
+ const apply = entry.apply(root);
20
+ if (!apply) return { skip: `${entry.id}: ${NO_COMMAND_SENTENCE}` };
21
+ return { gap: { id: entry.id, what: `${entry.id}: ${has}`, apply } };
22
+ };
23
+
24
+ export const composeProfileGapScreen = ({ root, deps = {} }) => {
25
+ const io = { readFileSync, lstatSync, ...deps };
26
+ const shipped = readShippedProfile(io);
27
+ if (shipped.refusal) return refused(shipped.refusal);
28
+ const record = readDeclines(root, io);
29
+ if (record.refusal) return refused(record.refusal);
30
+ const hasById = new Map(shipped.profile.items.map(({ id, has }) => [id, has]));
31
+ const judged = judgeProfileGaps({ root, deps: io, lineage: shipped.profile.lineage, declined: record.declined });
32
+ const gaps = [];
33
+ const skips = [];
34
+ for (const item of judged) {
35
+ const { gap, skip } = disposeOf(item, root, hasById);
36
+ if (gap) gaps.push(gap);
37
+ if (skip) skips.push(skip);
38
+ }
39
+ return { gaps, skips };
40
+ };
41
+
42
+ refuseDirectRun(import.meta.url);
@@ -1,8 +1,9 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { CANDIDATE_STATES, isDeployed, judgeCheckerGates } from './checker-gates-read.mjs';
4
+ import { normalizeLensBody, reconcileCommsText, reconcileStoryText } from './lens-region.mjs';
4
5
  import { CONFIG_REL, validateConfig } from './orchestration-config.mjs';
5
- import { TIER_TARGETS } from './reference-profile.mjs';
6
+ import { TIER_TARGETS, isDeclineCurrent } from './reference-profile.mjs';
6
7
  import {
7
8
  RULES_REGIONS,
8
9
  extractRegionBy,
@@ -18,6 +19,14 @@ const SLOTS_ID = 'epic-task-slots';
18
19
  const STORE_ID = 'epic-store-seeded';
19
20
  const STORE_PATH = 'docs/ai/epics';
20
21
  const CHECKER_GATES_ID = 'checker-gates-declared';
22
+ const SESSION_RULES_ID = 'session-close-rules';
23
+ const QUEUE_SEED_ID = 'named-queue-row-seed';
24
+ const PLANS_PATH = 'docs/plans';
25
+ const QUEUE_PATH = 'docs/plans/queue.md';
26
+ const QUEUE_SEED_PATH = fileURLToPath(new URL('../references/authoring/QUEUE_TEMPLATE.md', import.meta.url));
27
+ const ABSENT_ARM_CODES = new Set(['ENOENT', 'ENOTDIR']);
28
+ const CURRENT_STATUS = 'current';
29
+ const REFRESHED_STATUS = 'refreshed';
21
30
  const TIER_PREVIEW_PATH = fileURLToPath(new URL('./tier-preview.mjs', import.meta.url));
22
31
  const CHECKER_GATES_PATH = fileURLToPath(new URL('./checker-gates.mjs', import.meta.url));
23
32
  const UNDECIDED_STATES = [CANDIDATE_STATES.idTaken, CANDIDATE_STATES.probeUnreadable, CANDIDATE_STATES.withheld];
@@ -47,7 +56,20 @@ const REASONS = Object.freeze({
47
56
  storeNotDirectory: 'store-not-directory',
48
57
  storeSymlink: 'store-symlink',
49
58
  storeUnreadable: 'store-unreadable',
59
+ headingTwice: 'heading-twice',
60
+ regionPrior: 'region-prior',
61
+ regionCustomized: 'region-customized',
62
+ plansSymlink: 'plans-symlink',
63
+ plansNotDirectory: 'plans-not-directory',
64
+ plansUnreadable: 'plans-unreadable',
65
+ queueSymlink: 'queue-symlink',
66
+ queueNotRegular: 'queue-not-regular',
67
+ queueUnreadable: 'queue-unreadable',
68
+ seedUnreadable: 'seed-unreadable',
50
69
  });
70
+ export const STORY_REGION_ABSENT = 'story-sessions-absent';
71
+ const REGION_ABSENT = Object.freeze({ communication: 'communication-absent', 'story-sessions': STORY_REGION_ABSENT });
72
+ const JUDGEMENTS = Object.freeze({ communication: reconcileCommsText, 'story-sessions': reconcileStoryText });
51
73
  const TEMPLATE_REGIONS = RULES_REGIONS.filter(({ canon }) => canon === TEMPLATE_CANON);
52
74
  const STORY_REGION = RULES_REGIONS.find(({ id }) => id === STORY_ID);
53
75
 
@@ -162,6 +184,63 @@ const detectCheckerGates = ({ root, deps }) => {
162
184
  return undecided ? reportUndecidable(undecided.state) : { verdict: VERDICTS.present };
163
185
  };
164
186
 
187
+ // A region is judged by the reconcile's own pure judgement, so this entry and the lens step agree.
188
+ const judgeRegion = (text, spans, { id, headingRe, priors }) => {
189
+ if (!extractRegionBy(text, headingRe).found) return REGION_ABSENT[id];
190
+ const { status } = JUDGEMENTS[id](text, normalizeLensBody(spans[id].span), priors);
191
+ if (status === CURRENT_STATUS) return null;
192
+ return status === REFRESHED_STATUS ? REASONS.regionPrior : REASONS.regionCustomized;
193
+ };
194
+
195
+ const detectSessionRules = ({ root, deps }) => {
196
+ const template = readSpans(deps);
197
+ if (template.verdict) return template;
198
+ const target = readTarget(root, deps);
199
+ if (target.verdict) return target;
200
+ const { text } = target;
201
+ if (TEMPLATE_REGIONS.some(({ headingRe }) => extractRegionBy(text, headingRe).count > SINGLE_HEADING)) {
202
+ return reportUndecidable(REASONS.headingTwice);
203
+ }
204
+ for (const region of TEMPLATE_REGIONS) {
205
+ const reason = judgeRegion(text, template.spans, region);
206
+ if (reason) return reportUndecidable(reason);
207
+ }
208
+ return { verdict: VERDICTS.present };
209
+ };
210
+
211
+ export const readQueueSeed = (deps) => {
212
+ try {
213
+ return { bytes: deps.readFileSync(QUEUE_SEED_PATH) };
214
+ } catch {
215
+ return { reason: REASONS.seedUnreadable };
216
+ }
217
+ };
218
+
219
+ const absentUnlessSeedUnreadable = (deps) => {
220
+ const seed = readQueueSeed(deps);
221
+ return seed.reason ? reportUndecidable(seed.reason) : { verdict: VERDICTS.absent };
222
+ };
223
+
224
+ const detectQueueSeed = ({ root, deps }) => {
225
+ if (!isDeployed(root, deps)) return reportUndecidable(REASONS.noDeployment);
226
+ let plans;
227
+ try {
228
+ plans = deps.lstatSync(resolve(root, PLANS_PATH));
229
+ } catch (error) {
230
+ return ABSENT_ARM_CODES.has(error?.code) ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.plansUnreadable);
231
+ }
232
+ if (plans.isSymbolicLink()) return reportUndecidable(REASONS.plansSymlink);
233
+ if (!plans.isDirectory()) return reportUndecidable(REASONS.plansNotDirectory);
234
+ let queue;
235
+ try {
236
+ queue = deps.lstatSync(resolve(root, QUEUE_PATH));
237
+ } catch (error) {
238
+ return error?.code === MISSING_FILE_CODE ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.queueUnreadable);
239
+ }
240
+ if (queue.isSymbolicLink()) return reportUndecidable(REASONS.queueSymlink);
241
+ return queue.isFile() ? { verdict: VERDICTS.present } : reportUndecidable(REASONS.queueNotRegular);
242
+ };
243
+
165
244
  const tierPreview = (root) => buildInsertPreview(root, TIER_PREVIEW_PATH);
166
245
 
167
246
  export const PROFILE_GAPS = Object.freeze([
@@ -169,4 +248,25 @@ export const PROFILE_GAPS = Object.freeze([
169
248
  Object.freeze({ id: SLOTS_ID, detect: detectSlots, apply: tierPreview }),
170
249
  Object.freeze({ id: STORE_ID, detect: detectStore, apply: tierPreview }),
171
250
  Object.freeze({ id: CHECKER_GATES_ID, detect: detectCheckerGates, apply: (root) => buildInsertPreview(root, CHECKER_GATES_PATH) }),
251
+ Object.freeze({ id: SESSION_RULES_ID, detect: detectSessionRules, apply: (root) => buildInsertPreview(root) }),
252
+ Object.freeze({ id: QUEUE_SEED_ID, detect: detectQueueSeed, apply: tierPreview }),
172
253
  ]);
254
+
255
+ export const GAP_STATES = Object.freeze({
256
+ present: 'present',
257
+ offered: 'offered',
258
+ declined: 'declined',
259
+ undecidable: 'undecidable',
260
+ });
261
+
262
+ export const NO_COMMAND_SENTENCE = 'no runnable command — the root or the kit path carries a control byte or a backtick';
263
+
264
+ export const judgeProfileGaps = ({ root, deps, lineage, declined }) => PROFILE_GAPS.map((entry) => {
265
+ const detected = entry.detect({ root, deps });
266
+ if (detected.verdict === VERDICTS.present) return { entry, state: GAP_STATES.present };
267
+ if (detected.verdict !== VERDICTS.absent) return { entry, state: GAP_STATES.undecidable, reason: detected.reason };
268
+ if (isDeclineCurrent(declined, entry.id, lineage)) {
269
+ return { entry, state: GAP_STATES.declined, lineage: declined[entry.id] };
270
+ }
271
+ return { entry, state: GAP_STATES.offered };
272
+ });
@@ -100,6 +100,7 @@ import {
100
100
  } from './ack-store.mjs';
101
101
  import { ADOPTION, STORE_DIR_REL as SPEC_STORE_DIR_REL, SPEC_ADOPTION_LANE, declineFingerprint, readDeclineAck, surveySpecAdoption } from './spec-adoption.mjs';
102
102
  import { ENSURE_OPS } from './ensure-vocabulary.mjs';
103
+ import { composeProfileGapScreen } from './profile-gap-screen.mjs';
103
104
 
104
105
  // The upgrade ensure that seeds the spec store — the not-adopted item's apply; pinned to the vocabulary.
105
106
  const SPEC_LAYER_ENSURE = ENSURE_OPS.includes('specs') ? 'specs' : null;
@@ -198,6 +199,7 @@ export const SEVERITIES = Object.freeze({
198
199
  // declaration that is broken): an absent store offers the seed, a store with no live contract offers
199
200
  // the decline. Neither arm can leave the flow-optimal line standing — an offer is still an item.
200
201
  'spec-adoption': SEVERITY_OPTIONAL,
202
+ 'profile-gap': SEVERITY_OPTIONAL,
201
203
  'spec-adoption.adopting': SEVERITY_OPTIONAL,
202
204
  });
203
205
  // The per-item render tags (frozen presentation data, same language contract as the templates).
@@ -234,7 +236,8 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
234
236
  // Every static WHAT template lives here — `<key>` is the item key, `<key>.<variant>` a per-site
235
237
  // variant of the same item — so ALL variants are assertable at build time (single line, char cap,
236
238
  // banned tokens), never a fixture-coverage gamble. A pure-placeholder template marks a WHAT whose
237
- // content is fully dynamic (capped at composition by truncation-with-count).
239
+ // content is fully dynamic (capped at composition by truncation-with-count) — except `profile-gap`, whose
240
+ // WHAT is never truncated: the leaf's suite pins every composed WHAT under the cap.
238
241
  export const WHATS = Object.freeze({
239
242
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
240
243
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
@@ -273,6 +276,7 @@ export const WHATS = Object.freeze({
273
276
  'sandbox-lane': 'the wired review wrappers declare a session-sandbox recipe (egress hosts + writable state dirs) not yet acknowledged for this project',
274
277
  'worktrees-dir': 'write access to the worktrees parent dir {dir} is not confirmed — provision may still stop',
275
278
  'spec-adoption': 'feature-spec store absent (docs/ai/specs) — no feature contract can govern a plan here yet; seed the store, or record the decline',
279
+ 'profile-gap': '{what}',
276
280
  'spec-adoption.adopting': 'feature-spec store: {n} draft spec(s), no live contract — nothing governs a plan through it yet; land a live contract, or record the decline',
277
281
  });
278
282
 
@@ -333,6 +337,7 @@ export const BENEFITS = Object.freeze({
333
337
  'sandbox-lane': 'discoverability — the manifest-declared observed sandbox recipe for bridge runs surfaces itself instead of waiting to be asked',
334
338
  'worktrees-dir': 'parallel features — the host-specific write allowance or terminal fallback is surfaced before provision',
335
339
  'spec-adoption': 'contracts — a plan names the contract it builds to, and a change to a governed slice is visible at review instead of after it',
340
+ 'profile-gap': 'full flow — each difference from the reference profile is previewed by its own writer; a declined one returns only at a lineage step',
336
341
  });
337
342
 
338
343
  // ── the CLOSED opt-in capability registry (OPT-IN-SHIPS-INVISIBLE) ──────────────────────────────
@@ -379,6 +384,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
379
384
  // The feature-spec layer is delivered by upgrade's spec-layer ensure (there is no specs mode), so
380
385
  // its adoption state is declared where the store is seeded.
381
386
  { id: 'spec-adoption', mode: 'upgrade', advisorKey: 'spec-adoption' },
387
+ { id: 'full-flow-profile', mode: 'upgrade', advisorKey: 'profile-gap' },
382
388
  { id: 'adr-store-migration', mode: 'migrate-adr-store', advisorKey: 'adr-store-migration' },
383
389
  { id: 'review-recipe', mode: 'set-recipe', advisorKey: 'review-recipe' },
384
390
  // The execute slot is a DISTINCT opt-in from the review slot, and the same probe reports both —
@@ -1297,7 +1303,7 @@ const readReadLaneToggle = (root, deps) => {
1297
1303
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1298
1304
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1299
1305
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1300
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement']);
1306
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement', 'profile-gap']);
1301
1307
 
1302
1308
  // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1303
1309
  // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
@@ -1592,6 +1598,16 @@ const probeMcpChannel = ({ root, deps, add, skip }) => {
1592
1598
  }
1593
1599
  };
1594
1600
 
1601
+ export const probeProfileGaps = ({ root, deps, add, skip }) => {
1602
+ try {
1603
+ const { gaps, skips } = composeProfileGapScreen({ root, deps });
1604
+ for (const { what, apply } of gaps) add('profile-gap', what, apply);
1605
+ for (const reason of skips) skip('profile-gap', new Error(reason));
1606
+ } catch (err) {
1607
+ skip('profile-gap', err);
1608
+ }
1609
+ };
1610
+
1595
1611
  // ── assembly (frozen presentation order) ─────────────────────────────────────────────────────────
1596
1612
  const PROBES = Object.freeze([
1597
1613
  probeVelocityItems,
@@ -1614,6 +1630,7 @@ const PROBES = Object.freeze([
1614
1630
  probeWorktreesDir,
1615
1631
  probeMcpChannel,
1616
1632
  probeSpecAdoption,
1633
+ probeProfileGaps,
1617
1634
  ]);
1618
1635
 
1619
1636
  export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
@@ -27,7 +27,7 @@ const SINGLE_QUOTE = "'";
27
27
  const QUOTE_ESCAPE = "'\\''";
28
28
 
29
29
  // Append the OUTGOING canon here in the same release that changes the template
30
- // §2.5 region — the fragment-or-prior reconcile depends on it.
30
+ // §2.5 or §2.7 region — the fragment-or-prior reconcile depends on it.
31
31
  const COMMS_PRIOR_PRE_AD054 = `### 2.x. Communication (user-facing messages)
32
32
  Apply this as part of §2 before any user-facing summary:
33
33
  - **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
@@ -46,7 +46,13 @@ Apply this as part of §2 before any user-facing summary:
46
46
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
47
47
  const COMMS_PRIOR_STATE_BLOCK = `${COMMS_PRIOR_PLAIN_LANGUAGE}
48
48
  - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.`;
49
- const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK];
49
+ const COMMS_PRIOR_SKIP_FINDING = `${COMMS_PRIOR_STATE_BLOCK}
50
+ - **A skip that contradicts the tree is a finding.** A tool-composed \`skipped-*\` line whose stated reason the observed tree disproves (a "no Node" skip beside deployed Node scripts) is raised as a FINDING in the report, never pasted as a neutral outcome — and a tool may not emit a skip whose reason it could itself disprove.`;
51
+ const COMMS_PRIORS = [
52
+ COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE, COMMS_PRIOR_STATE_BLOCK, COMMS_PRIOR_SKIP_FINDING,
53
+ ];
54
+ const STORY_PRIOR_FIVE_SESSIONS = `### 2.x. Story sessions
55
+ A story — one row of an epic's ledger, carried by one plan — runs as five sessions, each ending at its own review checkpoint: **spec** (the contract under \`docs/ai/specs/\`, drafted and reviewed on the \`plan-authoring\` recipe), **plan** (the ledger, same recipe), **tests** (one task per ledger row, red first), **code** (one task per row, to green), and **diff review, release and record** (the review of the staged tree on the \`plan-execution\` recipe, the release where the story ships one, then the changelog and handover entries and the plan's Phase: Cleanup). Tests and code never share a session, and a spec and its plan never share one either. A storyless plan runs the same five. **Exception — a split:** moving code and its existing cases into modules, with no new logic and no new case, is one session: a short spec (the Module list), the split, the diff review, the release, Cleanup.`;
50
56
 
51
57
  export const RULES_REGIONS = Object.freeze([
52
58
  Object.freeze({
@@ -61,7 +67,7 @@ export const RULES_REGIONS = Object.freeze([
61
67
  headingRe: /^### 2[.]([0-9]+)[.] Story sessions/,
62
68
  label: '### 2.x. Story sessions',
63
69
  canon: TEMPLATE_CANON,
64
- priors: [],
70
+ priors: [STORY_PRIOR_FIVE_SESSIONS],
65
71
  }),
66
72
  Object.freeze({
67
73
  id: 'lens',
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // session-close-check.mjs — the read-only checker of a session close: judges the `## For the user`
3
+ // section of docs/ai/handover.md and its two labels. Governing contract:
4
+ // docs/ai/specs/kit/tier/session-rules/ (part session-close-check). Not a gate: a session close is
5
+ // not a commit boundary. main(argv, deps) returns, never exits, never asks; every read goes through deps.
6
+ import { resolve } from 'node:path';
7
+ import { safeLine } from './carriers.mjs';
8
+ import { isDeployed } from './checker-gates-read.mjs';
9
+ import { isDirectRun } from './direct-run.mjs';
10
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
11
+
12
+ const LF = String.fromCharCode(10);
13
+ const CR = String.fromCharCode(13);
14
+ const BOM_CHAR = String.fromCharCode(65279);
15
+ const ARGV_OFFSET = 2;
16
+ const EXIT_ACCEPT = 0;
17
+ const EXIT_REFUSAL = 1;
18
+ const EXIT_CANNOT_JUDGE = 2;
19
+ const CHECK_FLAG = '--check';
20
+ const CWD_FLAG = '--cwd';
21
+ const HELP_FLAG = '--help';
22
+ const HANDOVER_REL = 'docs/ai/handover.md';
23
+ const SECTION_HEADING = '## For the user';
24
+ const SECTION_BOUNDARY = '## ';
25
+ const LABELS = Object.freeze(['**What was done, and for what:**', '**What is next, and why:**']);
26
+ const SINGLE = 1;
27
+ const SYMLINK_CLASS = 'symlink';
28
+
29
+ const STATES = Object.freeze({
30
+ noDeployment: 'no-deployment',
31
+ handoverSymlink: 'handover-symlink',
32
+ handoverNotRegular: 'handover-not-regular',
33
+ handoverUnreadable: 'handover-unreadable',
34
+ handoverAbsent: 'handover-absent',
35
+ sectionAbsent: 'section-absent',
36
+ sectionTwice: 'section-twice',
37
+ labelAbsent: 'label-absent',
38
+ labelTwice: 'label-twice',
39
+ labelOrder: 'label-order',
40
+ labelEmpty: 'label-empty',
41
+ });
42
+
43
+ export const OUTCOME_LINES = Object.freeze({
44
+ accept: () => 'session-close-check: accept — the handover carries the For the user section with both labels and their text',
45
+ state: (name) => safeLine(name),
46
+ labelState: (name, label) => `${safeLine(name)}: ${safeLine(label)}`,
47
+ usage: (problem) => `usage: session-close-check.mjs --check [--cwd DIR] — ${safeLine(problem)}`,
48
+ help: () => [
49
+ 'session-close-check.mjs --check [--cwd DIR]',
50
+ 'Judges the For the user section of docs/ai/handover.md in DIR (default: the current directory); writes nothing.',
51
+ 'Exit codes: 0 accept, 1 one refusal per line, 2 usage or cannot judge.',
52
+ ].join(LF),
53
+ });
54
+
55
+ const result = (code, stdout = '', stderr = '') => ({ code, stdout, stderr });
56
+ const cannotJudge = (name) => result(EXIT_CANNOT_JUDGE, '', OUTCOME_LINES.state(name));
57
+
58
+ const parseArgs = (argv) => {
59
+ if (argv.length === 1 && argv[0] === HELP_FLAG) return { help: true };
60
+ const options = {};
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const flag = argv[index];
63
+ if (flag === HELP_FLAG) return { problem: `${HELP_FLAG} takes no other argument` };
64
+ if (flag === CHECK_FLAG) {
65
+ if (options.check) return { problem: `${CHECK_FLAG} given twice` };
66
+ options.check = true;
67
+ } else if (flag === CWD_FLAG) {
68
+ if (options.cwd !== undefined) return { problem: `${CWD_FLAG} given twice` };
69
+ const value = argv[index + 1];
70
+ if (value === undefined || value.startsWith('-')) return { problem: `${CWD_FLAG} needs a directory` };
71
+ options.cwd = value;
72
+ index += 1;
73
+ } else {
74
+ return { problem: `unknown argument ${flag}` };
75
+ }
76
+ }
77
+ if (!options.check) return { problem: `${CHECK_FLAG} is required` };
78
+ return { cwd: options.cwd ?? '.' };
79
+ };
80
+
81
+ const stripCr = (line) => (line.endsWith(CR) ? line.slice(0, -1) : line);
82
+ const isBlank = (text) => text.trim() === '';
83
+
84
+ const sectionLines = (text) => {
85
+ const lines = text.split(LF).map(stripCr);
86
+ if (lines[0].startsWith(BOM_CHAR)) lines[0] = lines[0].slice(BOM_CHAR.length);
87
+ const starts = lines.flatMap((line, index) => (line === SECTION_HEADING ? [index] : []));
88
+ if (starts.length !== SINGLE) return { state: starts.length === 0 ? STATES.sectionAbsent : STATES.sectionTwice };
89
+ const body = lines.slice(starts[0] + 1);
90
+ const end = body.findIndex((line) => line.startsWith(SECTION_BOUNDARY));
91
+ return { lines: end === -1 ? body : body.slice(0, end) };
92
+ };
93
+
94
+ const isLabelLine = (line) => LABELS.some((label) => line.startsWith(label));
95
+
96
+ const hasText = (lines, index, label) => {
97
+ if (!isBlank(lines[index].slice(label.length))) return true;
98
+ const after = lines.slice(index + 1);
99
+ const next = after.findIndex(isLabelLine);
100
+ return (next === -1 ? after : after.slice(0, next)).some((line) => !isBlank(line));
101
+ };
102
+
103
+ const judgeLabels = (lines) => {
104
+ const found = LABELS.map((label) => ({
105
+ label,
106
+ at: lines.flatMap((line, index) => (line.startsWith(label) ? [index] : [])),
107
+ }));
108
+ const once = found.filter(({ at }) => at.length === SINGLE);
109
+ const refusals = [
110
+ ...found.filter(({ at }) => at.length === 0).map(({ label }) => OUTCOME_LINES.labelState(STATES.labelAbsent, label)),
111
+ ...found.filter(({ at }) => at.length > SINGLE).map(({ label }) => OUTCOME_LINES.labelState(STATES.labelTwice, label)),
112
+ ];
113
+ if (once.length === LABELS.length && once[1].at[0] < once[0].at[0]) refusals.push(OUTCOME_LINES.state(STATES.labelOrder));
114
+ for (const { label, at } of once) {
115
+ if (!hasText(lines, at[0], label)) refusals.push(OUTCOME_LINES.labelState(STATES.labelEmpty, label));
116
+ }
117
+ return refusals;
118
+ };
119
+
120
+ const judgeHandover = (content) => {
121
+ const section = sectionLines(content);
122
+ if (section.state) return [OUTCOME_LINES.state(section.state)];
123
+ return judgeLabels(section.lines);
124
+ };
125
+
126
+ const runCheck = (options, deps) => {
127
+ const root = resolve(options.cwd);
128
+ if (!isDeployed(root, deps)) return cannotJudge(STATES.noDeployment);
129
+ const read = readRegularFileNoFollow(resolve(root, HANDOVER_REL), deps);
130
+ if (read.outcome === 'absent') return result(EXIT_REFUSAL, '', OUTCOME_LINES.state(STATES.handoverAbsent));
131
+ if (read.outcome === 'foreign') {
132
+ return cannotJudge(read.className === SYMLINK_CLASS ? STATES.handoverSymlink : STATES.handoverNotRegular);
133
+ }
134
+ if (read.outcome !== 'ok') return cannotJudge(STATES.handoverUnreadable);
135
+ const refusals = judgeHandover(read.content);
136
+ if (refusals.length > 0) return result(EXIT_REFUSAL, '', refusals.join(LF));
137
+ return result(EXIT_ACCEPT, OUTCOME_LINES.accept());
138
+ };
139
+
140
+ export const main = (argv, deps = {}) => {
141
+ const parsed = parseArgs(argv);
142
+ if (parsed.help) return result(EXIT_ACCEPT, OUTCOME_LINES.help());
143
+ if (parsed.problem) return result(EXIT_CANNOT_JUDGE, '', OUTCOME_LINES.usage(parsed.problem));
144
+ return runCheck(parsed, deps);
145
+ };
146
+
147
+ if (isDirectRun(import.meta.url)) {
148
+ const outcome = main(process.argv.slice(ARGV_OFFSET));
149
+ if (outcome.stdout) console.log(outcome.stdout);
150
+ if (outcome.stderr) console.error(outcome.stderr);
151
+ process.exitCode = outcome.code;
152
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // tier-preview.mjs — the offer of lineage step 4.0.0: one preview over the whole profile-gap
3
- // registry; on an explicit yes (--apply) the absent epic and task slots and the epic store seed;
3
+ // registry; on an explicit yes (--apply) the absent epic and task slots, the epic store seed and the queue seed;
4
4
  // on an explicit no (--decline) the decline record. Governing contract:
5
5
  // docs/ai/specs/kit/tier/tier-offer/ (part tier-preview). main(argv, deps) returns, never exits,
6
6
  // never asks; every read and write goes through deps.
@@ -12,9 +12,9 @@ import { isDirectRun } from './direct-run.mjs';
12
12
  import { tmpNote } from './ensure-ops.mjs';
13
13
  import { CANON_README, applySetOps, refreshReadme } from './orchestration-config.mjs';
14
14
  import { writeConfig } from './orchestration-write.mjs';
15
- import { PROFILE_GAPS, readTierConfig } from './profile-gaps.mjs';
15
+ import { GAP_STATES, NO_COMMAND_SENTENCE, judgeProfileGaps, readQueueSeed, readTierConfig } from './profile-gaps.mjs';
16
16
  import { composeReadiness, planRecipe } from './recipes.mjs';
17
- import { TIER_TARGETS, isDeclineCurrent, readDeclines, readShippedProfile } from './reference-profile.mjs';
17
+ import { TIER_TARGETS, readDeclines, readShippedProfile } from './reference-profile.mjs';
18
18
 
19
19
  const LF = String.fromCharCode(10);
20
20
  const ARGV_OFFSET = 2;
@@ -30,30 +30,28 @@ const SLOTS_ID = 'epic-task-slots';
30
30
  const STORE_ID = 'epic-store-seeded';
31
31
  const STORE_DIR = 'docs/ai/epics';
32
32
  const STORE_SEED = 'docs/ai/epics/.gitkeep';
33
+ const QUEUE_ID = 'named-queue-row-seed';
34
+ const PLANS_DIR = 'docs/plans';
35
+ const QUEUE_SEED = 'docs/plans/queue.md';
36
+ const MISSING_CODE = 'ENOENT';
33
37
  const CONFIG_WRITTEN = 'docs/ai/orchestration.json';
34
38
  const DECLINES_REL = 'docs/ai/profile-declines.json';
35
39
  const SCHEMA = 1;
36
40
  const INDENT = ' ';
37
41
  const WORDS = Object.freeze({
38
- present: 'present',
39
- offered: 'offered',
40
- declined: 'declined',
41
- undecidable: 'undecidable',
42
42
  applied: 'applied',
43
43
  recorded: 'recorded',
44
44
  });
45
- const ABSENT_VERDICT = 'absent';
46
- const PRESENT_VERDICT = 'present';
47
45
 
48
46
  export const OUTCOME_LINES = Object.freeze({
49
47
  entry: (id, word, detail) => `${id}: ${word}${detail ? ` — ${detail}` : ''}`,
50
48
  declinedAt: (lineage) => `at lineage ${lineage}`,
51
49
  applyLine: (command) => `${INDENT}apply: ${command}`,
52
- noCommand: () => `${INDENT}apply: no runnable command — the root or the kit path carries a control byte or a backtick`,
50
+ noCommand: () => `${INDENT}apply: ${NO_COMMAND_SENTENCE}`,
53
51
  slotValue: (activity, slot, value, skipped = []) => `${INDENT}${activity}.${slot} = ${value}`
54
52
  + skipped.map(({ candidate, reason }) => ` — ${candidate} skipped: ${reason}`).join(''),
55
53
  slotPresent: (activity, slot) => `${INDENT}${activity}.${slot}: present`,
56
- tmpLeft: (tmp) => tmpNote(STORE_SEED, tmp)[0],
54
+ tmpLeft: (rel, tmp) => tmpNote(rel, tmp)[0],
57
55
  nothingOffered: () => 'nothing offered — nothing recorded',
58
56
  detectFailed: (cause) => `backend detection failed (${safeLine(cause)}) — every bridge is treated as not ready`,
59
57
  refusal: (name, detail) => `tier-preview: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
@@ -61,7 +59,7 @@ export const OUTCOME_LINES = Object.freeze({
61
59
  help: () => [
62
60
  'tier-preview.mjs --cwd DIR [--apply | --decline]',
63
61
  'Previews every profile gap of the project in DIR; writes nothing by default.',
64
- '--apply writes the offered items: the absent epic and task settings and the epic store seed.',
62
+ '--apply writes the offered items: the absent epic and task settings, the epic store seed and the queue seed.',
65
63
  '--decline records every offered item as declined at the shipped profile lineage.',
66
64
  'Exit codes: 0 done, 1 one named refusal, 2 usage.',
67
65
  ].join(LF),
@@ -121,23 +119,18 @@ const slotLines = (slots) => slots.map(({ target, declared, value, skipped }) =>
121
119
  ? OUTCOME_LINES.slotPresent(target.activity, target.slot)
122
120
  : OUTCOME_LINES.slotValue(target.activity, target.slot, value, skipped)));
123
121
 
124
- const judgeEntries = (root, deps, profile, declined) => PROFILE_GAPS.map((entry) => {
125
- const detected = entry.detect({ root, deps });
126
- if (detected.verdict === PRESENT_VERDICT) return { entry, word: WORDS.present };
127
- if (detected.verdict !== ABSENT_VERDICT) return { entry, word: WORDS.undecidable, detail: detected.reason };
128
- if (isDeclineCurrent(declined, entry.id, profile.lineage)) {
129
- return { entry, word: WORDS.declined, detail: OUTCOME_LINES.declinedAt(declined[entry.id]) };
130
- }
131
- return { entry, word: WORDS.offered };
132
- });
122
+ const judgeEntries = (root, deps, profile, declined) => judgeProfileGaps({ root, deps, lineage: profile.lineage, declined })
123
+ .map(({ entry, state, lineage, reason }) => ({
124
+ entry, word: state, detail: state === GAP_STATES.declined ? OUTCOME_LINES.declinedAt(lineage) : reason,
125
+ }));
133
126
 
134
127
  const renderEntry = ({ entry, word, detail }, root, slots) => {
135
128
  const lines = [OUTCOME_LINES.entry(entry.id, word, detail)];
136
- if (word === WORDS.offered) {
129
+ if (word === GAP_STATES.offered) {
137
130
  const command = entry.apply(root);
138
131
  lines.push(command ? OUTCOME_LINES.applyLine(command) : OUTCOME_LINES.noCommand());
139
132
  }
140
- if (entry.id === SLOTS_ID && (word === WORDS.offered || word === WORDS.applied)) lines.push(...slotLines(slots));
133
+ if (entry.id === SLOTS_ID && (word === GAP_STATES.offered || word === WORDS.applied)) lines.push(...slotLines(slots));
141
134
  return lines;
142
135
  };
143
136
 
@@ -149,19 +142,40 @@ const writeSlots = (root, config, slots, deps) => {
149
142
  writeConfig(root, refreshReadme(merged).config, deps);
150
143
  };
151
144
 
152
- const storeCreated = (root, deps) => {
145
+ const directoryAt = (root, rel, deps) => {
153
146
  try {
154
- return deps.lstat(resolve(root, STORE_DIR)).isDirectory();
147
+ return deps.lstat(resolve(root, rel)).isDirectory();
155
148
  } catch {
156
149
  return false;
157
150
  }
158
151
  };
159
152
 
153
+ const missingAt = (root, rel, deps) => {
154
+ try {
155
+ deps.lstat(resolve(root, rel));
156
+ return false;
157
+ } catch (error) {
158
+ return error?.code === MISSING_CODE;
159
+ }
160
+ };
161
+
162
+ const writeQueueSeed = (root, deps, bytes, made) => {
163
+ const plansMissing = missingAt(root, PLANS_DIR, deps);
164
+ try {
165
+ return { seeded: writeProjectFileCreateOnly(root, QUEUE_SEED, bytes, deps, { noun: 'the queue seed' }) };
166
+ } catch (error) {
167
+ if (plansMissing && directoryAt(root, PLANS_DIR, deps)) made.push(`${PLANS_DIR} created`);
168
+ return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
169
+ }
170
+ };
171
+
160
172
  const runApply = (root, deps, judged, slots, config) => {
161
173
  const byId = new Map(judged.map((item) => [item.entry.id, item]));
162
174
  const extra = [];
163
175
  const made = [];
164
- if (byId.get(SLOTS_ID)?.word === WORDS.offered) {
176
+ const seed = byId.get(QUEUE_ID)?.word === GAP_STATES.offered ? readQueueSeed(deps) : null;
177
+ if (seed?.reason) return { refusal: refuse(seed.reason) };
178
+ if (byId.get(SLOTS_ID)?.word === GAP_STATES.offered) {
165
179
  try {
166
180
  writeSlots(root, config, slots, deps);
167
181
  } catch (error) {
@@ -170,16 +184,26 @@ const runApply = (root, deps, judged, slots, config) => {
170
184
  made.push(`${CONFIG_WRITTEN} written`);
171
185
  byId.set(SLOTS_ID, { ...byId.get(SLOTS_ID), word: WORDS.applied });
172
186
  }
173
- if (byId.get(STORE_ID)?.word === WORDS.offered) {
187
+ if (byId.get(STORE_ID)?.word === GAP_STATES.offered) {
174
188
  let seeded;
175
189
  try {
176
190
  seeded = writeProjectFileCreateOnly(root, STORE_SEED, '', deps, { noun: 'the epic store seed' });
177
191
  } catch (error) {
178
- if (storeCreated(root, deps)) made.push(`${STORE_DIR} created`);
192
+ if (directoryAt(root, STORE_DIR, deps)) made.push(`${STORE_DIR} created`);
179
193
  return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
194
  }
181
- byId.set(STORE_ID, { ...byId.get(STORE_ID), word: seeded.created ? WORDS.applied : WORDS.present });
182
- if (seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(seeded.tmpLeftBehind));
195
+ byId.set(STORE_ID, { ...byId.get(STORE_ID), word: seeded.created ? WORDS.applied : GAP_STATES.present });
196
+ if (seeded.created) made.push(`${STORE_SEED} created`);
197
+ if (seeded.tmpLeftBehind) {
198
+ extra.push(OUTCOME_LINES.tmpLeft(STORE_SEED, seeded.tmpLeftBehind));
199
+ made.push(`${seeded.tmpLeftBehind} left behind`);
200
+ }
201
+ }
202
+ if (seed) {
203
+ const written = writeQueueSeed(root, deps, seed.bytes, made);
204
+ if (written.refusal) return written;
205
+ byId.set(QUEUE_ID, { ...byId.get(QUEUE_ID), word: written.seeded.created ? WORDS.applied : GAP_STATES.present });
206
+ if (written.seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(QUEUE_SEED, written.seeded.tmpLeftBehind));
183
207
  }
184
208
  return { judged: judged.map((item) => byId.get(item.entry.id)), extra };
185
209
  };
@@ -190,7 +214,7 @@ const serializeDeclines = (declined) => {
190
214
  };
191
215
 
192
216
  const runDecline = (root, deps, judged, profile, declined) => {
193
- const offered = judged.filter(({ word }) => word === WORDS.offered);
217
+ const offered = judged.filter(({ word }) => word === GAP_STATES.offered);
194
218
  if (offered.length === 0) return { judged, extra: [OUTCOME_LINES.nothingOffered()] };
195
219
  const merged = { ...(declined ?? {}) };
196
220
  for (const { entry } of offered) merged[entry.id] = profile.lineage;
@@ -200,7 +224,7 @@ const runDecline = (root, deps, judged, profile, declined) => {
200
224
  return { refusal: refuse('write-failed', causeOf(error)) };
201
225
  }
202
226
  return {
203
- judged: judged.map((item) => (item.word === WORDS.offered ? { ...item, word: WORDS.recorded } : item)),
227
+ judged: judged.map((item) => (item.word === GAP_STATES.offered ? { ...item, word: WORDS.recorded } : item)),
204
228
  extra: [],
205
229
  };
206
230
  };
@@ -222,7 +246,7 @@ const runOffer = (options, given) => {
222
246
  ...deps, onDetectError: (error) => notes.push(OUTCOME_LINES.detectFailed(causeOf(error))),
223
247
  });
224
248
  const judged = judgeEntries(root, deps, shipped.profile, record.declined);
225
- const offersSlots = judged.some(({ entry, word }) => entry.id === SLOTS_ID && word === WORDS.offered);
249
+ const offersSlots = judged.some(({ entry, word }) => entry.id === SLOTS_ID && word === GAP_STATES.offered);
226
250
  const config = offersSlots ? readTierConfig(root, deps).config : null;
227
251
  const slots = offersSlots ? planSlots(config, readiness) : [];
228
252
  let outcome = { judged, extra: [] };