@arjunkhera/atlas 0.0.0 → 0.2.1

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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +18 -0
  2. package/.mcp.json +18 -0
  3. package/README.md +32 -1
  4. package/agents/artifact-format/example.html +71 -0
  5. package/agents/artifact-format/system.css +273 -0
  6. package/agents/artifact-renderer.md +132 -0
  7. package/agents/capability-reader.md +100 -0
  8. package/agents/code-explorer.md +108 -0
  9. package/agents/reviewer-architect.md +78 -0
  10. package/agents/reviewer-pm.md +81 -0
  11. package/agents/reviewer-security.md +82 -0
  12. package/bin/atlas +5 -0
  13. package/door/cli.mjs +280 -0
  14. package/door/lib/helpers.mjs +139 -0
  15. package/door/lib/install.mjs +260 -0
  16. package/door/lib/kind-drift.mjs +76 -0
  17. package/door/lib/onboarding.mjs +130 -0
  18. package/door/lib/pull-request.mjs +119 -0
  19. package/door/lib/releases.mjs +42 -0
  20. package/door/lib/status.mjs +47 -0
  21. package/door/lib/tooling.mjs +119 -0
  22. package/package.json +24 -3
  23. package/shape/check.mjs +1797 -0
  24. package/shape/releases.json +15 -0
  25. package/skills/sdlc-task/SKILL.md +174 -0
  26. package/skills/sdlc-task/lifecycle.yaml +27 -0
  27. package/skills/sdlc-task/templates/design-doc.md +118 -0
  28. package/skills/sdlc-task/templates/resume-anchor.md +25 -0
  29. package/work/code-read.mjs +65 -0
  30. package/work/lib/action-store.mjs +84 -0
  31. package/work/lib/capability.mjs +487 -0
  32. package/work/lib/child-env.mjs +11 -0
  33. package/work/lib/client.mjs +214 -0
  34. package/work/lib/code-read.mjs +238 -0
  35. package/work/lib/delivery.mjs +624 -0
  36. package/work/lib/mcp-stdio.mjs +68 -0
  37. package/work/lib/questions.mjs +278 -0
  38. package/work/lib/sweep.mjs +189 -0
  39. package/work/lib/verb-fields.mjs +53 -0
  40. package/work/lib/verbs.mjs +511 -0
  41. package/work/manifest-0.5.0.json +609 -0
  42. package/work/mcp.mjs +216 -0
  43. package/work/protected-paths.json +38 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "note": "Every copy of shape/check.mjs a release of Atlas has shipped. The last row is the check this package ships. atlas status reads this list; git history is never read.",
