@sabaiway/agent-workflow-kit 14.7.0 → 14.9.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,70 @@ 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.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)
8
+
9
+ **`/agent-workflow-kit tier` answers "what is this tier, and what do I do next?" from the disk alone.** Until 14.9.0
10
+ every tool of the epic, story and task tier worked, but nothing said what the tier is or which step comes next, and
11
+ two refusals of a solo walk had their remedy only in a contract. The new guide prints a fixed text — an epic, a story
12
+ and a task in one sentence each, the five story sessions, both templates by their installed paths, every key of both
13
+ templates with one sentence each, and the Recommendations command — then, per epic, its stage and the ordered steps
14
+ left, the first being the next one. Each step that runs a tool prints its command on its own line, ready to run from
15
+ the project root; an authoring step (copy a template, fill it, edit a row) is named in words. Story S3 of the epic
16
+ `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`; contract `docs/ai/specs/kit/tier/tier-guide/` (new), with
17
+ `docs/ai/specs/kit/tier/tier-walk.md` at revision 3.
18
+
19
+ - **The stages.** Per epic: E1 no epic yet, E2 the epic to fix, E3 open with a story in hand, E4 every story landed
20
+ and the epic to close, E5 landed. Per story in hand: S1 no plan, S2 a plan and no checkpoint, S3 a checkpoint and
21
+ no brief, S4 a brief with no binding, S5 a brief bound to an older checkpoint, S6 the task in hand, S7 the brief
22
+ done, S8 the checkpoints pruned and the story to land. A plan left over from a landed story is named as its
23
+ Cleanup: the checkpoint prune where one exists, then the removal of the plan and its briefs.
24
+ - **Nothing is guessed.** An unreadable epic file or plan, a plan that does not parse, a second plan for one story, a
25
+ brief the grammar refuses, a malformed binding block, a checkpoint the reader refuses: each is printed as a state
26
+ with its reader, file and cause, and the guide still exits 0; an epic its check refuses is stage E2, with the
27
+ check command. A `{{KEY}}` placeholder left in an accepted epic or a
28
+ stamped brief is named with its file before the stage's steps. Several epics or stories in hand each render.
29
+ - **Read-only, like `now`.** `node tools/tier-guide.mjs [--dir <project>] [--json]`; `--help` only as the whole
30
+ invocation. Exit 0 anything rendered, 1 the directory is not a git work tree, 2 usage. `--json` prints the same
31
+ facts as an envelope `{ schema, command, dir, tier, states, entries, skipped }`, each step carrying its text, its
32
+ kind (`run`, `write` or `fact`) and its command or null. It writes no file and no git state.
33
+ - **Where to find it.** The mode `tier` (mode doc `references/modes/tier.md`), one Inspect entry in the command
34
+ catalog (so `/agent-workflow-kit help` lists it) and the `### Mode: tier` header in `SKILL.md`. The other places
35
+ that should mention it (the help's own text, the readmes, the welcome screen, the install's closing lines) come
36
+ with the epic's next story.
37
+ - **Internals.** `readPlanEntries` moves from `tools/plan-shape-cli.mjs` into `tools/plan-files.mjs`;
38
+ `plan-shape-cli` and the guide's facts module import it. The repository walk fixture `test/tier-walk-e2e.test.mjs`
39
+ now reads the guide at every stage, and every tool step but the designed refusal cells is a line the guide
40
+ printed, run through `/bin/sh` unchanged. The package-content pin names the two tools and the mode doc, and the
41
+ tarball holds 336 files. The guide's suites do not ship.
42
+
43
+ ## 14.8.0 — the kit ships an epic template and a task-brief template, each accepted by its checker once filled in (AD-154)
44
+
45
+ **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
46
+ two shapes lived only in the planning reference and in what `epic-shape-cli` and `task-brief` refuse. The kit now
47
+ ships `references/authoring/EPIC_TEMPLATE.md` and `references/authoring/TASK_TEMPLATE.md` in its skill home. Copy one
48
+ from there into your project, replace every `{{KEY}}` placeholder with a value its checker admits and change no
49
+ other byte: the epic, placed at `docs/ai/epics/<EPIC_ID>.md`, passes `epic-shape-cli --check`; the brief, placed at
50
+ `docs/plans/TASK-<stem>-T<n>.md` beside a plan that carries the same Story line and the named row, with a checkpoint
51
+ minted, is stamped by `task-brief stamp` and passes `task-brief check`. A template copied and left unedited is
52
+ refused: the epic by the check, the brief by the stamp.
53
+ Story S2 of the epic `KIT-USERS-LEARN-THE-TIER-AND-WALK-IT`; contract `docs/ai/specs/kit/tier/tier-templates.md`
54
+ (new), with `docs/ai/specs/kit/tier/tier-walk.md` at revision 2.
55
+
56
+ - **Two templates, one placeholder form, a closed key list each.** The epic template carries eleven keys (`DATE`,
57
+ `EPIC_TITLE`, `INTENT`, `VALUE`, `NON_GOALS`, `ACCEPTANCE`, `SPECS`, `STORY_NAME`, `OWNED_PATHS`, `EPIC_ID`,
58
+ `QUEUE_BUCKET`) and one story row; the brief template carries thirteen keys and one task with one test file and one
59
+ module. Neither carries guidance prose: a template is the shape and its placeholders only. Where a grammar leaves a
60
+ choice, the template makes it: the epic opens `state: open` with `S1` planned, `depends-on: none` and
61
+ `shared: none`; the brief groups `test, impl` and reads the plan alone. For more stories, files or cases, repeat
62
+ the line in the form its checker takes: the next story id, and a `## Budget` line for each added file.
63
+ - **Not deployed into a project.** The directory sits outside `references/templates/`, so neither `bootstrap` nor
64
+ `upgrade` copies anything from it into `docs/ai/`; `init` places it in the kit's skill home only. Nothing in the
65
+ kit points at the templates yet; the guide that explains each key comes with a later story of the same epic.
66
+ - **Internals.** The repository walk fixture `test/tier-walk-e2e.test.mjs` now renders its epic and brief from the
67
+ installed templates, pins each template's bytes to a literal text, and checks that an unedited copy is refused; its
68
+ ground helpers move to `test/tier-walk-harness.test.mjs`. The package-content pin names both files, and the tarball
69
+ holds 333 files. Neither test file ships.
70
+
7
71
  ## 14.7.0 — an epic closes in a project with no queue file, every task-brief refusal says what failed, and the missing-section offer is a command you can run (AD-153)
