@sabaiway/agent-workflow-kit 14.8.0 → 14.10.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,67 @@ 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.10.0 — every place a user first looks names the epic, story and task tier and the guide that explains it (AD-156)
8
+
9
+ **A kit user now meets the tier without running the help.** Until 14.10.0 only the help named the tier and its
10
+ guide. Six more places now carry the phrase `epic, story and task` and the command `/agent-workflow-kit tier` on one
11
+ line, each in its own words: the skill's description, a read-only row of the kit README's command table directly after
12
+ `now`, a short paragraph after step 3 of the root README's start section, a fixed line of the welcome mat in the
13
+ bootstrap and upgrade report footer, a read-only entry of the bootstrap's optional block, and a bullet of the install's `Next —` lines.
14
+ Story S4 of the epic `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`, its last; contract
15
+ `docs/ai/specs/kit/tier/tier-discovery.md` (new).
16
+
17
+ - **The welcome mat.** The tier line is printed right after the help line, in every footer, whichever rung fires or
18
+ none; it is not a rung, since a rung prints only when every rung above it is silent and a user with no backend
19
+ ready would never reach it. The six rungs and their order are unchanged; with no rung, the two lines stand alone.
20
+ - **The bootstrap block.** The lead-in now says every writer previews first, since the new tier entry writes
21
+ nothing; every other entry is unchanged.
22
+ - **Proof on the installed copies.** The repository walk fixture `test/tier-walk-e2e.test.mjs` checks each
23
+ installed surface in both of its states — the installer's recorded output, the installed files and the help
24
+ rendered from the installed `tools/commands.mjs` — and the root README once, from the repository. Each region
25
+ is cut by asserted anchors (a missing one fails naming the surface and the anchor, from the new test helper
26
+ `test/doc-region-harness.test.mjs`), and cells that remove, move or case-change a token assert the surface is named.
27
+ - **No budget raised.** The router byte budgets hold unedited; `bin/install.mjs` grows by its one bullet line, a
28
+ recorded raise to 585 lines. The help's own text, which the 14.9.0 entry expected with this story, stays as it is:
29
+ its rendered `tier` line already names the tier and the guide. The command catalog, the rungs and `upgrade.md` are
30
+ unchanged.
31
+
32
+ ## 14.9.0 — one read-only guide says what an epic, a story and a task are, and names the next step of the walk (AD-155)
33
+
34
+ **`/agent-workflow-kit tier` answers "what is this tier, and what do I do next?" from the disk alone.** Until 14.9.0
35
+ every tool of the epic, story and task tier worked, but nothing said what the tier is or which step comes next, and
36
+ two refusals of a solo walk had their remedy only in a contract. The new guide prints a fixed text — an epic, a story
37
+ and a task in one sentence each, the five story sessions, both templates by their installed paths, every key of both
38
+ templates with one sentence each, and the Recommendations command — then, per epic, its stage and the ordered steps
39
+ left, the first being the next one. Each step that runs a tool prints its command on its own line, ready to run from
40
+ the project root; an authoring step (copy a template, fill it, edit a row) is named in words. Story S3 of the epic
41
+ `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`; contract `docs/ai/specs/kit/tier/tier-guide/` (new), with
42
+ `docs/ai/specs/kit/tier/tier-walk.md` at revision 3.
43
+
44
+ - **The stages.** Per epic: E1 no epic yet, E2 the epic to fix, E3 open with a story in hand, E4 every story landed
45
+ and the epic to close, E5 landed. Per story in hand: S1 no plan, S2 a plan and no checkpoint, S3 a checkpoint and
46
+ no brief, S4 a brief with no binding, S5 a brief bound to an older checkpoint, S6 the task in hand, S7 the brief
47
+ done, S8 the checkpoints pruned and the story to land. A plan left over from a landed story is named as its
48
+ Cleanup: the checkpoint prune where one exists, then the removal of the plan and its briefs.
49
+ - **Nothing is guessed.** An unreadable epic file or plan, a plan that does not parse, a second plan for one story, a
50
+ brief the grammar refuses, a malformed binding block, a checkpoint the reader refuses: each is printed as a state
51
+ with its reader, file and cause, and the guide still exits 0; an epic its check refuses is stage E2, with the
52
+ check command. A `{{KEY}}` placeholder left in an accepted epic or a
53
+ stamped brief is named with its file before the stage's steps. Several epics or stories in hand each render.
54
+ - **Read-only, like `now`.** `node tools/tier-guide.mjs [--dir <project>] [--json]`; `--help` only as the whole
55
+ invocation. Exit 0 anything rendered, 1 the directory is not a git work tree, 2 usage. `--json` prints the same
56
+ facts as an envelope `{ schema, command, dir, tier, states, entries, skipped }`, each step carrying its text, its
57
+ kind (`run`, `write` or `fact`) and its command or null. It writes no file and no git state.
58
+ - **Where to find it.** The mode `tier` (mode doc `references/modes/tier.md`), one Inspect entry in the command
59
+ catalog (so `/agent-workflow-kit help` lists it) and the `### Mode: tier` header in `SKILL.md`. The other places
60
+ that should mention it (the help's own text, the readmes, the welcome screen, the install's closing lines) come
61
+ with the epic's next story.
62
+ - **Internals.** `readPlanEntries` moves from `tools/plan-shape-cli.mjs` into `tools/plan-files.mjs`;
63
+ `plan-shape-cli` and the guide's facts module import it. The repository walk fixture `test/tier-walk-e2e.test.mjs`
64
+ now reads the guide at every stage, and every tool step but the designed refusal cells is a line the guide
65
+ printed, run through `/bin/sh` unchanged. The package-content pin names the two tools and the mode doc, and the
66
+ tarball holds 336 files. The guide's suites do not ship.
67
+
7
68
  ## 14.8.0 — the kit ships an epic template and a task-brief template, each accepted by its checker once filled in (AD-154)
8
69
 
9
70
  **A first epic and a first task brief can start from a file instead of from a checker's refusal.** Until 14.8.0 the
package/README.md CHANGED
@@ -234,6 +234,7 @@ file), or run the guarded `/agent-workflow-kit uninstall`.
234
234
  | `/agent-workflow-kit setup [backend]` | opt-in, any time | **link-only** auto-setup of a bridge: places the bundled bridge skill (only into an absent / empty / managed dir — never overwrites an unmanaged one) + links its wrappers onto `PATH` via managed symlinks (idempotent; refuses to clobber a non-symlink; try `--dry-run` to preview). The binary install + the one-time subscription login stay **manual**: it prints the exact **login** command and points the binary install at each bridge's `setup/README.md`. POSIX wrappers — on Windows use WSL. Never commits, never runs a subscription CLI. |