3
+ "releases": [
4
+ {
5
+ "check_version": 1,
6
+ "sha256": "3dfd659a7533c51dce11bd0e2d7a9312f041c5955d45ac3cc67d3e29d328b5d2",
7
+ "first_package": "0.1.1"
8
+ },
9
+ {
10
+ "check_version": 2,
11
+ "sha256": "a4bc5ab8c75be8f216d460df9233670f5ba4ed9d853236f6b55a7f0d10dfe293",
12
+ "first_package": "0.2.0"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,174 @@
1
+ ---
2
+ name: sdlc-task
3
+ description: >
4
+ The Atlas delivery lifecycle in a session — start / pause / resume / status / lock / done.
5
+ Trigger whenever the owner starts new software work in chat ("let's build X", "new
6
+ feature", "start a task"), pauses or resumes work ("park this", "pick up where we left
7
+ off"), asks where a task stands, or is ready to lock a design ("lock it down", "we're done
8
+ specifying"). Every write goes through the atlas-work verbs; this skill says when to call
9
+ which one.
10
+ ---
11
+
12
+ # sdlc-task — the delivery lifecycle
13
+
14
+ Atlas is the package that sets how a repo is worked on. This skill is its
15
+ delivery lifecycle. It travels with the package, not with any one repo.
16
+
17
+ The kernel is data, not prose. Read [`lifecycle.yaml`](lifecycle.yaml), beside
18
+ this file, for the stages, tiers, tripwires and the lock contract before you
19
+ act. A verb reads those lists; it never hard-codes them.
20
+
21
+ ## Where the state lives
22
+
23
+ Every work item, question, decision and delivery mark is a record in the graph,
24
+ written through the **atlas-work verbs** (the `atlas-work` MCP server). Do not
25
+ write raw graph records for work. The verbs hold the write rules, the owner
26
+ checks and the transition log; each verb's own description says what it needs.
27
+
28
+ | You want to | Verb |
29
+ |---|---|
30
+ | Record new work, with the owner's words | `item_propose`, then `item_stage` as it moves |
31
+ | Freeze the design | `item_lock` |
32
+ | Park it, or pick it up again | `item_park`, `item_resume` |
33
+ | Split it, or link it to other work | `item_split`, `item_link` |
34
+ | Ask the owner something, or record the answer | `question_ask`, `question_answer` |
35
+ | Record a decision in the owner's words | `decision_record` |
36
+ | Mark it delivered, or reopen it | `item_done`, `item_reopen` |
37
+ | Say where things stand | `where_are_we`, `in_flight`, `needs_me` |
38
+
39
+ The repo's own facts come from `atlas.yaml` and `atlas/`. Its `kind` (`service`,
40
+ `library`, `schema`) decides which procedures it has and what "delivered" means.
41
+ Read it. Never assume a deployed service.
42
+
43
+ A change to the process itself is not a task. It is an amendment to the
44
+ frozen text, and the rule for one is short:
45
+
46
+ 1. Say so at once. Never build against a sentence you know is wrong.
47
+ 2. Write the amendment as its own dated file: what changes, why, the new
48
+ frozen text in full, one new hash over the whole frozen section, and the old
49
+ hash named as retired.
50
+ 3. Never edit frozen text in place. A reviewer's finding is a normal reason to
51
+ amend.
52
+
53
+ (Source: the Atlas lock procedure, amendment A1.6, section 12.)
54
+
55
+ ## start
56
+
57
+ 1. **Tier** (`lifecycle.yaml` `tiers`): propose one — hotfix, standard or
58
+ initiative — with a one-line reason. The owner can override. The forced
59
+ escalators are `lifecycle.yaml` `tier_escalators`.
60
+ 2. **Record the work at once** with `item_propose`: the owner's goal
61
+ **verbatim**, and one line of interpretation that is clearly yours. Move its
62
+ stage with `item_stage` as the design conversation opens.
63
+ 3. **Worktree and branch.** Read the repo's `default_branch` from its record
64
+ (`registry_list`); it may be `main` or `master`. Branch from it, and never
65
+ commit to it. Local sessions use `feature/`, `fix/` or `chore/`; cloud
66
+ sessions use `claude/`. The pull request targets that branch.
67
+ 4. **Design doc** (standard and above): copy
68
+ [`templates/design-doc.md`](templates/design-doc.md) to
69
+ `docs/design-docs/<slug>.md`, fill Context and goal, and list it in
70
+ `docs/index.md` in the commit that first lands it. Hotfix tier skips the
71
+ doc: the acceptance criteria live on the work item, and a resume anchor
72
+ covers pauses.
73
+ 5. Enter the design loop.
74
+
75
+ ## The design loop (between start and lock)
76
+
77
+ - **One voice.** The owner talks to one colleague. Crews run behind the
78
+ curtain as subagents and come back as digests — never as a committee
79
+ surfacing question lists.
80
+ - **Guided elicitation** (the owner's words: *"one on one
81
+ conversation that flows… the purpose of agent is to do research and guide my
82
+ thoughts into useful features"*). Research first, then **one pointed question
83
+ at a time**, with a recommendation (the owner: *"Questions if posed
84
+ should be one by one. Just think, if you say 5 things in one go, how do I
85
+ respond."*). Keep the question queue in the design doc; surface only the
86
+ next one. Record each question with `question_ask` and each answer with
87
+ `question_answer`.
88
+ - **Succinct chat; artifacts carry the content** (the owner's words:
89
+ *"the content is very verbose… I won't read that much"* · *"artefacts should
90
+ be the default method of communication… use as many diagrams and examples as
91
+ possible"*). Chat is a few short sentences: the outcome, the artifact link,
92
+ the one question.
93
+ - **Lead with a worked example.** Real input, real process, real output, and a
94
+ sequence diagram where order matters. The owner cannot read dense prose.
95
+ - **Read the reason, not only the rule.** Before you build to a done line, read
96
+ the owner's words behind it and ask whether the thing should exist at all. A
97
+ done line is a measure, not the goal.
98
+ - **Research before opinions.** Fan out `atlas:code-explorer` for how the system
99
+ works today. Raw exploration never enters this session — digests only.
100
+ - **When the task is a cluster of filed bugs, first ask whether the model
101
+ they presume was ever decided.** A bug report says the code surprised
102
+ someone; it does not say what the code should do.
103
+ - **The doc accretes in real time.** Every resolved question, decision and
104
+ approach lands in the design doc as it happens. The doc is the pause and
105
+ resume state. Record each decision with `decision_record`.
106
+ - **HTML is the owner's reading surface.** Any design, review or status the
107
+ owner reads is a styled artifact, rendered by the `atlas:artifact-renderer`
108
+ crew. Its treatment contract lives in that crew; do not restate it.
109
+ - **No bare acronyms in front of the owner.** Spell a thing out on first
110
+ mention. Ids are for the machine; names are for the human.
111
+ - **User stories before acceptance criteria** (the owner's words:
112
+ *"I need to see how a user would use the product, or like how the flow
113
+ works"*). Never draft criteria for a flow no story walks through.
114
+ - **Adversarial review before lock.** Standard tier: at least one persona.
115
+ Initiative: the panel — `atlas:reviewer-pm`, `atlas:reviewer-architect`,
116
+ `atlas:reviewer-security` — each briefed with the doc path, in parallel. Brief each
117
+ one to ask whether the design is the right thing, not only whether it is
118
+ consistent. An unresolved finding goes into the doc's open questions, and
119
+ that blocks the lock.
120
+
121
+ ## lock
122
+
123
+ Gate on `lifecycle.yaml` `lock.preconditions`, all four: the open questions
124
+ are empty, the owner ratified the criteria, the approach is chosen, the tier
125
+ is confirmed. Then:
126
+
127
+ 1. Stamp the design doc's lock block: date, ratifier, tier, the sha256 of the
128
+ acceptance criteria text, frozen interfaces, the work list. Commit.
129
+ 2. Call `item_lock` with the frozen scope and its hash.
130
+ 3. Build against the frozen text. **Tripwires** (`lifecycle.yaml`
131
+ `tripwires`): a discovery after lock that touches the criteria, security,
132
+ a schema or cost stops the build and goes back to the owner. If the frozen
133
+ text is wrong, say so at once and write an amendment. Never build against a
134
+ sentence you know is wrong.
135
+ 4. Test with the repo's own `test` procedure, which `atlas.yaml` names. Open a
136
+ pull request. Keep a separate acceptance reviewer, and close its findings
137
+ before acceptance.
138
+
139
+ ## pause
140
+
141
+ 1. Bring the resume state current: the design doc (standard and above), or a
142
+ resume anchor from [`templates/resume-anchor.md`](templates/resume-anchor.md)
143
+ (hotfix). The bar: a fresh session needs nothing else.
144
+ 2. Call `item_park` with the reason. A parked item keeps its stage.
145
+
146
+ ## resume
147
+
148
+ 1. Find it with `where_are_we` or `in_flight`, then read its design doc or its
149
+ newest resume anchor.
150
+ 2. **Check the world still matches.** Does the branch exist? Is the blocker
151
+ closed? Are the criteria still true? Write any change into the doc first.
152
+ 3. Call `item_resume` and continue at the recorded stage.
153
+
154
+ ## status
155
+
156
+ Call `where_are_we` for the item, or `needs_me` for what waits on the owner.
157
+ Report lifecycle, stage, open questions, what is blocked on the owner, and the
158
+ next action. One short paragraph.
159
+
160
+ ## done
161
+
162
+ 1. Prove delivery the way the repo's `kind` requires, then call `item_done`. A
163
+ `library` or `schema` repo proves it by merged pull requests and a green
164
+ gate. A `service` repo also needs its live checks.
165
+ 2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
166
+ then a tool, then a skill, then a doc — and say which.
167
+ 3. Design doc status: `SHIPPED <date>`.
168
+
169
+ ## Model tiering
170
+
171
+ The design loop and the lock judgement run on the frontier model. Crews state
172
+ their own tier in their own files, which ship with Atlas: `atlas:code-explorer`
173
+ is Sonnet; the reviewer personas inherit the frontier model. Drafting from a
174
+ locked spec goes to cheaper models through subagents.
@@ -0,0 +1,27 @@
1
+ # The delivery lifecycle kernel, as data.
2
+ #
3
+ # Atlas is the package that sets how a repo is worked on, and this is its
4
+ # lifecycle. It travels with the package, beside the skill that reads it, not
5
+ # with any one repo. A verb reads these lists; it never hard-codes them.
6
+ #
7
+ # The values came from the old ops pack, unchanged. That pack and every script
8
+ # that read it are deleted.
9
+ version: 1
10
+ authority: the Atlas lock procedure, amendment A1.9, section 5, slice L2 stage one.
11
+
12
+ lifecycle_states: [proposed, active, paused, done, dropped, archived]
13
+ stages: [capture, spec, build, verify, ship, learn]
14
+
15
+ lock:
16
+ freezes: [acceptance_criteria, interfaces, approach]
17
+ preconditions: [open_questions_empty, ac_ratified, approach_chosen, tier_confirmed]
18
+
19
+ tripwires: [acceptance_criteria, security, schema_migrations, money_cost]
20
+
21
+ tiers: [hotfix, standard, initiative]
22
+ tier_escalators: [migrations, auth_rls_surface, new_external_dependency, money_cost]
23
+
24
+ # Both templates travel with the package, beside the kernel.
25
+ templates:
26
+ design_doc: templates/design-doc.md
27
+ resume_anchor: templates/resume-anchor.md
@@ -0,0 +1,118 @@
1
+ <!-- Living design document — template (lifecycle spec §7, LC-5).
2
+ Copy to docs/design-docs/<slug>.md at `sdlc-task start` (standard/initiative
3
+ tiers). Sections may be trimmed at standard tier; never at initiative.
4
+ The artifact projection republishes on change (D14: git is truth).
5
+ Structure rules that keep the doc easy to read by machine:
6
+ sections addressable by heading, decisions/questions as list items with
7
+ stable ids (D-n / OQ-n / RQ-n), no prose that depends on rendering.
8
+ Reference rule (the owner's directive): ids are for the machine,
9
+ names are for the human — every cross-reference to a stable id links to
10
+ its definition (markdown section link here; anchor + tooltip in the HTML
11
+ artifact, see the atlas:artifact-renderer crew) and spells out what
12
+ it is on first mention. Never leave a bare acronym for the reader to
13
+ decode. -->
14
+
15
+ # <Feature name> — design
16
+
17
+ > Status: DRAFT (in design loop) | LOCKED <date> | SHIPPED <date>
18
+ > Work item: <work item id> · Tier: hotfix | standard | initiative
19
+ > Artifact: <claude.ai artifact url once published>
20
+
21
+ ## Context & goal
22
+
23
+ <One paragraph: the problem in the owner's words (verbatim where possible),
24
+ and the one-line goal. Agent interpretation kept separate.>
25
+
26
+ ## Goals & non-goals
27
+
28
+ <!-- From the owner's exemplar PRD shape. Bullets, testable
29
+ in spirit: what this change is FOR, and what it deliberately is not.
30
+ Success metrics where they exist — how we'd know it worked. -->
31
+
32
+ - Goals: …
33
+ - Non-goals: …
34
+ - Success metrics: …
35
+
36
+ ## Flows
37
+
38
+ <The user-visible flows this touches, before → after. Sequence diagrams
39
+ (mermaid) where they earn their keep.>
40
+
41
+ ## User stories
42
+
43
+ <!-- Mandatory at standard/initiative tier (the owner's words: "I need
44
+ to see how a user would use the product, or like how the flow works").
45
+ Concrete scenes with stable ids (US-n): who, the moment, what they do,
46
+ what the system does, what they see — written as narrative the owner can
47
+ react to, each tagged to the slice that ships it. Illustrative dialogue
48
+ is fine; mark invented values as illustrative. Draft these BEFORE
49
+ acceptance criteria — the AC formalize what the stories show. -->
50
+
51
+ - **US-1 — <scene name>** *(slice)*: …
52
+
53
+ ## Architecture
54
+
55
+ <High-level shape: components touched, data model changes, boundaries crossed.
56
+ Initiative tier: current-state diagram + target-state diagram.>
57
+
58
+ ## Approaches considered
59
+
60
+ <A/B with real depth at initiative tier — costs, risks, why the loser lost.
61
+ Standard tier: may collapse to "approach + rejected alternative, one line".>
62
+
63
+ - **Approach A — <name>**: …
64
+ - **Approach B — <name>**: …
65
+ - **Chosen**: <A/B> because <reason>.
66
+
67
+ ## Open questions
68
+
69
+ <!-- The lock precondition: this checklist must be EMPTY (all items moved to
70
+ Resolved) before `sdlc-task lock` will stamp the lock block. -->
71
+
72
+ - [ ] OQ-1 —
73
+
74
+ ## Resolved questions
75
+
76
+ - RQ-1 — <question> → <resolution, owner-verbatim where it was an owner call> (<date>)
77
+
78
+ ## Decision log
79
+
80
+ - D-1 — <decision> — <rationale> (<date>, <ratifier>)
81
+
82
+ ## Risks & failure modes
83
+
84
+ <!-- What breaks, degrades, or bites later; per-risk mitigation or "accepted".
85
+ From the exemplar PRD shape; trimmable at standard tier. -->
86
+
87
+ ## Test & eval plan
88
+
89
+ <!-- How each slice proves itself: suites, scenarios, eval questions, prod
90
+ verification. The red spec's table of contents. Trimmable at standard. -->
91
+
92
+ ## Acceptance criteria
93
+
94
+ <!-- These freeze at lock (LC-6). Write them testable — the red spec derives
95
+ from this list. -->
96
+
97
+ 1. …
98
+
99
+ ## Lock block
100
+
101
+ <!-- Stamped by `sdlc-task lock`; never hand-edited afterwards. -->
102
+
103
+ ```
104
+ LOCKED: <date> by <ratifier>
105
+ Tier: <tier>
106
+ Frozen: AC (sha256 of the AC section), interfaces: <list>, approach: <chosen>
107
+ Tripwires in force: AC / security / schema / cost (kernel defaults)
108
+ Emitted: <child containers / issues created at lock>
109
+ ```
110
+
111
+ ## Deviation log (post-lock)
112
+
113
+ <!-- Every post-lock divergence from this document. Review checks this list.
114
+ A deviation touching a tripwire (AC, security, schema, cost) must halt
115
+ and return to the owner instead of landing here. -->
116
+
117
+ | # | What changed vs the doc | Why | Tripwire? | Reviewed |
118
+ |---|---|---|---|---|
@@ -0,0 +1,25 @@
1
+ <!-- Resume anchor — template (lifecycle spec §8).
2
+ For work BELOW the design-doc threshold: hotfix tier, bug investigations,
3
+ any interrupted queue work. Posted as a comment on the issue (queue lane)
4
+ or the container thread (conversation lane) at every pause AND at stage
5
+ transitions of long-running work.
6
+ The bar (and the eval): a fresh session given only this anchor proceeds
7
+ without asking the owner anything the anchor should have contained. -->
8
+
9
+ ## ⏸ Resume anchor — <date>
10
+
11
+ **Goal:** <one line, owner-verbatim where possible>
12
+
13
+ **State:** lifecycle `<active|paused>` · stage `<capture|spec|build|verify|ship|learn>`
14
+
15
+ **Findings so far:** <POINTER to where they live — issue comment link, doc
16
+ path, graph entity id. Never duplicate content inline.>
17
+
18
+ **Open questions** (empty = resumable without the owner):
19
+
20
+ - [ ] …
21
+
22
+ **Next action:** <the single highest-leverage sentence for the resuming
23
+ session — imperative, specific, no context required beyond this anchor>
24
+
25
+ **Refs:** branch `<name>` · worktree `<path, if still live>` · PR/issue links
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ // Atlas M1 slice L4: resolve the citations of a code read.
3
+ // The M1 acceptance criterion says "a reviewer resolves every citation". This
4
+ // is how: each path and line is opened in the commit the digest names, read
5
+ // with `git show`, and checked. Nothing is written, no branch moves, and no
6
+ // working tree is touched, so the resolve is safe on a repo another session is
7
+ // using. A never-read path is never opened. A never-quoted path gives back the
8
+ // digest of its line, not its text.
9
+ //
10
+ // atlas code-read resolve --digest <file.json> --repo <owner/repo> \
11
+ // --root <path to that repo> [--output report.json]
12
+ //
13
+ // The digest file may hold one digest, an array of digests, or the arguments
14
+ // of a where_are_we call ({ code: [...] }).
15
+ import { execFileSync } from 'node:child_process';
16
+ import { childEnvironment } from './lib/child-env.mjs';
17
+ import { readFile, writeFile } from 'node:fs/promises';
18
+ import { resolve as resolvePath } from 'node:path';
19
+ import { acceptCodeRead, resolveCitations } from './lib/code-read.mjs';
20
+
21
+ const args = process.argv.slice(2);
22
+ const command = args[0];
23
+ const option = (name) => { const index = args.indexOf(`--${name}`); return index === -1 ? undefined : args[index + 1]; };
24
+ const usage = 'usage: atlas code-read resolve --digest <file.json> --root <repo path> [--repo <owner/repo>] [--output <report.json>]';
25
+ if (command !== 'resolve') { process.stderr.write(`${usage}\n`); process.exit(1); }
26
+
27
+ const digestPath = option('digest'), rootOption = option('root');
28
+ if (!digestPath || !rootOption) { process.stderr.write(`${usage}\n`); process.exit(1); }
29
+ const roots = new Map();
30
+ // --root may be given once, or once per repo as "owner/repo=/path".
31
+ for (let index = 0; index < args.length; index += 1) {
32
+ if (args[index] !== '--root') continue;
33
+ const value = args[index + 1] ?? '';
34
+ const split = value.indexOf('=');
35
+ if (split > 0 && value.slice(0, split).includes('/') && value.slice(split + 1).startsWith('/')) roots.set(value.slice(0, split), value.slice(split + 1));
36
+ else roots.set('*', resolvePath(value));
37
+ }
38
+
39
+ const raw = JSON.parse(await readFile(resolvePath(digestPath), 'utf8'));
40
+ const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.code) ? raw.code : [raw]);
41
+ const only = option('repo');
42
+ const digests = list.map((entry, index) => acceptCodeRead(entry, { name: `code[${index}]` }).digest).filter((digest) => !only || digest.repo === only);
43
+ if (!digests.length) { process.stderr.write('no digest to resolve\n'); process.exit(1); }
44
+
45
+ const reports = [];
46
+ for (const digest of digests) {
47
+ const root = roots.get(digest.repo) ?? roots.get('*');
48
+ if (!root) { process.stderr.write(`no --root for ${digest.repo}\n`); process.exit(1); }
49
+ // git show reads the commit, not the working tree. The checkout may sit on
50
+ // any branch with any uncommitted change; the answer is the same.
51
+ const readFileAt = async (commit, path) => execFileSync('git', ['-C', root, 'show', `${commit}:${path}`], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, env: childEnvironment() });
52
+ const report = await resolveCitations({ digest, readFileAt });
53
+ reports.push({ ...report, root });
54
+ process.stderr.write(`${digest.repo}@${digest.commit.slice(0, 7)}: ${report.resolved}/${report.citations} citations resolved${report.failed ? `, ${report.failed} failed` : ''}${report.quote_mismatches ? `, ${report.quote_mismatches} quotes do not match` : ''}\n`);
55
+ for (const row of report.rows.filter((entry) => !entry.resolved || entry.quote_matches === false)) process.stderr.write(` ${row.path}:${row.line} — ${row.reason ?? 'the quote is not on that line'}\n`);
56
+ }
57
+
58
+ const pass = reports.every((report) => report.pass);
59
+ const output = option('output');
60
+ if (output) {
61
+ await writeFile(resolvePath(output), `${JSON.stringify({ resolved_at: new Date().toISOString(), pass, reports }, null, 2)}\n`);
62
+ process.stderr.write(`wrote ${output}\n`);
63
+ }
64
+ process.stderr.write(pass ? 'every citation resolved\n' : 'some citations did not resolve\n');
65
+ process.exitCode = pass ? 0 : 1;
@@ -0,0 +1,84 @@
1
+ import { mkdir, open, lstat, rename, rm, readdir } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+
6
+ // Local write-ahead records survive process exit. A stale lock is deliberately
7
+ // held for operator reconciliation; a lost process must not start a second writer.
8
+ export function createActionStore({ directory } = {}) {
9
+ if (typeof directory !== 'string' || !isAbsolute(directory)) throw new TypeError('absolute journal directory is required');
10
+ const key = (id) => createHash('sha256').update(id).digest('hex');
11
+ const path = (id) => join(directory, `${key(id)}.json`);
12
+ async function init() {
13
+ await mkdir(directory, { recursive: true, mode: 0o700 });
14
+ const stat = await lstat(directory);
15
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('journal directory must not be a symlink');
16
+ }
17
+ async function assertRecord(id) {
18
+ try { const stat = await lstat(path(id)); if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('journal record must be a regular file, not a symlink'); }
19
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
20
+ }
21
+ async function get(id) {
22
+ await init();
23
+ await assertRecord(id);
24
+ try {
25
+ const file = await open(path(id), constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ try { return JSON.parse(await file.readFile('utf8')); } finally { await file.close(); }
27
+ }
28
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
29
+ }
30
+ async function put(id, value) {
31
+ await init();
32
+ await assertRecord(id);
33
+ const temp = `${path(id)}.${randomUUID()}.tmp`;
34
+ const handle = await open(temp, 'wx', 0o600);
35
+ try { await handle.writeFile(JSON.stringify(value)); await handle.sync(); }
36
+ finally { await handle.close(); }
37
+ await rename(temp, path(id));
38
+ const parent = await open(directory, 'r');
39
+ try { await parent.sync(); } finally { await parent.close(); }
40
+ }
41
+ async function withLock(id, operation) {
42
+ await init();
43
+ const lock = join(directory, `${key(id)}.lock`);
44
+ let handle;
45
+ try { handle = await open(lock, 'wx', 0o600); }
46
+ catch (error) { if (error.code === 'EEXIST') throw new Error('action journal is locked; reconcile its prior writer before retry'); throw error; }
47
+ try {
48
+ await handle.writeFile(JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() }));
49
+ await handle.sync();
50
+ return await operation();
51
+ } finally { await handle.close(); await rm(lock); }
52
+ }
53
+ async function inspect() {
54
+ // Read-only inventory. Never create a journal or remove a writer lock.
55
+ let entries;
56
+ try {
57
+ const stat = await lstat(directory);
58
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe journal directory');
59
+ entries = await readdir(directory);
60
+ } catch (error) {
61
+ if (error.code === 'ENOENT') return { complete: false, records: [], locks: [] };
62
+ throw error;
63
+ }
64
+ if (entries.length > 10000) throw new Error('journal inventory exceeds inspection bound');
65
+ const records = [], locks = [];
66
+ let complete = true;
67
+ for (const name of entries.sort()) {
68
+ if (!/^[a-f0-9]{64}\.(json|lock)$/.test(name)) { complete = false; continue; }
69
+ try {
70
+ const full = join(directory, name), stat = await lstat(full);
71
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) throw new Error('unsafe journal entry');
72
+ const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ let value;
74
+ try { value = JSON.parse(await handle.readFile('utf8')); } finally { await handle.close(); }
75
+ if (name.endsWith('.lock')) { locks.push({ name }); continue; }
76
+ const requestId = value.request?.requestId;
77
+ if (typeof requestId !== 'string' || name !== `${key(requestId)}.json` || typeof value.phase !== 'string') throw new Error('invalid journal identity');
78
+ records.push({ requestId, itemId: value.item_id ?? value.request?.itemId ?? null, phase: value.phase, kind: value.kind ?? 'work' });
79
+ } catch { complete = false; if (name.endsWith('.lock')) locks.push({ name }); }
80
+ }
81
+ return { complete, records, locks };
82
+ }
83
+ return Object.freeze({ get, put, withLock, inspect });
84
+ }