8
72
 
9
73
  **A project outside this repo can now walk one epic, one story and one task to the end, solo.** A walk through the
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".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '14.7.0'
6
+ version: '14.9.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/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.7.0",
6
+ "version": "14.9.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.7.0",
3
+ "version": "14.9.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,32 @@
1
+ ---
2
+ type: epic
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: 90d
6
+ owner: none
7
+ maxLines: 60
8
+ state: open
9
+ ---
10
+
11
+ # Epic: {{EPIC_TITLE}}
12
+
13
+ ## Intent
14
+ {{INTENT}}
15
+
16
+ ## Value
17
+ {{VALUE}}
18
+
19
+ ## Non-goals
20
+ {{NON_GOALS}}
21
+
22
+ ## Acceptance
23
+ {{ACCEPTANCE}}
24
+
25
+ ## Specs
26
+ {{SPECS}}
27
+
28
+ ## Stories ledger
29
+ - S1 | {{STORY_NAME}} | depends-on: none | owns: {{OWNED_PATHS}} | shared: none | state: planned
30
+
31
+ ## Queue
32
+ Row {{EPIC_ID}} in {{QUEUE_BUCKET}}
@@ -0,0 +1,23 @@
1
+ # Task: {{TASK_NAME}}
2
+ Story: {{STORY_ID}} of {{EPIC_ID}}
3
+
4
+ ## Slice
5
+ Plan: {{PLAN_PATH}}
6
+ Row: {{ROW_ID}}
7
+ Grouping: test, impl
8
+ Files:
9
+ - {{TEST_PATH}} :: test
10
+ - {{MODULE_PATH}} :: impl
11
+
12
+ ## Reads
13
+ - {{PLAN_PATH}}
14
+
15
+ ## Acceptance
16
+ - {{ACCEPTANCE_COMMAND}} :: {{EXPECTED_OUTCOME}}
17
+
18
+ ## Negative cases
19
+ - {{NEGATIVE_CASE}} :: {{NEGATIVE_OUTCOME}}
20
+
21
+ ## Budget
22
+ - {{TEST_PATH}} :: {{TEST_MAX_LINES}}
23
+ - {{MODULE_PATH}} :: {{MODULE_MAX_LINES}}
@@ -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()`.
@@ -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));