runlist 0.0.0 → 0.94.2

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,491 @@
1
+ // Trusted local adapters supply authentication and policy. Never expose these
2
+ // callbacks, config, test hooks, or this factory as browser request parameters.
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { assertSnapshotCurrent, createFileExclusive, MutationConflictError, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
8
+ import { authorizeManagedSource, authorizeManagedDestination, authorizeRepoGeneratedPath } from './managed-path.mjs';
9
+ import { canonicalPlanIdentity, readPlanOwnership } from './pickup.mjs';
10
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
11
+ import { nativePlanItems, parseNativeRecord } from './native-record.mjs';
12
+ import { stateDir } from './naming.mjs';
13
+ import {getStageDefinitions, readPlanStage, readShipsFrontmatter} from './stages.mjs';
14
+ import {withoutSourceStage} from '../assets/app/stage-source.mjs';
15
+
16
+ const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
17
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
18
+ const revisionPattern = /^sha256:[a-f0-9]{64}$/;
19
+ const hash = value => createHash('sha256').update(value).digest('hex');
20
+ const json = value => JSON.stringify(value, null, 2) + '\n';
21
+
22
+ export class SourceEditError extends Error {
23
+ constructor(code, message, details = {}) { super(message); this.name = 'SourceEditError'; this.code = code; this.details = details; }
24
+ }
25
+ function fail(code, message, details) { throw new SourceEditError(code, message, details); }
26
+
27
+ // Shared by narrative editing and lifecycle receipts. Trusted adapters only.
28
+ export function assertPrivateEditorStorage(repoRoot, directory) {
29
+ // Draft bodies and before-images must never become shared repository data.
30
+ // In a Git checkout require ignored AND untracked storage; do not alter Git.
31
+ // Git may be missing/broken, and a configured root may sit below a checkout
32
+ // or worktree. Inspect physical ancestors without suppressing stat failures.
33
+ let gitContext = Boolean(process.env.GIT_DIR || process.env.GIT_WORK_TREE || process.env.GIT_COMMON_DIR);
34
+ try {
35
+ for (let directory = realpathSync(repoRoot);;) {
36
+ try { lstatSync(path.join(directory, '.git')); gitContext = true; break; }
37
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
38
+ const parent = path.dirname(directory);
39
+ if (parent === directory) break;
40
+ directory = parent;
41
+ }
42
+ } catch { fail('unsafe-local-state', 'Cannot inspect Git checkout markers before storing private editor state.'); }
43
+ let git = false;
44
+ try {
45
+ const discovery = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
46
+ // A successful false/unexpected response is not a verified non-Git folder
47
+ // (for example, this can be a bare repository or its metadata directory).
48
+ if (discovery !== 'true') gitContext = true;
49
+ else git = true;
50
+ } catch { /* An ordinary non-Git folder remains usable without Git. */ }
51
+ if (!git && gitContext) fail('unsafe-local-state', 'Cannot verify private editor storage in this Git checkout. Make Git available and ensure editor state is ignored and untracked before saving drafts or edits.');
52
+ if (git) {
53
+ const relative = path.relative(repoRoot, directory).split(path.sep).join('/');
54
+ try {
55
+ execFileSync('git', ['-C', repoRoot, 'check-ignore', '-q', '--', `${relative}/`], { stdio: 'ignore' });
56
+ if (execFileSync('git', ['-C', repoRoot, 'ls-files', '--', relative], { encoding: 'utf8' }).trim()) throw new Error('tracked');
57
+ } catch { fail('unsafe-local-state', 'Editor state must be ignored and untracked before storing drafts or before-images.'); }
58
+ }
59
+ }
60
+ function text(source) {
61
+ // Native UTF-16 validation rejects lone surrogates without allocating a full
62
+ // UTF-8 buffer and decoded copy for every read, revision, draft and save.
63
+ if (typeof source !== 'string' || Buffer.byteLength(source) > MAX_SOURCE_BYTES
64
+ || source.includes('\0') || !source.isWellFormed()) {
65
+ fail('invalid-source', 'Source must be valid UTF-8 text of at most 8 MiB without NUL bytes.');
66
+ }
67
+ return source;
68
+ }
69
+ export function sourceRevision(source) { return `sha256:${hash(text(source))}`; }
70
+ function requireRevision(value) { if (!revisionPattern.test(value ?? '')) fail('invalid-revision', 'An exact SHA-256 source revision is required.'); }
71
+ function requireId(id) { if (!uuid.test(id ?? '')) fail('invalid-id', 'A lowercase UUID v4 operation or draft ID is required.'); }
72
+ function synchronous(value) { if (value?.then) fail('adapter-error', 'Editor authentication and authorization callbacks must be synchronous.'); return value; }
73
+ function principalKey(actor) { return hash(JSON.stringify([actor.kind, actor.id, actor.session_id ?? null])); }
74
+
75
+ // Include the raw closing fence/newline in the protected envelope. No parse /
76
+ // serialize pass is allowed to normalize unrelated source on a narrative save.
77
+ function envelope(source) {
78
+ const match = source.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/);
79
+ return match?.[0] ?? '';
80
+ }
81
+ function historySections(source) {
82
+ const sections = [];
83
+ const lines = source.slice(envelope(source).length).match(/[^\n]*\n|[^\n]+$/g) ?? [];
84
+ let fence = null, history = null;
85
+ for (const line of lines) {
86
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)/);
87
+ if (marker) {
88
+ if (!fence) fence = marker[1];
89
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length && !marker[2].trim()) fence = null;
90
+ } else if (!fence && /^ {0,3}#{1,2}\s/.test(line)) {
91
+ if (history !== null) { sections.push(history); history = null; }
92
+ if (/^ {0,3}##\s+Version History\s*(?:#+\s*)?$/i.test(line.trimEnd())) history = '';
93
+ }
94
+ if (history !== null) history += line;
95
+ }
96
+ if (history !== null) sections.push(history);
97
+ return sections;
98
+ }
99
+ function describe(source, legacyTypes = new Set(['plan'])) {
100
+ const { frontmatter } = extractFrontmatter(source);
101
+ const warnings = [];
102
+ const fm = parseSimpleFrontmatter(frontmatter, warnings);
103
+ if (fm.record_schema !== undefined) {
104
+ const native = parseNativeRecord(source);
105
+ return { type: fm.type, editable: native.ok, diagnostics: native.diagnostics };
106
+ }
107
+ const editable = legacyTypes.has(fm.type) && envelope(source) !== '' && warnings.length === 0;
108
+ return { type: fm.type, editable, diagnostics: editable ? [] : [{ code: 'unsupported-source', message: 'Narrative editing requires an unambiguous configured document or valid native v1 record.' }] };
109
+ }
110
+ function validateEdit(before, after, legacyTypes, stagePolicy, undo = false) {
111
+ text(after);
112
+ if (envelope(before) !== envelope(after)) {
113
+ const beforeMetadata = parseSimpleFrontmatter(extractFrontmatter(before).frontmatter);
114
+ let stageOnly = false;
115
+ try {stageOnly = stagePolicy?.allowed && beforeMetadata.type === 'plan'
116
+ && withoutSourceStage(envelope(before)) === withoutSourceStage(envelope(after));} catch { /* Ambiguous metadata is never editable. */ }
117
+ if (!stageOnly) {
118
+ fail('managed-fields', 'Only the plan stage may change through this editor; other frontmatter is managed by domain operations.');
119
+ }
120
+ const selected = readPlanStage(readShipsFrontmatter(extractFrontmatter(after).frontmatter));
121
+ if (!undo && (selected.invalid || (selected.word && stagePolicy.configured && !stagePolicy.words.has(selected.word)))) {
122
+ fail('invalid-stage', 'Choose a configured stage or leave the stage unset.');
123
+ }
124
+ }
125
+ if (JSON.stringify(historySections(before)) !== JSON.stringify(historySections(after))) fail('managed-fields', 'Lifecycle version history must be retained byte for byte.');
126
+ const beforeItems = nativePlanItems(extractFrontmatter(before).body).map(item => item.id);
127
+ const afterItems = nativePlanItems(extractFrontmatter(after).body).map(item => item.id);
128
+ if (JSON.stringify(beforeItems) !== JSON.stringify(afterItems)) fail('managed-fields', 'Stable item anchors must be retained; allocation/removal belongs to domain operations.');
129
+ for (const source of [before, after]) {
130
+ const info = describe(source,legacyTypes);
131
+ if (!info.editable) fail('invalid-record', 'Invalid or unsupported records are read-only.', { diagnostics: info.diagnostics });
132
+ }
133
+ }
134
+ function conflict(expected, current) {
135
+ fail('revision-conflict', 'The source changed; review the current source and keep the draft before retrying.', { expectedRevision: expected, currentRevision: sourceRevision(current), currentSource: current });
136
+ }
137
+
138
+ export function createSourceEditor({ config, authenticate, authorize, testHooks = {}, legacyTypes = ['plan'], allowUnconfiguredRead = false, allowStageEdits = false, domainPrepare = null }) {
139
+ if (!config?.repoRoot || typeof authenticate !== 'function' || typeof authorize !== 'function') fail('adapter-error', 'A repository config and explicit authentication and authorization callbacks are required.');
140
+ legacyTypes = new Set(legacyTypes.filter(type=>!['prompt','flag','decision'].includes(type)));
141
+ const stagePolicy = {allowed:allowStageEdits === true, configured:Array.isArray(config.raw?.taxonomy?.milestones ?? config.taxonomy?.milestones), words:new Set(getStageDefinitions(config).map(stage=>stage.word))};
142
+ // Snapshot trusted configuration; a caller cannot widen roots after issuance.
143
+ config = { repoRoot: path.resolve(config.repoRoot), docsRoots: config.docsRoots ? [...config.docsRoots] : undefined, docsRoot: config.docsRoot };
144
+ const options = { repoRoot: config.repoRoot, locked: true };
145
+ const root = path.join(stateDir(config.repoRoot), 'editor');
146
+ const generated = candidate => {
147
+ const authorized = authorizeRepoGeneratedPath(candidate, config, { kind: 'Private editor state' }).path;
148
+ let cursor = config.repoRoot;
149
+ for (const segment of path.relative(config.repoRoot, authorized).split(path.sep).filter(Boolean)) {
150
+ cursor = path.join(cursor, segment);
151
+ if (existsSync(cursor) && lstatSync(cursor).isSymbolicLink()) fail('unsafe-local-state', 'Editor state may not traverse symlinks.');
152
+ }
153
+ return authorized;
154
+ };
155
+ const operations = path.join(root, 'operations');
156
+ // Keep only bounded terminal receipt metadata, never before/after bodies.
157
+ // Every reuse checks the complete on-disk byte hash, not just timestamps.
158
+ const validatedReceipts = new Map();
159
+ const receiptBuffer = Buffer.allocUnsafe(64 * 1024);
160
+ function identity(context) {
161
+ const actor = synchronous(authenticate(context));
162
+ if (!actor || !['human', 'agent'].includes(actor.kind) || typeof actor.id !== 'string' || !actor.id.trim()
163
+ || (actor.kind === 'agent' && (typeof actor.session_id !== 'string' || !actor.session_id.trim()))) {
164
+ fail('unauthenticated', 'The trusted adapter must identify a human or an agent with its actual session.');
165
+ }
166
+ return { kind: actor.kind, id: actor.id, ...(actor.label ? { label: String(actor.label) } : {}), ...(actor.kind === 'agent' ? { session_id: actor.session_id } : {}) };
167
+ }
168
+ function permit(actor, action, filePath) {
169
+ const grant = synchronous(authorize({ actor: structuredClone(actor), action, path: filePath }));
170
+ if (grant?.allowed !== true || (actor.kind === 'agent' && (typeof grant.grant_id !== 'string' || !grant.grant_id.trim()))) {
171
+ fail('forbidden', 'The actor lacks verified, path-scoped authority for this operation.');
172
+ }
173
+ return actor.kind === 'agent' ? grant.grant_id : null;
174
+ }
175
+ function managed(input) {
176
+ if (typeof input !== 'string' || !input) fail('invalid-path', 'A configured Markdown path is required.');
177
+ const filePath = authorizeManagedSource(path.resolve(config.repoRoot, input), config).path;
178
+ const type = describe(text(readFileSync(filePath, 'utf8'))).type;
179
+ if (type === 'prompt' || (!legacyTypes.has(type) && !['flag','decision'].includes(type) && !allowUnconfiguredRead)) fail('unsupported-source', 'This source is outside the adapter’s supported document types.');
180
+ return filePath;
181
+ }
182
+ function sourceSnapshot(filePath) {
183
+ authorizeManagedSource(filePath, config);
184
+ const snapshot = snapshotFile(filePath);
185
+ text(snapshot.content);
186
+ if (!readFileSync(filePath).equals(Buffer.from(snapshot.content))) fail('invalid-source', 'Source is not a stable valid UTF-8 file.');
187
+ return snapshot;
188
+ }
189
+ function durableDirectory(directory) {
190
+ generated(directory);
191
+ if (existsSync(directory)) {
192
+ if (!lstatSync(directory).isDirectory()) fail('unsafe-local-state', 'Editor state directories must be directories.');
193
+ return;
194
+ }
195
+ durableDirectory(path.dirname(directory));
196
+ try { mkdirSync(directory, { mode: 0o700 }); }
197
+ catch (error) {
198
+ if (error.code !== 'EEXIST') throw error;
199
+ generated(directory);
200
+ if (!lstatSync(directory).isDirectory()) throw error;
201
+ }
202
+ let fd;
203
+ try { fd = openSync(path.dirname(directory), 'r'); fsyncSync(fd); }
204
+ catch (error) { if (!['EINVAL', 'ENOTSUP', 'EPERM', 'EACCES', 'EBADF', 'EISDIR'].includes(error.code)) throw error; }
205
+ finally { if (fd !== undefined) closeSync(fd); }
206
+ }
207
+ function localState() {
208
+ generated(root);
209
+ assertPrivateEditorStorage(config.repoRoot, root);
210
+ durableDirectory(operations);
211
+ }
212
+ function readState(filePath, onRead = null) {
213
+ generated(filePath);
214
+ if (!existsSync(filePath)) return null;
215
+ try {
216
+ if (!lstatSync(filePath).isFile() || lstatSync(filePath).size > 32 * 1024 * 1024) throw new Error('size');
217
+ const raw = readFileSync(filePath, 'utf8');
218
+ const value = JSON.parse(raw);
219
+ if (value.schema !== 1) throw new Error('schema');
220
+ onRead?.(raw);
221
+ return value;
222
+ } catch { fail('state-corrupt', 'Private editor state requires inspection; it will not be overwritten.', { statePath: filePath }); }
223
+ }
224
+ function writeState(filePath, value) {
225
+ generated(filePath);
226
+ durableDirectory(path.dirname(filePath));
227
+ validatedReceipts.delete(value.id);
228
+ if (existsSync(filePath)) replaceSnapshot(snapshotFile(filePath), json(value), options);
229
+ else createFileExclusive(filePath, json(value), { ...options, mode: 0o600 });
230
+ }
231
+ const operationPath = id => { requireId(id); return generated(path.join(operations, `${id}.json`)); };
232
+ function receipt(id, remember = false) {
233
+ let digest;
234
+ const value = readState(operationPath(id), remember ? raw => { digest = hash(raw); } : null);
235
+ if (value && (value.id !== id || !['prepared', 'committed', 'not-applied', 'settled-unknown'].includes(value.state)
236
+ || !value.actor || typeof value.path !== 'string' || !['save', 'undo', 'native-action'].includes(value.kind)
237
+ || value.beforeRevision !== sourceRevision(value.before) || value.afterRevision !== sourceRevision(value.after))) {
238
+ fail('state-corrupt', 'Operation receipt is inconsistent; no source will be written.', { operationId: id });
239
+ }
240
+ if (remember && value && value.state !== 'prepared') {
241
+ if (validatedReceipts.size >= 256) validatedReceipts.delete(validatedReceipts.keys().next().value);
242
+ validatedReceipts.set(id, { digest, summary: { id, state: value.state, path: Buffer.from(value.path).toString('utf8') } });
243
+ }
244
+ return value;
245
+ }
246
+ function receiptDigest(filePath) {
247
+ generated(filePath);
248
+ let fd;
249
+ const same = (a, b) => ['dev','ino','size','mtimeNs','ctimeNs','mode'].every(key => a[key] === b[key]);
250
+ try {
251
+ const before = lstatSync(filePath, { bigint: true });
252
+ if (!before.isFile() || before.size > 32n * 1024n * 1024n) throw new Error('size');
253
+ fd = openSync(filePath, 'r');
254
+ if (!same(before, fstatSync(fd, { bigint: true }))) throw new Error('changed');
255
+ const digest = createHash('sha256');
256
+ let count, bytes = 0;
257
+ while ((count = readSync(fd, receiptBuffer, 0, receiptBuffer.length, null)) > 0) {
258
+ bytes += count;
259
+ if (bytes > Number(before.size)) throw new Error('changed');
260
+ digest.update(receiptBuffer.subarray(0, count));
261
+ }
262
+ generated(filePath);
263
+ if (bytes !== Number(before.size) || !same(before, fstatSync(fd, { bigint: true }))
264
+ || !same(before, lstatSync(filePath, { bigint: true }))) throw new Error('changed');
265
+ return digest.digest('hex');
266
+ } catch { fail('state-corrupt', 'Private editor state requires inspection; it will not be overwritten.', { statePath: filePath }); }
267
+ finally { if (fd !== undefined) closeSync(fd); }
268
+ }
269
+ function scanReceipt(id) {
270
+ const cached = validatedReceipts.get(id);
271
+ if (cached && receiptDigest(operationPath(id)) === cached.digest) return cached.summary;
272
+ validatedReceipts.delete(id);
273
+ // A changed/new receipt is fully parsed and validated. Prepared receipts
274
+ // always retain the existing full-body recovery path, including restart.
275
+ return receipt(id, true);
276
+ }
277
+ function own(value, actor) { if (principalKey(value.actor) !== principalKey(actor)) fail('forbidden', 'This operation or draft belongs to another actor.'); }
278
+ function result(value) {
279
+ return { operationId: value.id, state: value.state, revision: value.state === 'committed' ? value.afterRevision : value.settledRevision ?? value.beforeRevision, actor: value.actor, grantId: value.grantId, affectedFiles: value.state === 'committed' && value.before !== value.after ? [value.path] : [], undoOf: value.undoOf ?? null };
280
+ }
281
+ function reconcile(value, current) {
282
+ if (value.state !== 'prepared') return value;
283
+ if (current === value.after) {
284
+ value = { ...value, state: 'committed', recovered: true };
285
+ writeState(operationPath(value.id), value);
286
+ } else if (current !== value.before) {
287
+ fail('repair-required', 'Publication outcome is unknown after an intervening edit; inspect the retained before/after images.', { operationId: value.id, currentRevision: sourceRevision(current) });
288
+ }
289
+ return value;
290
+ }
291
+ function claimPath(filePath) {
292
+ const key = canonicalPlanIdentity(filePath, config).key;
293
+ return generated(path.join(stateDir(config.repoRoot), 'ownership', `${key}.json`));
294
+ }
295
+ function claimGuard(actor, filePath) {
296
+ const ownership = readPlanOwnership(path.relative(config.repoRoot, filePath), config);
297
+ if (ownership?.corrupt) fail('claim-conflict', 'Repair corrupt ownership through the CLI before saving.');
298
+ // No implicit takeover, even of a dead claim. The human editor is distinct
299
+ // from the launching agent; release/reclaim remains an explicit CLI action.
300
+ if (ownership?.state === 'owned' && (actor.kind !== 'agent' || ownership.sessionId !== actor.session_id)) {
301
+ fail('claim-conflict', 'The plan is owned by another session; coordinate or release it through the CLI.', { sessionId: ownership.sessionId });
302
+ }
303
+ }
304
+ const domainHash = request => hash(JSON.stringify([request.action,request.note,request.optionId??null,request.at]));
305
+ function mutate(context, request, kind) {
306
+ requireId(request.operationId); requireRevision(request.expectedRevision);
307
+ const actor = identity(context);
308
+ const filePath = managed(request.path);
309
+ permit(actor, kind, filePath);
310
+ localState();
311
+ return withPathLocks([filePath, root, claimPath(filePath)], { repoRoot: config.repoRoot }, () => {
312
+ if (principalKey(identity(context)) !== principalKey(actor)) fail('unauthenticated', 'Actor identity changed while waiting for the operation lock.');
313
+ const grantId = permit(actor, kind, filePath);
314
+ claimGuard(actor, filePath);
315
+ const ownershipPath = claimPath(filePath);
316
+ const ownershipSnapshot = existsSync(ownershipPath) ? snapshotFile(ownershipPath) : null;
317
+ const snapshot = sourceSnapshot(filePath);
318
+ let prior = receipt(request.operationId);
319
+ if (prior) {
320
+ own(prior, actor);
321
+ if (prior.path !== filePath || prior.kind !== kind || prior.beforeRevision !== request.expectedRevision
322
+ || (kind !== 'undo' ? prior.after !== request.source : prior.undoOf !== request.undoOf)
323
+ || (kind === 'native-action' && prior.requestHash !== domainHash(request))) fail('operation-reused', 'Operation ID was already bound to a different request.');
324
+ prior = reconcile(prior, snapshot.content);
325
+ if (prior.state === 'committed') return { ...result(prior), replayed: true, currentRevision: sourceRevision(snapshot.content) };
326
+ if (prior.state !== 'prepared') fail('operation-settled', 'This operation was settled without applying; use a new ID after review.');
327
+ }
328
+ // Discover interrupted operations before a newer save obscures their
329
+ // publication evidence. A pending before-image is retried by its owner.
330
+ for (const name of readdirSync(operations)) {
331
+ if (!name.endsWith('.json')) continue;
332
+ const pending = scanReceipt(name.slice(0, -5));
333
+ if (pending.id !== request.operationId && pending.path === filePath && pending.state === 'prepared') {
334
+ const recovered = reconcile(pending, snapshot.content);
335
+ if (recovered.state === 'prepared') fail('operation-pending', 'Retry or settle the interrupted operation before saving.', { operationId: pending.id });
336
+ }
337
+ }
338
+ if (sourceRevision(snapshot.content) !== request.expectedRevision) conflict(request.expectedRevision, snapshot.content);
339
+ let after = request.source;
340
+ if (kind === 'undo') {
341
+ const original = receipt(request.undoOf);
342
+ if (!original) fail('operation-missing', 'Undo requires a retained committed operation.');
343
+ own(original, actor);
344
+ if (original.path !== filePath || original.state !== 'committed' || original.afterRevision !== request.expectedRevision) fail('undo-conflict', 'Undo requires the original operation’s exact resulting revision.');
345
+ // Compensating edit under a new operation ID, with an exact revision
346
+ // precondition. Never replay claims, lifecycle hooks, or Git state.
347
+ after = original.before;
348
+ }
349
+ if(kind==='native-action'){
350
+ if(typeof domainPrepare!=='function')fail('unsupported-operation','This adapter has no native domain writer.');
351
+ if(!prior && Math.abs(Date.now()-Date.parse(request.at))>5*60*1000)fail('review-expired','Reload and review this action with a fresh recording date.');
352
+ after=synchronous(domainPrepare(snapshot.content,request,actor));
353
+ if(after!==request.source)fail('review-conflict','The submitted source differs from the reviewed domain action.');
354
+ }else validateEdit(snapshot.content, after,legacyTypes,stagePolicy,kind==='undo');
355
+ const value = prior ? { ...prior, grantId } : { schema: 1, id: request.operationId, kind, undoOf: kind === 'undo' ? request.undoOf : null, path: filePath, actor, grantId, at: new Date().toISOString(), state: 'prepared', ...(kind==='native-action'?{requestHash:domainHash(request)}:{}), before: snapshot.content, after, beforeRevision: request.expectedRevision, afterRevision: sourceRevision(after) };
356
+ writeState(operationPath(value.id), value);
357
+ testHooks.afterPrepare?.(value);
358
+ try {
359
+ if (permit(actor, kind, filePath) !== grantId) fail('authority-changed', 'Authority changed during preparation; review and retry with current authority.');
360
+ if (ownershipSnapshot) assertSnapshotCurrent(ownershipSnapshot);
361
+ else if (existsSync(ownershipPath)) fail('claim-conflict', 'Ownership appeared while preparing the source save.');
362
+ if (after !== snapshot.content) replaceSnapshot(snapshot, after, { ...options, testHooks: testHooks.atomic });
363
+ testHooks.afterPublish?.(value);
364
+ } catch (error) {
365
+ // Keep WAL evidence when publication or durability is uncertain. No
366
+ // blind rollback can clobber an external editor's later generation.
367
+ if (error instanceof MutationConflictError) conflict(request.expectedRevision, sourceSnapshot(filePath).content);
368
+ throw error;
369
+ }
370
+ const committed = { ...value, state: 'committed' };
371
+ writeState(operationPath(value.id), committed);
372
+ return result(committed);
373
+ });
374
+ }
375
+ function draftPath(actor, id) { requireId(id); return generated(path.join(root, 'drafts', principalKey(actor), `${id}.json`)); }
376
+ function draft(context, request, action) {
377
+ const actor = identity(context), filePath = managed(request.path);
378
+ permit(actor, action, filePath);
379
+ const location = draftPath(actor, request.draftId);
380
+ if (action !== 'read-draft') localState();
381
+ return withPathLocks([location], { repoRoot: config.repoRoot }, () => {
382
+ permit(actor, action, filePath);
383
+ const previous = readState(location);
384
+ if (previous) {
385
+ const { revision, ...fields } = previous;
386
+ if (previous.id !== request.draftId || revision !== `sha256:${hash(json(fields))}`) fail('state-corrupt', 'Draft state is inconsistent; it will not be overwritten.');
387
+ own(previous, actor);
388
+ if (previous.path !== filePath) fail('draft-reused', 'Draft ID is already bound to another document.');
389
+ }
390
+ if (action === 'read-draft') {
391
+ if (!previous || previous.discarded) return null;
392
+ return { ...previous, stale: sourceRevision(sourceSnapshot(filePath).content) !== previous.baseRevision };
393
+ }
394
+ const previousRevision = previous?.discarded ? null : previous?.revision ?? null;
395
+ if (previousRevision !== request.expectedDraftRevision) fail('draft-conflict', 'A newer draft exists; it will not be overwritten.', { currentDraftRevision: previousRevision });
396
+ const discarded = action === 'discard-draft';
397
+ if (!discarded) {
398
+ text(request.source); text(request.baseSource); requireRevision(request.baseRevision);
399
+ if (sourceRevision(request.baseSource) !== request.baseRevision) fail('invalid-revision', 'Draft base source does not match its revision.');
400
+ }
401
+ const value = { schema: 1, id: request.draftId, actor, path: filePath, discarded, baseRevision: discarded ? null : request.baseRevision, baseSource: discarded ? null : request.baseSource, source: discarded ? null : request.source, at: new Date().toISOString() };
402
+ if (!discarded && request.pendingOperation != null) {
403
+ const pending = request.pendingOperation;
404
+ requireId(pending.operationId); requireRevision(pending.expectedRevision);
405
+ if (!['save','undo'].includes(pending.kind) || pending.expectedRevision !== request.baseRevision) fail('invalid-request','The interrupted save must refer to this draft base.');
406
+ if (pending.kind === 'undo') requireId(pending.undoOf);
407
+ value.pendingOperation = { operationId:pending.operationId,kind:pending.kind,expectedRevision:pending.expectedRevision,...(pending.kind === 'undo' ? {undoOf:pending.undoOf} : {}) };
408
+ }
409
+ value.revision = `sha256:${hash(json(value))}`;
410
+ writeState(location, value);
411
+ return value;
412
+ });
413
+ }
414
+ return Object.freeze({
415
+ listRecovery(context, {path:requestedPath=null} = {}) {
416
+ const actor=identity(context),items=[];let unavailable=0;
417
+ const candidates=(directory)=>{
418
+ generated(directory);if(!existsSync(directory))return [];
419
+ if(!lstatSync(directory).isDirectory())fail('unsafe-local-state','Recovery storage must be a directory.');
420
+ return readdirSync(directory).filter(name=>uuid.test(name.slice(0,-5))&&name.endsWith('.json'));
421
+ };
422
+ function scoped(value,action) {
423
+ own(value,actor);
424
+ const file=authorizeManagedDestination(value.path,config).path;
425
+ permit(actor,action,file);
426
+ if(requestedPath && path.resolve(config.repoRoot,requestedPath)!==file)return null;
427
+ return file;
428
+ }
429
+ const directory=generated(path.join(root,'drafts',principalKey(actor)));
430
+ for(const name of candidates(directory)) {
431
+ try {
432
+ const value=readState(generated(path.join(directory,name)));
433
+ if(!value||value.discarded)continue;
434
+ const {revision,...fields}=value;
435
+ if(value.id!==name.slice(0,-5)||revision!==`sha256:${hash(json(fields))}`||sourceRevision(value.baseSource)!==value.baseRevision)fail('state-corrupt','Draft needs inspection.');
436
+ const file=scoped(value,'read-draft');if(!file||value.source===value.baseSource&&!value.pendingOperation)continue;
437
+ let stale=false,available=true;
438
+ try{stale=sourceRevision(sourceSnapshot(managed(file)).content)!==value.baseRevision;}catch{available=false;}
439
+ items.push({kind:'draft',draftId:value.id,path:file,at:value.at,stale,available,pendingOperation:value.pendingOperation??null});
440
+ }catch{unavailable++;}
441
+ }
442
+ for(const name of candidates(operations)) {
443
+ try {
444
+ const raw=readState(operationPath(name.slice(0,-5)));
445
+ if(!raw||!raw.actor||principalKey(raw.actor)!==principalKey(actor)||raw.state!=='prepared')continue;
446
+ const value=receipt(name.slice(0,-5)),file=scoped(value,'inspect-operation');if(!file)continue;
447
+ let available=true;try{managed(file);}catch{available=false;}
448
+ items.push({kind:'operation',operationId:value.id,path:file,at:value.at,state:value.state,available});
449
+ }catch{unavailable++;}
450
+ }
451
+ return {items,unavailable};
452
+ },
453
+ read(context, request) {
454
+ const actor = identity(context), filePath = managed(request.path);
455
+ permit(actor, 'read', filePath);
456
+ const source = sourceSnapshot(filePath).content;
457
+ return { path: filePath, source, revision: sourceRevision(source), ...describe(source,legacyTypes) };
458
+ },
459
+ nativeAction: (context, request) => mutate(context, request, 'native-action'),
460
+ save: (context, request) => mutate(context, request, 'save'),
461
+ undo: (context, request) => mutate(context, request, 'undo'),
462
+ putDraft: (context, request) => draft(context, request, 'write-draft'),
463
+ readDraft: (context, request) => draft(context, request, 'read-draft'),
464
+ discardDraft: (context, request) => draft(context, request, 'discard-draft'),
465
+ inspectOperation(context, request) {
466
+ const actor = identity(context), value = receipt(request.operationId);
467
+ if (!value) return null;
468
+ own(value, actor); permit(actor, 'inspect-operation', managed(value.path));
469
+ return { ...value, currentRevision: sourceRevision(sourceSnapshot(value.path).content) };
470
+ },
471
+ settleOperation(context, request) {
472
+ requireRevision(request.expectedRevision);
473
+ if (request.disposition !== 'leave-current') fail('invalid-repair', 'Settlement requires an explicit leave-current disposition after inspection.');
474
+ const actor = identity(context), value = receipt(request.operationId);
475
+ if (!value) fail('operation-missing', 'No operation receipt exists.');
476
+ own(value, actor); const filePath = managed(value.path);
477
+ permit(actor, 'settle-operation', filePath); localState();
478
+ return withPathLocks([root, filePath], { repoRoot: config.repoRoot }, () => {
479
+ if (principalKey(identity(context)) !== principalKey(actor)) fail('unauthenticated', 'Actor identity changed while waiting for the operation lock.');
480
+ permit(actor, 'settle-operation', filePath);
481
+ const current = sourceSnapshot(filePath).content, latest = receipt(value.id);
482
+ if (sourceRevision(current) !== request.expectedRevision) conflict(request.expectedRevision, current);
483
+ if (latest.state !== 'prepared') return result(latest);
484
+ const state = current === latest.after ? 'committed' : current === latest.before ? 'not-applied' : 'settled-unknown';
485
+ const settled = { ...latest, state, settledAt: new Date().toISOString(), settledRevision: request.expectedRevision };
486
+ writeState(operationPath(value.id), settled);
487
+ return result(settled);
488
+ });
489
+ },
490
+ });
491
+ }
package/src/stages.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { parseSimpleFrontmatter } from './frontmatter.mjs';
2
+
3
+ /** Repository-defined delivery stages. Status remains a separate lifecycle field. */
4
+ function configuredMilestones(config) {
5
+ return (config?.raw ?? config)?.taxonomy?.milestones;
6
+ }
7
+
8
+ /** Missing and blank ships values are unset; never coerce other YAML values. */
9
+ export function readPlanStage(value) {
10
+ if (value == null) return { word: null, invalid: false };
11
+ if (typeof value !== 'string') return { word: null, invalid: true };
12
+ const word = value.trim() || null;
13
+ if (word && /[\r\n\0]/.test(value)) return { word: null, invalid: true };
14
+ return { word, invalid: false };
15
+ }
16
+
17
+ /** The YAML subset parser represents bare blank fields as empty lists.
18
+ * Distinguish a blank ships scalar from an explicitly authored empty list.
19
+ */
20
+ export function readShipsFrontmatter(frontmatter) {
21
+ const value = parseSimpleFrontmatter(frontmatter).ships;
22
+ if (Array.isArray(value) && value.length === 0) {
23
+ const firstShips = frontmatter.split(/\r?\n/).find(line => /^ships:/.test(line));
24
+ if (/^ships:[ \t]*$/.test(firstShips ?? '')) return null;
25
+ }
26
+ return value ?? null;
27
+ }
28
+
29
+ /** Accept the legacy string vocabulary and richer { word, meaning } entries. */
30
+ export function getStageDefinitions(config) {
31
+ const milestones = configuredMilestones(config);
32
+ if (!Array.isArray(milestones)) return [];
33
+ const definitions = [];
34
+ const seen = new Set();
35
+ for (const entry of milestones) {
36
+ const value = typeof entry === 'string' ? entry
37
+ : entry && typeof entry === 'object' && !Array.isArray(entry) ? entry.word : null;
38
+ const { word, invalid } = readPlanStage(value);
39
+ if (invalid) continue;
40
+ if (!word || seen.has(word)) continue;
41
+ seen.add(word);
42
+ definitions.push({ word, meaning: typeof entry?.meaning === 'string' ? entry.meaning.trim() : '' });
43
+ }
44
+ return definitions;
45
+ }
46
+
47
+ /** Configuration diagnostics use the same warning convention as other taxonomies. */
48
+ export function validateStageDefinitions(config) {
49
+ const milestones = configuredMilestones(config);
50
+ if (milestones == null) return [];
51
+ if (!Array.isArray(milestones)) return ['Config: taxonomy.milestones must be null or an array.'];
52
+ const warnings = [];
53
+ const seen = new Set();
54
+ for (const [index, entry] of milestones.entries()) {
55
+ const objectEntry = entry && typeof entry === 'object' && !Array.isArray(entry);
56
+ const value = typeof entry === 'string' ? entry : objectEntry ? entry.word : entry;
57
+ if (typeof value === 'string' && /[\r\n\0]/.test(value)) {
58
+ warnings.push(`Config: taxonomy.milestones[${index}] stage word must be a single line without NUL characters.`);
59
+ continue;
60
+ }
61
+ const { word, invalid } = readPlanStage(value);
62
+ if (invalid || !word) {
63
+ warnings.push(`Config: taxonomy.milestones[${index}] must be a nonempty string or an object with a nonempty string word.`);
64
+ continue;
65
+ }
66
+ if (objectEntry && typeof entry.meaning !== 'string') {
67
+ warnings.push(`Config: taxonomy.milestones[${index}].meaning must be a string.`);
68
+ }
69
+ if (seen.has(word)) warnings.push(`Config: taxonomy.milestones contains duplicate stage '${word}'.`);
70
+ seen.add(word);
71
+ }
72
+ return warnings;
73
+ }