235
235
  | `/agent-workflow-kit status` | any time | **read-only** single view of **versions + deployment + settings + bridges**: which members (kit / memory / engine / the two bridges) are installed and at what version (with an honest "installed on this machine" note when one is behind) and — in a project — what's deployed (`docs/ai`, the version stamps, and the **visibility**: visible / hidden / unclear), and the **feature-spec adoption state** (`not adopted` / `adopting (N draft)` / `adopted (N live, M draft)` / `could not be read`, plus `declined` when recorded), plus your settings (orchestration recipes, attribution, velocity) and the bridges' readiness. The two version axes (package number vs deployment-structure head) stay decoupled. Never writes, never commits, never runs a subscription CLI. |
236
236
  | `/agent-workflow-kit now` | any time | **read-only** answer to where work stands in four blocks: **NOW** names every plan in flight, its phase, current and next step, and the tree evidence; **STEPS** gives every ledger row its evidence-derived status; **QUEUE** folds named rows under their full heading paths and calls position priority only in the judged backlog; **CAMPAIGN** shows the source-size practice figures and config state. The plan's own text never sets a status; a contradicted claim prints beside it. A block that cannot be derived is WITHHELD with its cause; NOW has two halves, plans and tree, each independently a value or WITHHELD, and NOW itself is WITHHELD only when both are. Never writes, never commits, never runs a subscription CLI. |
237
+ | `/agent-workflow-kit tier` | any time | **read-only** guide to the epic, story and task tier: what each level is, and the next step of the walk, with the command that runs it wherever a tool does. Never writes, never commits, never runs a subscription CLI. |
237
238
  | `/agent-workflow-kit recipes` | any time | **read-only** orchestration advisor: presents five named recipes for composing the carriers of a step — the bridges and the full-tool executor subagent — into plan → execute → review — **Solo / Reviewed / Council / Delegated / Subagent** — plans + recommends one for your environment (degrading with a stated reason when a backend isn't ready, or when the executor vehicle is missing/unusable), and offers the choice. The activity/slot registry (six activities) is rendered by `procedures` and by `set-recipe --help`. The orchestrator runs it via the bridge skills or the executor vehicle and **always commits**; the kit never executes a recipe, never runs a subscription CLI, never commits. |
238
239
  | `/agent-workflow-kit procedures <activity>` | any time | **read-only** activity-procedures advisor: prints a named activity's ordered steps (`plan-authoring` / `plan-execution` / `routine` / `feedback-triage` / `epic` / `task`) read **live** from the engine, plus the **resolved recipe per slot** from your `docs/ai/orchestration.json` (agent-writable via `set-recipe`, or hand-edited) + carrier readiness (`plan-authoring`: author, fold, review; default Reviewed when a backend is ready, Council on request; slot-aware incl. Delegated and Subagent) — and, for every dispatched backend, the **full driving contract at the point of use** (exact copy-pasteable invocation, grounding levers like agy's `--facts`/`--decided`, the round-2 `--continue` delta, guarded passthrough), verbatim from the bridge manifests (drift-guarded; each wrapper's `--help` prints the same) — except `feedback-triage`, whose record-bound block carries the check with `--excerpts`, the resolved member lines (ready bridge commands, the ready lens brief, or explicit skipped members), and either the record round-table command for a bridge obligation or the no-receipt fact otherwise: the record is the artifact. `--override <slot>=<value>` adjusts one slot per run. Composes with `recipes`; never writes, never commits, never runs a subscription CLI. |
239
240
  | `/agent-workflow-kit set-recipe` | any time | **config writer** for `docs/ai/orchestration.json`: tell the agent your preference in plain language and it maps it to explicit `--set <activity>.<slot>=<value>` / `--unset` ops; the kit validates, **previews by default**, and writes only on `--write` (deployment-gated, atomic, symlink/TOCTOU-safe), resolving the effective recipe vs live readiness. Writes **only** that file — **never runs a backend or a subagent, never commits**; hand-editing stays fully supported. |
package/SKILL.md CHANGED
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: agent-workflow-kit
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".
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.8.0'
6
+ version: '14.10.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -115,6 +115,10 @@ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/status.md` before actin
115
115
 
116
116
  read-only — read `${CLAUDE_SKILL_DIR}/references/modes/now.md` before acting.
117
117
 
118
+ ### Mode: tier
119
+
120
+ read-only — read `${CLAUDE_SKILL_DIR}/references/modes/tier.md` before acting.
121
+
118
122
  ### Mode: recommendations
119
123
 
120
124
  read-only — read `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md` before acting.
package/bin/install.mjs CHANGED
@@ -551,6 +551,7 @@ const main = async () => {
551
551
  Next — open your agent inside a project and run the skill:
552
552
  • first time in this project -> /agent-workflow-kit
553
553
  • project already has the kit -> /agent-workflow-kit upgrade
554
+ • new to the epic, story and task tier -> /agent-workflow-kit tier
554
555
  • per agent: Claude Code -> /agent-workflow-kit · Devin Local -> /agent-workflow-kit ·
555
556
  Codex -> its /skills menu -> agent-workflow-kit (Codex may also auto-trigger it)
556
557
 
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.8.0",
6
+ "version": "14.10.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.8.0",
3
+ "version": "14.10.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",
@@ -43,7 +43,8 @@ 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** — every entry preview-first, a one-line why each, 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 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
+ - `/agent-workflow-kit tier` — read-only: explains the epic, story and task tier and names the next step of the walk;
47
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`);
48
49
  - `/agent-workflow-kit agents` — cheap-model subagents take the mechanical work (sweeps, changelog skeletons, gate triage; `${CLAUDE_SKILL_DIR}/references/modes/agents.md`);
49
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`);
@@ -0,0 +1,21 @@
1
+ ### Mode: tier
2
+
3
+ <!-- opt-in-capability: none — an inspection surface that configures nothing -->
4
+
5
+ Read-only. The tier guide: what an epic, a story and a task are, and where the walk of this project stands. It writes no file, cache, receipt, lock or git state; it never commits and never runs a subscription CLI.
6
+
7
+ Run `node ${CLAUDE_SKILL_DIR}/tools/tier-guide.mjs --dir <project> --json`.
8
+
9
+ Render the returned envelope compactly in the user's conversational language. Never paste the JSON. Keep every path and every command verbatim, each command on its own line so it runs as printed; translate only the prose around them.
10
+
11
+ Keep this order: the tier text (what each level is, the five story sessions, the two templates, the glossary of their keys, the Recommendations pointer); then every rendered state with its reader, file, cause and next command; then per epic its stage, any placeholder left and the accepted check; then each story in hand with its stage, any placeholder left and its ordered actions; then the epic's own ordered actions (for example the check, the close, or the Cleanup a landed story left over). The first action printed is the next step. Several epics or stories each render; never pick one silently.
12
+
13
+ Exit codes:
14
+
15
+ | Code | Meaning |
16
+ |------|---------|
17
+ | 0 | Anything rendered, every rendered state included. |
18
+ | 1 | Nothing rendered: the `--dir` is not a git work tree; the cause is printed on stderr. |
19
+ | 2 | Usage: an unknown argument, a `--dir` without a value, or `--help` / `-h` combined with anything. |
20
+
21
+ **Invariants:** read-only · every printed command runnable as printed from the project root · compact user-language render with paths and commands verbatim · `process.exitCode`, never `process.exit()`.
@@ -77,7 +77,7 @@ skipping the installed-versions block."* — never a silent skip (Hard Constrain
77
77
  the rest of the report — and the commit gate — proceeds.
78
78
 
79
79
  **Welcome mat — the last line(s) of the footer.** After the version block and the backend-status
80
- line, print *"Run `/agent-workflow-kit help` to see every command."* then **one** recommended next
80
+ line, print *"Run `/agent-workflow-kit help` to see every command."* and *"New to the epic, story and task tier? Run `/agent-workflow-kit tier`."* then **one** recommended next
81
81
  step, chosen **caveat-aware** from signals already in hand (the version block's notes, the settings
82
82
  areas of the same `--json` envelope, and the backend-status line — no new helper call) in this
83
83
  priority order:
@@ -99,7 +99,7 @@ priority order:
99
99
  placed) → the optional *`/agent-workflow-kit agents`* opt-in (never run it without a yes);
