@sabaiway/agent-workflow-kit 14.12.0 → 14.13.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,34 @@ 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.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)
8
+
9
+ **Your agent now ends a session with two short blocks written for you, and the handover keeps them.** Until 14.13.0
10
+ the rules file did not ask for these two blocks, no memory template had a place for them, and a
11
+ 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
12
+ slot, and a queue seed on the upgrade's existing one yes. Story S5 of the epic `CONSUMERS-MOVE-ONTO-THE-FULL-FLOW`;
13
+ contract `docs/ai/specs/kit/tier/session-rules/` (new).
14
+
15
+ - **Two rules in the rules template.** §2.5 Communication gains the bullet `Two blocks for the user`: at a plan's
16
+ approval, at a release and at every session close the message body carries *What was done, and for what* and
17
+ *What is next, and why*, in the dialogue language, with no internal ids and no review-round tallies. §2.7 Story
18
+ sessions gains one sentence: inside the tests and code sessions each task runs as its own carrier session. The
19
+ upgrade's `lens` step refreshes a region still equal to the text an earlier kit shipped, without an ask; a region
20
+ you edited is kept and flagged, as before.
21
+ - **A `For the user` slot.** The handover template gains a `## For the user` section with the two labels, and the
22
+ changelog template's entry a `**For the user:**` line after its Goal. A project bootstrapped from now on starts with
23
+ them.
24
+ - **`tools/session-close-check.mjs --check [--cwd <project>]`.** Read-only: it judges the `## For the user` section of
25
+ `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,
26
+ named. It checks that both labels carry text, never what language the text is in or what it says. It is not
27
+ offered as a gate: a session close is not a commit.
28
+ - **The upgrade offers a work queue.** The `tier` step's registry gains two entries after `checker-gates-declared`.
29
+ `named-queue-row-seed` offers to seed a missing `docs/plans/queue.md`; on the same one yes,
30
+ `tools/tier-preview.mjs --apply` writes it, create-only, from the kit's `references/authoring/QUEUE_TEMPLATE.md`:
31
+ the named-row rule, the purge archive, priority order and the check line, and four empty buckets. An existing queue
32
+ is never touched, and `queue-audit-cli`'s `--require-names` stays opt-in. `session-close-rules` only reports whether
33
+ both rules are current in your rules file; it never offers a write.
34
+
7
35
  ## 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
36
 
9
37
  **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.13.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.13.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
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.13.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
@@ -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.
@@ -1,6 +1,7 @@
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
6
  import { TIER_TARGETS } from './reference-profile.mjs';
6
7
  import {
@@ -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,19 @@ 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
+ const REGION_ABSENT = Object.freeze({ communication: 'communication-absent', 'story-sessions': 'story-sessions-absent' });
71
+ const JUDGEMENTS = Object.freeze({ communication: reconcileCommsText, 'story-sessions': reconcileStoryText });
51
72
  const TEMPLATE_REGIONS = RULES_REGIONS.filter(({ canon }) => canon === TEMPLATE_CANON);
52
73
  const STORY_REGION = RULES_REGIONS.find(({ id }) => id === STORY_ID);
53
74
 
@@ -162,6 +183,63 @@ const detectCheckerGates = ({ root, deps }) => {
162
183
  return undecided ? reportUndecidable(undecided.state) : { verdict: VERDICTS.present };
163
184
  };
164
185
 
186
+ // A region is judged by the reconcile's own pure judgement, so this entry and the lens step agree.
187
+ const judgeRegion = (text, spans, { id, headingRe, priors }) => {
188
+ if (!extractRegionBy(text, headingRe).found) return REGION_ABSENT[id];
189
+ const { status } = JUDGEMENTS[id](text, normalizeLensBody(spans[id].span), priors);
190
+ if (status === CURRENT_STATUS) return null;
191
+ return status === REFRESHED_STATUS ? REASONS.regionPrior : REASONS.regionCustomized;
192
+ };
193
+
194
+ const detectSessionRules = ({ root, deps }) => {
195
+ const template = readSpans(deps);
196
+ if (template.verdict) return template;
197
+ const target = readTarget(root, deps);
198
+ if (target.verdict) return target;
199
+ const { text } = target;
200
+ if (TEMPLATE_REGIONS.some(({ headingRe }) => extractRegionBy(text, headingRe).count > SINGLE_HEADING)) {
201
+ return reportUndecidable(REASONS.headingTwice);
202
+ }
203
+ for (const region of TEMPLATE_REGIONS) {
204
+ const reason = judgeRegion(text, template.spans, region);
205
+ if (reason) return reportUndecidable(reason);
206
+ }
207
+ return { verdict: VERDICTS.present };
208
+ };
209
+
210
+ export const readQueueSeed = (deps) => {
211
+ try {
212
+ return { bytes: deps.readFileSync(QUEUE_SEED_PATH) };
213
+ } catch {
214
+ return { reason: REASONS.seedUnreadable };
215
+ }
216
+ };
217
+
218
+ const absentUnlessSeedUnreadable = (deps) => {
219
+ const seed = readQueueSeed(deps);
220
+ return seed.reason ? reportUndecidable(seed.reason) : { verdict: VERDICTS.absent };
221
+ };
222
+
223
+ const detectQueueSeed = ({ root, deps }) => {
224
+ if (!isDeployed(root, deps)) return reportUndecidable(REASONS.noDeployment);
225
+ let plans;
226
+ try {
227
+ plans = deps.lstatSync(resolve(root, PLANS_PATH));
228
+ } catch (error) {
229
+ return ABSENT_ARM_CODES.has(error?.code) ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.plansUnreadable);
230
+ }
231
+ if (plans.isSymbolicLink()) return reportUndecidable(REASONS.plansSymlink);
232
+ if (!plans.isDirectory()) return reportUndecidable(REASONS.plansNotDirectory);
233
+ let queue;
234
+ try {
235
+ queue = deps.lstatSync(resolve(root, QUEUE_PATH));
236
+ } catch (error) {
237
+ return error?.code === MISSING_FILE_CODE ? absentUnlessSeedUnreadable(deps) : reportUndecidable(REASONS.queueUnreadable);
238
+ }
239
+ if (queue.isSymbolicLink()) return reportUndecidable(REASONS.queueSymlink);
240
+ return queue.isFile() ? { verdict: VERDICTS.present } : reportUndecidable(REASONS.queueNotRegular);
241
+ };
242
+
165
243
  const tierPreview = (root) => buildInsertPreview(root, TIER_PREVIEW_PATH);
166
244
 
167
245
  export const PROFILE_GAPS = Object.freeze([
@@ -169,4 +247,6 @@ export const PROFILE_GAPS = Object.freeze([
169
247
  Object.freeze({ id: SLOTS_ID, detect: detectSlots, apply: tierPreview }),
170
248
  Object.freeze({ id: STORE_ID, detect: detectStore, apply: tierPreview }),
171
249
  Object.freeze({ id: CHECKER_GATES_ID, detect: detectCheckerGates, apply: (root) => buildInsertPreview(root, CHECKER_GATES_PATH) }),
250
+ Object.freeze({ id: SESSION_RULES_ID, detect: detectSessionRules, apply: (root) => buildInsertPreview(root) }),
251
+ Object.freeze({ id: QUEUE_SEED_ID, detect: detectQueueSeed, apply: tierPreview }),
172
252
  ]);
@@ -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,7 +12,7 @@ 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 { PROFILE_GAPS, readQueueSeed, readTierConfig } from './profile-gaps.mjs';
16
16
  import { composeReadiness, planRecipe } from './recipes.mjs';
17
17
  import { TIER_TARGETS, isDeclineCurrent, readDeclines, readShippedProfile } from './reference-profile.mjs';
18
18
 
@@ -30,6 +30,10 @@ 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;
@@ -53,7 +57,7 @@ export const OUTCOME_LINES = Object.freeze({
53
57
  slotValue: (activity, slot, value, skipped = []) => `${INDENT}${activity}.${slot} = ${value}`
54
58
  + skipped.map(({ candidate, reason }) => ` — ${candidate} skipped: ${reason}`).join(''),
55
59
  slotPresent: (activity, slot) => `${INDENT}${activity}.${slot}: present`,
56
- tmpLeft: (tmp) => tmpNote(STORE_SEED, tmp)[0],
60
+ tmpLeft: (rel, tmp) => tmpNote(rel, tmp)[0],
57
61
  nothingOffered: () => 'nothing offered — nothing recorded',
58
62
  detectFailed: (cause) => `backend detection failed (${safeLine(cause)}) — every bridge is treated as not ready`,
59
63
  refusal: (name, detail) => `tier-preview: refused — ${name}${detail ? `: ${safeLine(detail)}` : ''}`,
@@ -61,7 +65,7 @@ export const OUTCOME_LINES = Object.freeze({
61
65
  help: () => [
62
66
  'tier-preview.mjs --cwd DIR [--apply | --decline]',
63
67
  '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.',
68
+ '--apply writes the offered items: the absent epic and task settings, the epic store seed and the queue seed.',
65
69
  '--decline records every offered item as declined at the shipped profile lineage.',
66
70
  'Exit codes: 0 done, 1 one named refusal, 2 usage.',
67
71
  ].join(LF),
@@ -149,18 +153,39 @@ const writeSlots = (root, config, slots, deps) => {
149
153
  writeConfig(root, refreshReadme(merged).config, deps);
150
154
  };
151
155
 
152
- const storeCreated = (root, deps) => {
156
+ const directoryAt = (root, rel, deps) => {
153
157
  try {
154
- return deps.lstat(resolve(root, STORE_DIR)).isDirectory();
158
+ return deps.lstat(resolve(root, rel)).isDirectory();
155
159
  } catch {
156
160
  return false;
157
161
  }
158
162
  };
159
163
 
164
+ const missingAt = (root, rel, deps) => {
165
+ try {
166
+ deps.lstat(resolve(root, rel));
167
+ return false;
168
+ } catch (error) {
169
+ return error?.code === MISSING_CODE;
170
+ }
171
+ };
172
+
173
+ const writeQueueSeed = (root, deps, bytes, made) => {
174
+ const plansMissing = missingAt(root, PLANS_DIR, deps);
175
+ try {
176
+ return { seeded: writeProjectFileCreateOnly(root, QUEUE_SEED, bytes, deps, { noun: 'the queue seed' }) };
177
+ } catch (error) {
178
+ if (plansMissing && directoryAt(root, PLANS_DIR, deps)) made.push(`${PLANS_DIR} created`);
179
+ return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
+ }
181
+ };
182
+
160
183
  const runApply = (root, deps, judged, slots, config) => {
161
184
  const byId = new Map(judged.map((item) => [item.entry.id, item]));
162
185
  const extra = [];
163
186
  const made = [];
187
+ const seed = byId.get(QUEUE_ID)?.word === WORDS.offered ? readQueueSeed(deps) : null;
188
+ if (seed?.reason) return { refusal: refuse(seed.reason) };
164
189
  if (byId.get(SLOTS_ID)?.word === WORDS.offered) {
165
190
  try {
166
191
  writeSlots(root, config, slots, deps);
@@ -175,11 +200,21 @@ const runApply = (root, deps, judged, slots, config) => {
175
200
  try {
176
201
  seeded = writeProjectFileCreateOnly(root, STORE_SEED, '', deps, { noun: 'the epic store seed' });
177
202
  } catch (error) {
178
- if (storeCreated(root, deps)) made.push(`${STORE_DIR} created`);
203
+ if (directoryAt(root, STORE_DIR, deps)) made.push(`${STORE_DIR} created`);
179
204
  return { refusal: refuse('write-failed', [...made, causeOf(error)].join('; ')) };
180
205
  }
181
206
  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));
207
+ if (seeded.created) made.push(`${STORE_SEED} created`);
208
+ if (seeded.tmpLeftBehind) {
209
+ extra.push(OUTCOME_LINES.tmpLeft(STORE_SEED, seeded.tmpLeftBehind));
210
+ made.push(`${seeded.tmpLeftBehind} left behind`);
211
+ }
212
+ }
213
+ if (seed) {
214
+ const written = writeQueueSeed(root, deps, seed.bytes, made);
215
+ if (written.refusal) return written;
216
+ byId.set(QUEUE_ID, { ...byId.get(QUEUE_ID), word: written.seeded.created ? WORDS.applied : WORDS.present });
217
+ if (written.seeded.tmpLeftBehind) extra.push(OUTCOME_LINES.tmpLeft(QUEUE_SEED, written.seeded.tmpLeftBehind));
183
218
  }
184
219
  return { judged: judged.map((item) => byId.get(item.entry.id)), extra };
185
220
  };