100
100
  6. else **gates are declared but the approval hook is not wired** (the envelope's hook settings:
101
101
  at least one declared gate, wired = no) → the optional *`/agent-workflow-kit hook`* opt-in
102
- (never run it without a yes). If no rung applies, the help line above stands alone.
102
+ (never run it without a yes). If no rung applies, the two lines above stand alone.
103
103
 
104
104
  Keep it compact — a few short lines, plain language, no kit-internal terms.
105
105
 
@@ -91,6 +91,13 @@ const CATALOG = [
91
91
  kind: READ_ONLY,
92
92
  oneLine: 'Show where work stands: NOW answers the plan and tree, STEPS the evidence-derived ledger state, QUEUE its named positions, and CAMPAIGN its size-practice figures; the plan’s own text never sets a status.',
93
93
  },
94
+ {
95
+ key: 'tier',
96
+ invocation: invocationOf('tier'),
97
+ group: 'Inspect',
98
+ kind: READ_ONLY,
99
+ oneLine: 'Explain the epic, story and task tier in plain words and name the next step of the walk, with the command that runs it wherever a tool does: the tier guide reads the disk alone.',
100
+ },
94
101
  {
95
102
  key: 'backends',
96
103
  invocation: invocationOf('backends'),
@@ -2,13 +2,24 @@
2
2
  // Phase 2). A LEAF: Node built-ins only, read-only fs (one injectable readdir), no CLI, no side
3
3
  // effects on import — extracted so the procedures read surface lists plans without importing
4
4
  // review-state (whose graph reaches the flow-store write API). review-state re-exports everything
5
- // here, so every existing consumer keeps its import site. Dependency-free, Node >= 22.
5
+ // here except readPlanEntries, so every existing consumer keeps its import site. Dependency-free, Node >= 22.
6
6
 
7
7
  import { readdirSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
 
10
10
  export const PLANS_REL = 'docs/plans';
11
11
 
12
+ // The plans directory's entries as plan-shape-cli and the tier guide read them: absent is an empty
13
+ // list, any other read error is thrown with its code.
14
+ export const readPlanEntries = (cwd) => {
15
+ try {
16
+ return readdirSync(join(cwd, PLANS_REL), { withFileTypes: true });
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') return [];
19
+ throw new Error(`${PLANS_REL} could not be read (${error?.code ?? 'fs error'})`);
20
+ }
21
+ };
22
+
12
23
  // Scratch by the naming convention: EXECUTE-/FEEDBACK-/TASK- prefixes, or a name carrying PROMPT/prompt/
13
24
  // handoff. queue.md is the series index, never a plan.
14
25
  export const isScratchPlanName = (name) =>
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readdirSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
3
+ import { resolve } from 'node:path';
5
4
  import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
6
5
  import { isDirectRun } from './direct-run.mjs';
7
6
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
8
- import { plansInFlight, PLANS_REL } from './plan-files.mjs';
7
+ import { plansInFlight, PLANS_REL, readPlanEntries } from './plan-files.mjs';
9
8
  import { buildFacts, openRepo } from './plan-shape-facts.mjs';
10
9
  import { checkPlan, checkPlanStructure, formatFindings, parseLedger, PLAN_TITLE_PREFIX, verifyPlan } from './plan-shape.mjs';
11
10
 
@@ -21,15 +20,6 @@ const readPlan = (path) => {
21
20
  return result.content;
22
21
  };
23
22
 
24
- const readPlanEntries = (cwd) => {
25
- try {
26
- return readdirSync(join(cwd, PLANS_REL), { withFileTypes: true });
27
- } catch (error) {
28
- if (error?.code === 'ENOENT') return [];
29
- throw new Error(`${PLANS_REL} could not be read (${error?.code ?? 'fs error'})`);
30
- }
31
- };
32
-
33
23
  const getPaths = (text) => {
34
24
  try {
35
25
  const rows = parseLedger(text).rows.filter((row) => row.valid);
@@ -0,0 +1,315 @@
1
+ // spec:tier-guide — docs/ai/specs/kit/tier/tier-guide/index.md and its part guide-stages.md
2
+ import { lstatSync } from 'node:fs';
3
+ import { posix, resolve } from 'node:path';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { resolveGitLocation } from './git-env.mjs';
6
+ import { EPICS_REL, readEpicStore } from './epic-store.mjs';
7
+ import { checkEpic } from './epic-shape.mjs';
8
+ import { QUEUE_PATH, checkClaims, judgeClose, sweepSiblings } from './epic-shape-ledger.mjs';
9
+ import { PLANS_REL, isScratchPlanName, plansInFlight, readPlanEntries } from './plan-files.mjs';
10
+ import { PIN_FILE, readStoryLine } from './plan-shape-ownership.mjs';
11
+ import { PLAN_HEADINGS, checkPlan, isSweep, parseLedger } from './plan-shape.mjs';
12
+ import { buildFacts, openRepo } from './plan-shape-facts.mjs';
13
+ import { newestCheckpoint } from './checkpoint.mjs';
14
+ import { withRepository } from './checkpoint-core.mjs';
15
+ import { parseBrief } from './task-brief.mjs';
16
+ import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mjs';
17
+
18
+ const WORK_TREE = 'work-tree';
19
+ const LF = '\n';
20
+ const MARKDOWN = '.md';
21
+ const INFO = 'info';
22
+ const LANDED = 'landed';
23
+ const EPIC_TYPE = /^type:\s*epic\s*$/;
24
+ const FRONT = '---';
25
+ const CR_END = /\r$/;
26
+ const SPAN = /\{\{([\s\S]*?)\}\}/g;
27
+ const PLAN_STEM = /^[A-Za-z0-9_-]+$/;
28
+ // The brief grammar's Plan field, matched one line at a time as task-brief matches it.
29
+ const PLAN_FIELD = /^Plan: (docs\/plans\/[^/]+\.md)$/;
30
+ const TASK_PREFIX = 'TASK-';
31
+ const TASK_NUMBER = /-T([1-9][0-9]*)\.md$/;
32
+ const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
33
+ const NO_CHECKPOINT = 'no-checkpoint';
34
+ const HEADER_CODES = new Set(['sibling-header', 'sibling-read']);
35
+ const HEAD_ARGS = ['rev-parse', '-q', '--verify', 'HEAD^{commit}'];
36
+ const STAND_IN_EPIC = 'NEW-EPIC';
37
+ const E1_CAUSE = `no epic file in ${EPICS_REL}`;
38
+ const EPIC_TEMPLATE = '../references/authoring/EPIC_TEMPLATE.md';
39
+ const TASK_TEMPLATE = '../references/authoring/TASK_TEMPLATE.md';
40
+ const TEMPLATE_VERBS = new Set(['create', 'modify']);
41
+ const READERS = Object.freeze({ store: 'readEpicStore', sweep: 'sweepSiblings', entries: 'readPlanEntries',
42
+ plans: 'plansInFlight', plan: 'checkPlan', brief: 'parseBrief', sequence: 'newestCheckpoint', head: 'readGit' });
43
+
44
+ const q = shellQuoteArg;
45
+ const action = (kind, text, command = null) => ({ kind, text: escapeForDisplay(text), command });
46
+ const lineOf = (kind, text, parts) => (parts.every(isRenderableLine)
47
+ ? action(kind, text, parts.join(' '))
48
+ : action(kind, `${text} (not printed as a command: ${parts.filter((part) => !isRenderableLine(part)).map(escapeForDisplay).join(', ')} cannot stand on one line)`));
49
+ const makeTools = (toolsDir) => {
50
+ const tool = (name) => q(`${toolsDir}/${name}`);
51
+ const node = (kind, text, name, ...args) => lineOf(kind, text, ['node', tool(name), ...args]);
52
+ return {
53
+ epicCheck: (path, kind = 'run') => node(kind, `check the epic ${path}`, 'epic-shape-cli.mjs', '--check', q(path)),
54
+ review: (path) => node('run', `read the review brief of the epic ${path}; the solo review is the reading of it`, 'epic-shape-cli.mjs', '--review-brief', q(path)),
55
+ close: (path) => node('run', `close the epic ${path}`, 'epic-shape-cli.mjs', '--close', q(path)),
56
+ planCheck: (plan) => node('run', `check the plan ${plan}`, 'plan-shape-cli.mjs', '--check', q(plan)),
57
+ planVerify: (plan) => node('run', `verify the plan ${plan} against the tree`, 'plan-shape-cli.mjs', '--verify', q(plan)),
58
+ mint: (plan) => node('run', `mint a checkpoint of the plan ${plan}`, 'checkpoint.mjs', 'mint', '--plan', q(plan)),
59
+ prune: (plan) => node('run', `prune the checkpoints of the plan ${plan}`, 'checkpoint.mjs', 'prune', '--plan', q(plan)),
60
+ stamp: (brief) => node('run', `stamp the brief ${brief}`, 'task-brief.mjs', 'stamp', q(brief)),
61
+ check: (brief) => node('run', `check the brief ${brief}`, 'task-brief.mjs', 'check', q(brief)),
62
+ reviewState: () => node('run', 'check the review state of the tree', 'review-state.mjs', '--check'),
63
+ copy: (template, target, text) => lineOf('write', text, ['cp', q(resolve(toolsDir, template)), q(target)]),
64
+ };
65
+ };
66
+ const state = (reader, file, cause, next = null) => ({ reader, file, cause: escapeForDisplay(cause), next });
67
+ const findSpans = (file, text) => [...text.matchAll(SPAN)].map(([, span]) => ({ file, span: escapeForDisplay(span) }));
68
+ const readText = (cwd, path) => {
69
+ const result = readRegularFileNoFollow(resolve(cwd, path));
70
+ return result.outcome === 'ok' ? { text: result.content } : { cause: `${result.outcome}: ${result.code ?? result.className ?? 'unreadable'}` };
71
+ };
72
+ const isPresent = (cwd, path) => lstatSync(resolve(cwd, path), { throwIfNoEntry: false }) !== undefined;
73
+ // The lines of a file as the kit's readers see them: a CRLF file reads as its LF twin.
74
+ const linesOf = (text) => text.split(LF).map((line) => line.replace(CR_END, ''));
75
+ const declaresEpic = (text) => {
76
+ const lines = linesOf(text);
77
+ if (lines[0] !== FRONT) return false;
78
+ const end = lines.indexOf(FRONT, 1);
79
+ return end > 0 && lines.slice(1, end).some((line) => EPIC_TYPE.test(line));
80
+ };
81
+ const stripPath = (finding) => (finding.message.startsWith(`${finding.path}: `) ? finding.message.slice(finding.path.length + 2) : finding.message);
82
+
83
+ const readHead = (cwd, env) => withRepository(cwd, { env }, (context) => {
84
+ const result = context.run(HEAD_ARGS, context.top, undefined, context.env);
85
+ const stdout = String(result.stdout ?? '').trim();
86
+ if (result.status === 0 && OID.test(stdout)) return { ok: true, oid: stdout };
87
+ if (result.status === 1 && stdout === '' && !result.error) return { ok: true, oid: null };
88
+ return { ok: false, reason: String(result.stderr ?? result.error?.message ?? 'HEAD could not be read').trim() };
89
+ });
90
+
91
+ const readStore = (cwd) => {
92
+ const store = readEpicStore(cwd);
93
+ if (store.outcome === 'refused') return { states: [state(READERS.store, EPICS_REL, store.reason)], entries: [], epics: [], blocked: true };
94
+ const entries = store.entries ?? [];
95
+ const states = entries.filter((entry) => entry.outcome === 'unreadable' || entry.outcome === 'non-regular')
96
+ .map((entry) => state(READERS.store, `${EPICS_REL}/${entry.name}`, `${entry.outcome}: ${entry.reason ?? entry.kind}`));
97
+ const sweep = sweepSiblings(entries, EPICS_REL);
98
+ const headerFindings = sweep.findings.filter((finding) => HEADER_CODES.has(finding.code));
99
+ const refused = new Set(headerFindings.map((finding) => finding.path));
100
+ const epics = entries.filter((entry) => typeof entry.text === 'string' && entry.name.endsWith(MARKDOWN)
101
+ && !refused.has(`${EPICS_REL}/${entry.name}`) && declaresEpic(entry.text));
102
+ const siblingStates = headerFindings.filter((finding) => finding.code !== 'sibling-read');
103
+ return { entries, epics, headerFindings, skipped: sweep.counts.skipped, blocked: false,
104
+ states: [...states, ...siblingStates.map((finding) => ({ finding }))] };
105
+ };
106
+
107
+ const judgeEpic = (entries, entry) => {
108
+ const path = `${EPICS_REL}/${entry.name}`;
109
+ const others = entries.filter((other) => other.name !== entry.name);
110
+ const shape = checkEpic(entry.text, path);
111
+ const sweep = sweepSiblings(others, EPICS_REL);
112
+ const claims = shape.findings.length === 0 && sweep.ok ? checkClaims([shape.epic, ...sweep.epics]) : { findings: [] };
113
+ const findings = [...shape.findings, ...sweep.findings, ...claims.findings];
114
+ return { path, others, epic: shape.epic, accepted: !findings.some((finding) => finding.severity !== INFO) };
115
+ };
116
+
117
+ const planOf = (text) => linesOf(text).map((line) => PLAN_FIELD.exec(line)?.[1]).find(Boolean) ?? null;
118
+ const planShape = (row, epicId) => `The shape: the title # Plan: and a name; the five headings; under Goal and boundary the line Story: ${row.id} of ${epicId} and a governing spec path under docs/ai/specs or the words not adopted; one ledger row per file in six fields (id, verb, path, responsibility, budget, anchor) — a create or modify row an anchor that resolves to one place and, with no declared source-size cap, the budget n/a, a delete row a dash (—) in budget and anchor; the total line last, its before figure the current lines of every deleted file and of every modify row with a numeric budget, so total: 0 → 0 lines when every row is a create or modify with the budget n/a; at least one - bullet under Verification.`;
119
+
120
+ // The Story line is read under Goal and boundary alone, as plan-shape reads it.
121
+ const storyOf = (text) => {
122
+ const { document } = parseLedger(text);
123
+ const goal = document.headings.find((heading) => heading.text === PLAN_HEADINGS[0]);
124
+ const end = goal && document.headings.find((heading) => heading.index > goal.index && heading.level <= 2);
125
+ const lines = goal ? document.lines.slice(goal.index + 1, end?.index).map((line, offset) => ({ text: line, line: goal.index + 2 + offset })) : [];
126
+ return readStoryLine(lines);
127
+ };
128
+ const readPlan = (cwd, tools, path) => {
129
+ const read = readText(cwd, path);
130
+ if (read.text === undefined) return { path, text: null, story: null, failure: state(READERS.plans, path, read.cause) };
131
+ try {
132
+ const { story, findings } = storyOf(read.text);
133
+ if (findings.length) return { path, text: read.text, story: null, failure: state(READERS.plan, path, findings[0].message, tools.planCheck(path)) };
134
+ return { path, text: read.text, story, failure: null };
135
+ } catch (error) {
136
+ return { path, text: read.text, story: null, failure: state(READERS.plan, path, error.message, tools.planCheck(path)) };
137
+ }
138
+ };
139
+
140
+ const readPlans = (cwd, tools) => {
141
+ try {
142
+ const listing = readPlanEntries(cwd);
143
+ const names = plansInFlight(cwd, () => listing);
144
+ const plans = names.map((name) => readPlan(cwd, tools, `${PLANS_REL}/${name}`));
145
+ const briefs = listing.filter((entry) => entry.isFile() && entry.name.startsWith(TASK_PREFIX) && entry.name.endsWith(MARKDOWN))
146
+ .map((entry) => {
147
+ const path = `${PLANS_REL}/${entry.name}`;
148
+ const read = readText(cwd, path);
149
+ return { path, name: entry.name, text: read.text ?? '', cause: read.cause ?? null, plan: planOf(read.text ?? ''),
150
+ number: Number(TASK_NUMBER.exec(entry.name)?.[1] ?? 1) };
151
+ });
152
+ return { ok: true, plans, briefs, states: plans.filter((plan) => plan.failure).map((plan) => plan.failure) };
153
+ } catch (error) {
154
+ return { ok: false, state: state(READERS.entries, PLANS_REL, error.message) };
155
+ }
156
+ };
157
+
158
+ const readBinding = (brief) => {
159
+ if (brief.cause) return { refused: brief.cause };
160
+ const parsed = parseBrief(brief.text);
161
+ if (!parsed.ok) return { refused: parsed.reason };
162
+ if (parsed.unclosed || parsed.blocks.length > 1) return { refused: 'binding: the block count is not one closed block', binding: true };
163
+ if (parsed.blocks.length === 0) return { parsed, bound: false };
164
+ const value = (() => { try { return JSON.parse(parsed.blocks[0].source); } catch { return null; } })();
165
+ const isOid = (field) => typeof field === 'string' && OID.test(field);
166
+ if (!isOid(value?.checkpoint) || !isOid(value?.head)) return { refused: 'binding: the block carries no checkpoint and head', binding: true };
167
+ return { parsed, bound: true, checkpoint: value.checkpoint, head: value.head };
168
+ };
169
+
170
+ const judgeStory = (context, epicPath, epicId, row) => {
171
+ const { cwd, tools, plans } = context;
172
+ const own = plans.plans.filter((plan) => plan.story?.id === row.id && plan.story?.epic === epicId);
173
+ const stateOf = (stage, actions, extra = {}) => ({ story: { id: row.id, plan: extra.plan ?? null, stage, residual: extra.residual ?? [], actions } });
174
+ if (own.length > 1) return { state: state(READERS.plans, own[0].path, `story ${row.id} of ${epicId} has ${own.length} plans: ${own.map((plan) => plan.path).join(', ')}`) };
175
+ if (own.length === 0) {
176
+ if (plans.states.length) return {};
177
+ const stem = `${epicId}-${row.id}`;
178
+ const standIn = `${PLANS_REL}/${stem}${MARKDOWN}`;
179
+ if (!PLAN_STEM.test(stem) || isScratchPlanName(`${stem}${MARKDOWN}`)) {
180
+ return stateOf('S1', [tools.review(epicPath), action('write', `author the plan by hand under ${PLANS_REL} at a name of ASCII letters, digits, dashes and underscores with no EXECUTE-, FEEDBACK- or TASK- prefix and no PROMPT, prompt or handoff, since the epic id and story id make no such name. ${planShape(row, epicId)}`)]);
181
+ }
182
+ if (isPresent(cwd, standIn)) return { state: state(READERS.plans, standIn, `the plan stand-in path is taken by a file carrying no Story line ${row.id} of ${epicId} under Goal and boundary: move the line there, or rename or remove the file`) };
183
+ return stateOf('S1', [tools.review(epicPath), action('write', `author the plan by hand at ${standIn}. ${planShape(row, epicId)}`)]);
184
+ }
185
+ const plan = own[0];
186
+ try {
187
+ return judgePlan(context, epicPath, row, plan, stateOf);
188
+ } catch (error) {
189
+ return { state: state(READERS.plan, plan.path, error.message, tools.planCheck(plan.path)) };
190
+ }
191
+ };
192
+
193
+ const judgePlan = (context, epicPath, row, plan, stateOf) => {
194
+ const { cwd, env, tools, plans, head, today } = context;
195
+ const stem = posix.basename(plan.path, MARKDOWN);
196
+ const standIn = (number) => `${PLANS_REL}/${TASK_PREFIX}${stem}-T${number}${MARKDOWN}`;
197
+ const taken = plans.briefs.find((brief) => brief.plan !== null && brief.plan !== plan.path && brief.path === standIn(brief.number));
198
+ if (taken) return { state: state(READERS.brief, taken.path, `the brief stand-in path is taken by a brief of ${taken.plan}: rename or remove it`) };
199
+ const briefs = plans.briefs.filter((brief) => brief.plan === plan.path || (brief.plan === null && brief.path === standIn(brief.number)));
200
+ const judged = briefs.map((brief) => ({ ...brief, ...readBinding(brief) }));
201
+ const refusedBrief = judged.find((brief) => brief.refused);
202
+ if (refusedBrief) return { state: state(READERS.brief, refusedBrief.path, refusedBrief.refused,
203
+ refusedBrief.binding ? tools.check(refusedBrief.path) : tools.stamp(refusedBrief.path)) };
204
+ const top = Math.max(0, ...judged.map((brief) => brief.number));
205
+ const inHandAll = judged.filter((brief) => brief.number === top);
206
+ if (inHandAll.length > 1) return { state: state(READERS.brief, inHandAll[0].path, `briefs ${inHandAll.map((brief) => brief.path).join(', ')} carry one task number`) };
207
+ const inHand = inHandAll[0];
208
+ const rows = parseLedger(plan.text).rows.filter((ledgerRow) => ledgerRow.valid);
209
+ const briefed = new Set(judged.map((brief) => brief.parsed.rowId));
210
+ const unbriefed = rows.find((ledgerRow) => !briefed.has(ledgerRow.id));
211
+ const residual = judged.filter((brief) => brief.bound).flatMap((brief) => findSpans(brief.path, brief.text));
212
+ const extra = { plan: plan.path, residual };
213
+ const newest = newestCheckpoint(cwd, plan.path, { env });
214
+ const noHead = () => ({ state: state(READERS.head, null, 'HEAD is unborn: no commit to compare the brief with') });
215
+ const copyAction = (ledgerRow, number) => (ledgerRow && TEMPLATE_VERBS.has(ledgerRow.verb) && !isSweep(ledgerRow.path) && posix.basename(ledgerRow.path) !== PIN_FILE
216
+ ? [tools.copy(TASK_TEMPLATE, standIn(number), `copy the task template to ${standIn(number)}`),
217
+ action('write', `fill every key of the brief template's closed list in ${standIn(number)}, Row: ${ledgerRow.id}`), tools.stamp(standIn(number))]
218
+ : [action('write', `write the brief of row ${ledgerRow?.id ?? 'none'} by hand at ${standIn(number)} per the brief grammar (checkpoint, task-brief); the task template does not fit it`), tools.stamp(standIn(number))]);
219
+ if (!newest.ok && !String(newest.reason).startsWith(NO_CHECKPOINT)) return { state: state(READERS.sequence, plan.path, newest.reason) };
220
+ if (!newest.ok) {
221
+ if (!judged.some((brief) => brief.bound)) {
222
+ const paths = rows.flatMap((ledgerRow) => [ledgerRow.path, ledgerRow.anchorPath].filter(Boolean));
223
+ const result = checkPlan(plan.text, buildFacts(cwd, { paths, repo: openRepo(cwd) }));
224
+ if (result.findings.length) return { state: state(READERS.plan, plan.path, result.findings[0].message, tools.planCheck(plan.path)) };
225
+ return stateOf('S2', [tools.planCheck(plan.path), tools.mint(plan.path)], extra);
226
+ }
227
+ if (head.oid === null) return noHead();
228
+ if (!unbriefed && inHand.bound && inHand.head !== head.oid) {
229
+ return stateOf('S8', [action('write', `edit the story row ${row.id} of ${epicPath} to read state: landed ${today}`)], extra);
230
+ }
231
+ return { state: state(READERS.sequence, plan.path, 'the sequence was pruned before the story was done', tools.mint(plan.path)) };
232
+ }
233
+ if (!inHand) return stateOf('S3', copyAction(rows[0], 1), extra);
234
+ if (!inHand.bound) return stateOf('S4', [tools.stamp(inHand.path)], extra);
235
+ if (head.oid === null) return noHead();
236
+ if (inHand.head !== head.oid) {
237
+ return stateOf('S7', unbriefed ? [tools.mint(plan.path), ...copyAction(unbriefed, top + 1)]
238
+ : [tools.planVerify(plan.path), tools.prune(plan.path)], extra);
239
+ }
240
+ if (inHand.checkpoint !== newest.oid) return stateOf('S5', [tools.stamp(inHand.path)], extra);
241
+ const files = inHand.parsed.files.map((file) => file.path);
242
+ return stateOf('S6', [tools.check(inHand.path),
243
+ action('write', `bring the Files of ${inHand.path} to the row's post-state, the test first, until every Acceptance line is green`),
244
+ ...inHand.parsed.acceptance.map(([command]) => lineOf('run', 'run the Acceptance command of the brief', [command])),
245
+ tools.reviewState(),
246
+ lineOf('run', 'stage the Files of the brief', ['git', '--literal-pathspecs', 'add', '--', ...files.map(q)]),
247
+ lineOf('run', 'commit the Files of the brief', ['git', 'commit', '-m', q(inHand.parsed.title)])], extra);
248
+ };
249
+
250
+ const cleanupOf = (context, epicId, rows) => context.plans.plans
251
+ .filter((plan) => plan.story?.epic === epicId && rows.some((row) => row.id === plan.story.id && row.state === LANDED))
252
+ .flatMap((plan) => {
253
+ const newest = newestCheckpoint(context.cwd, plan.path, { env: context.env });
254
+ const prune = !newest.ok && String(newest.reason).startsWith(NO_CHECKPOINT) ? [] : [context.tools.prune(plan.path)];
255
+ return [...prune, action('write', `remove the plan ${plan.path} and its briefs: the Cleanup of a landed story`)];
256
+ });
257
+
258
+ const judgeEntry = (context, store, entry) => {
259
+ const judged = judgeEpic(store.entries, entry);
260
+ const { tools } = context;
261
+ const id = posix.basename(judged.path, MARKDOWN);
262
+ const base = { stage: 'E2', id, path: judged.path, cause: null, fact: null, residual: [], actions: [tools.epicCheck(judged.path)], stories: [] };
263
+ if (!judged.accepted) return { entry: base, states: [] };
264
+ const { epic } = judged;
265
+ const residual = findSpans(judged.path, entry.text);
266
+ const accepted = { ...base, residual };
267
+ if (epic.fields.state === LANDED) return { entry: { ...accepted, stage: 'E5', actions: cleanupOf(context, id, epic.rows) }, states: [] };
268
+ const landed = new Set(epic.rows.filter((row) => row.state === LANDED).map((row) => row.id));
269
+ const inHand = epic.rows.filter((row) => row.state !== LANDED && row.dependsOn.every((dependency) => landed.has(dependency)));
270
+ if (inHand.length === 0) {
271
+ const queueRead = readRegularFileNoFollow(resolve(context.cwd, QUEUE_PATH));
272
+ const queue = queueRead.outcome === 'ok' ? queueRead.content : { outcome: queueRead.outcome, reason: queueRead.code ?? queueRead.className };
273
+ const closed = judgeClose({ epic: entry.text, path: judged.path, queue, entries: judged.others });
274
+ const edits = closed.findings.filter((finding) => finding.severity !== INFO).map((finding) => (finding.code === 'close-result'
275
+ ? action('write', `add the line Result line: ${context.today} under ## Acceptance of ${judged.path}`)
276
+ : action('write', finding.code === 'close-queue' ? `delete the queue row: ${stripPath(finding)}` : stripPath(finding))));
277
+ return { entry: { ...accepted, stage: 'E4', actions: [...edits, tools.close(judged.path), ...cleanupOf(context, id, epic.rows)] }, states: [] };
278
+ }
279
+ const stories = inHand.map((row) => judgeStory(context, judged.path, id, row));
280
+ return { entry: { ...accepted, stage: 'E3', fact: tools.epicCheck(judged.path, 'fact'), actions: cleanupOf(context, id, epic.rows),
281
+ stories: stories.filter((story) => story.story).map((story) => story.story) }, states: stories.filter((story) => story.state).map((story) => story.state) };
282
+ };
283
+
284
+ const judgeFirstEpic = (context, store) => {
285
+ const standIn = `${EPICS_REL}/${STAND_IN_EPIC}${MARKDOWN}`;
286
+ if (isPresent(context.cwd, standIn)) return { states: [state(READERS.store, standIn, 'the epic stand-in path is taken by a file that is not an epic: rename or remove it')], entries: [] };
287
+ return { states: [], entries: [{ stage: 'E1', id: null, path: null, cause: E1_CAUSE, fact: null, residual: [], actions: [
288
+ action('write', `create the epic store directory ${EPICS_REL}`, `mkdir -p ${EPICS_REL}`),
289
+ context.tools.copy(EPIC_TEMPLATE, standIn, `copy the epic template to ${standIn}`),
290
+ action('write', `fill every key of the epic template's closed list in ${standIn}; EPIC_ID is ${STAND_IN_EPIC}, the file stem`),
291
+ context.tools.epicCheck(standIn)], stories: [] }] };
292
+ };
293
+
294
+ export const readTierFacts = ({ cwd, env, toolsDir, today }) => {
295
+ const location = resolveGitLocation(cwd, { env });
296
+ if (location.state !== WORK_TREE) return { location, states: [], entries: [], skipped: 0 };
297
+ const store = readStore(cwd);
298
+ const tools = makeTools(toolsDir);
299
+ const firstEpic = store.epics[0] ? `${EPICS_REL}/${store.epics[0].name}` : null;
300
+ const storeStates = store.states.map((item) => (item.finding
301
+ ? state(READERS.sweep, item.finding.path, stripPath(item.finding), firstEpic ? tools.epicCheck(firstEpic) : null) : item));
302
+ if (store.blocked) return { location, states: storeStates, entries: [], skipped: 0 };
303
+ const plans = readPlans(cwd, tools);
304
+ const head = readHead(cwd, env);
305
+ const headStates = head.ok ? [] : [state(READERS.head, null, head.reason)];
306
+ if (!plans.ok) return { location, states: [...storeStates, plans.state, ...headStates], entries: [], skipped: store.skipped };
307
+ const context = { cwd, env, tools, plans, head: head.ok ? head : { oid: null }, today };
308
+ if (store.epics.length === 0) {
309
+ const first = store.headerFindings.length ? { states: [], entries: [] } : judgeFirstEpic(context, store);
310
+ return { location, states: [...storeStates, ...plans.states, ...headStates, ...first.states], entries: first.entries, skipped: store.skipped };
311
+ }
312
+ const judged = store.epics.map((entry) => judgeEntry(context, store, entry));
313
+ return { location, states: [...storeStates, ...plans.states, ...headStates, ...judged.flatMap((item) => item.states)],
314
+ entries: judged.map((item) => item.entry), skipped: store.skipped };
315
+ };
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // spec:tier-guide — docs/ai/specs/kit/tier/tier-guide/index.md
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
6
+ import { isDirectRun } from './direct-run.mjs';
7
+ import { shellQuoteArg } from './repo-lex.mjs';
8
+ import { readTierFacts } from './tier-guide-facts.mjs';
9
+
10
+ const SCHEMA = 1;
11
+ const TOOLS_DIR = dirname(fileURLToPath(import.meta.url));
12
+ const INDENT = ' ';
13
+ const HELP = `tier — read-only: what the epic, story and task tier is, and the next step of the walk.
14
+
15
+ Usage:
16
+ node tier-guide.mjs [--dir PROJECT] [--json]
17
+
18
+ --dir the project to read (default: the current directory).
19
+ --json the JSON envelope instead of the plain text.
20
+ --help answered only when it is the whole invocation.
21
+
22
+ Exit codes: 0 anything rendered, a rendered state included; 1 nothing rendered (a --dir that is not a
23
+ git work tree); 2 usage.
24
+
25
+ Reads only: no store, no receipt, no cache, no lock.`;
26
+
27
+ const EPIC_KEYS = [
28
+ ['DATE', 'the day the epic was last updated, YYYY-MM-DD.'],
29
+ ['EPIC_TITLE', 'the epic in a few plain words.'],
30
+ ['INTENT', 'what the epic sets out to change.'],
31
+ ['VALUE', 'what a user gains once every story has landed.'],
32
+ ['NON_GOALS', 'what the epic will not do.'],
33
+ ['ACCEPTANCE', 'how anyone can tell the epic is done.'],
34
+ ['SPECS', 'the contracts the epic governs, or none.'],
35
+ ['STORY_NAME', 'the first story in a few plain words.'],
36
+ ['OWNED_PATHS', 'the files the first story owns, comma separated.'],
37
+ ['EPIC_ID', 'the epic file\'s own stem; the Queue line must equal it.'],
38
+ ['QUEUE_BUCKET', 'the queue section the epic\'s row stands in.'],
39
+ ];
40
+ const TASK_KEYS = [
41
+ ['TASK_NAME', 'the task in a few plain words; the commit title.'],
42
+ ['STORY_ID', 'the story the task serves: S1, S2 and so on.'],
43
+ ['EPIC_ID', 'the epic the story belongs to, its file stem.'],
44
+ ['PLAN_PATH', 'the plan under docs/plans that carries the row.'],
45
+ ['ROW_ID', 'the ledger row the task carries.'],
46
+ ['TEST_PATH', 'the test file of the row, written first.'],
47
+ ['MODULE_PATH', 'the module file of the row.'],
48
+ ['ACCEPTANCE_COMMAND', 'a command line that proves the task done.'],
49
+ ['EXPECTED_OUTCOME', 'what that command answers when the task is done.'],
50
+ ['NEGATIVE_CASE', 'a case the task must not break.'],
51
+ ['NEGATIVE_OUTCOME', 'what stays true in that case.'],
52
+ ['TEST_MAX_LINES', 'the line budget of the test file.'],
53
+ ['MODULE_MAX_LINES', 'the line budget of the module file.'],
54
+ ];
55
+ const renderTier = (toolsDir, cwd) => [
56
+ 'An epic is one outcome too large for one plan, split into stories in its ledger under docs/ai/epics.',
57
+ 'A story is one row of an epic\'s ledger, carried by one plan under docs/plans.',
58
+ 'A task is one row of a story\'s plan, carried by one brief bound to a checkpoint.',
59
+ 'A story runs as five sessions: spec, plan, tests, code, diff review + release + record.',
60
+ `The epic template: ${resolve(toolsDir, '../references/authoring/EPIC_TEMPLATE.md')}`,
61
+ `The task template: ${resolve(toolsDir, '../references/authoring/TASK_TEMPLATE.md')}`,
62
+ 'Epic template keys:',
63
+ ...EPIC_KEYS.map(([key, text]) => `${INDENT}${key}: ${text}`),
64
+ 'Task template keys:',
65
+ ...TASK_KEYS.map(([key, text]) => `${INDENT}${key}: ${text}`),
66
+ 'What the setup still lacks against the reference profile is the Recommendations screen:',
67
+ `node ${shellQuoteArg(`${toolsDir}/recommendations.mjs`)} --cwd ${shellQuoteArg(cwd)}`,
68
+ ];
69
+
70
+ const valueAt = (argv, index, flag) => {
71
+ const value = argv[index + 1];
72
+ if (value === undefined || value === '' || value.startsWith('-')) throw fail(2, `${flag} takes a value`);
73
+ return value;
74
+ };
75
+ const parseArgv = (argv) => {
76
+ if (argv.includes('--help') || argv.includes('-h')) {
77
+ if (argv.length !== 1) throw fail(2, '--help is answered only when it is the whole invocation');
78
+ return { help: true };
79
+ }
80
+ return argv.reduce((parsed, arg, index) => {
81
+ if (parsed.skip) return { ...parsed, skip: false };
82
+ if (arg === '--dir') return { ...parsed, dir: valueAt(argv, index, arg), skip: true };
83
+ if (arg === '--json') return { ...parsed, json: true };
84
+ throw fail(2, `unknown argument "${arg}" — run with --help`);
85
+ }, { help: false, dir: '.', json: false, skip: false });
86
+ };
87
+
88
+ const renderActions = (actions, indent) => actions.flatMap((item, index) =>
89
+ [`${indent}${index + 1}. ${item.text}`, ...(item.command === null ? [] : [item.command])]);
90
+ const renderResidual = (residual, indent) => residual.map(({ file, span }) => `${indent}placeholder ${span} in ${file}`);
91
+ const renderEntry = (entry) => [
92
+ `${INDENT}epic ${entry.id ?? '-'} ${entry.path ?? '-'} - stage ${entry.stage}`,
93
+ ...(entry.cause ? [`${INDENT}${entry.cause}`] : []),
94
+ ...renderResidual(entry.residual, INDENT),
95
+ ...(entry.fact ? [`${INDENT}accepted: ${entry.fact.text}`, entry.fact.command] : []),
96
+ ...entry.stories.flatMap((story) => [`${INDENT}story ${story.id} - stage ${story.stage}`,
97
+ ...renderResidual(story.residual, INDENT + INDENT), ...renderActions(story.actions, INDENT + INDENT)]),
98
+ ...renderActions(entry.actions, INDENT),
99
+ ];
100
+ const renderPlain = (tier, facts) => [
101
+ 'TIER', ...tier,
102
+ ...(facts.states.length ? ['STATES', ...facts.states.flatMap((item) => [
103
+ `${INDENT}${item.reader}${item.file === null ? '' : ` ${item.file}`}: ${item.cause}`,
104
+ ...(item.next ? [`${INDENT}${INDENT}next: ${item.next.text}`, ...(item.next.command === null ? [] : [item.next.command])] : [])])] : []),
105
+ `EPICS (entries skipped: ${facts.skipped})`, ...facts.entries.flatMap(renderEntry),
106
+ ].join('\n');
107
+
108
+ export const main = (argv, io = {}) => {
109
+ const log = io.log ?? console.log;
110
+ const error = io.error ?? console.error;
111
+ const parsed = (() => {
112
+ try { return parseArgv(argv); } catch (err) { error(err.message); return null; }
113
+ })();
114
+ if (parsed === null) return 2;
115
+ if (parsed.help) { log(HELP); return 0; }
116
+ const cwd = resolve(io.cwd ?? process.cwd(), parsed.dir);
117
+ const env = { ...(io.env ?? process.env), GIT_OPTIONAL_LOCKS: '0' };
118
+ const today = io.today ?? new Date().toISOString().slice(0, 10);
119
+ const facts = readTierFacts({ cwd, env, toolsDir: TOOLS_DIR, today });
120
+ if (facts.location.state !== 'work-tree') {
121
+ error(`${cwd} is not a git work tree (${facts.location.state}: ${facts.location.cause})`);
122
+ return 1;
123
+ }
124
+ const tier = renderTier(TOOLS_DIR, cwd);
125
+ log(parsed.json ? JSON.stringify({ schema: SCHEMA, command: 'tier', dir: cwd, tier,
126
+ states: facts.states, entries: facts.entries, skipped: facts.skipped }, null, 2) : renderPlain(tier, facts));
127
+ return 0;
128
+ };
129
+
130
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));