runlist 0.0.0 → 0.94.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 (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -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 +292 -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/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
@@ -0,0 +1,269 @@
1
+ // Human-scoped yardstick reviews share CLI assessment and archive semantics.
2
+ import { fork } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readFileSync, lstatSync, mkdirSync, readdirSync, openSync, fsyncSync, closeSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { authorizeRepoGeneratedPath, authorizeManagedDestination } from './managed-path.mjs';
7
+ import { currentProcessOwner, processStartIdentity, processOwnerLiveness, inspectTransactions, withPathLocks, snapshotFile, replaceSnapshot, createFileExclusive } from './atomic-mutation.mjs';
8
+ import { readPlanOwnership, canonicalPlanIdentity } from './pickup.mjs';
9
+ import { stateDir } from './naming.mjs';
10
+ import { SourceEditError, sourceRevision, assertPrivateEditorStorage } from './source-editor.mjs';
11
+ import { compareYardstick, readYardstick } from './yardstick.mjs';
12
+ import { isArchivedPath } from './util.mjs';
13
+ import { isBundledRunlistRuntime } from './desktop-runtime.mjs';
14
+
15
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
16
+ const ACTIONS = new Set(['serves', 'clear', 'fold', 'close']);
17
+ const fail = (code, message) => { throw new SourceEditError(code, message); };
18
+ const json = value => JSON.stringify(value, null, 2) + '\n';
19
+ const activeJobs = new Set();
20
+ const relative = (root, file) => path.relative(root, file).split(path.sep).join('/');
21
+
22
+ function runWorker(request, onStart) {
23
+ return new Promise((resolve, reject) => {
24
+ const env = { ...process.env };
25
+ for (const key of Object.keys(env)) if (/SESSION|^CODEX_|^OPENCODE_/.test(key)) delete env[key];
26
+ const child = fork(new URL('./app-lifecycle-worker.mjs', import.meta.url), [], {
27
+ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
28
+ execArgv: isBundledRunlistRuntime(process.execPath) ? ['--max-old-space-size=256', '--max-semi-space-size=4'] : [],
29
+ });
30
+ let report = '', result, truncated = false;
31
+ const timer = setTimeout(() => { child.kill(); reject(new SourceEditError('yardstick-uncertain', 'The yardstick runner timed out. Inspect its retained operation before retrying.')); }, 60_000);
32
+ const append = data => { const text = String(data); if (report.length + text.length > 128000) truncated = true; report += text.slice(0, Math.max(0, 128000 - report.length)); };
33
+ child.stdout.on('data', append); child.stderr.on('data', append);
34
+ child.on('message', value => { result = value; });
35
+ child.on('error', error => { clearTimeout(timer); reject(error); });
36
+ child.on('exit', () => {
37
+ clearTimeout(timer);
38
+ if (!result) return reject(new SourceEditError('yardstick-uncertain', 'The yardstick runner stopped. Inspect its retained operation before retrying.'));
39
+ if (!result.ok) return reject(new SourceEditError(result.code === 'lifecycle-conflict' ? 'revision-conflict' : result.code, result.message, { report }));
40
+ resolve({ result: result.result, guards: result.guards, report: report.replace(/\x1b\[[0-9;]*m/g, '') + (truncated ? '\n[Report shortened.]\n' : '') });
41
+ });
42
+ try {
43
+ onStart?.({ ...currentProcessOwner(), pid: child.pid, processStartIdentity: processStartIdentity(child.pid), processStartedAt: new Date().toISOString() });
44
+ child.send({ ...request, kind: 'yardstick' });
45
+ } catch (error) { child.kill(); clearTimeout(timer); reject(error); }
46
+ });
47
+ }
48
+
49
+ export function createAppYardstick({ config, read, actor, pending = () => {} }) {
50
+ const directory = path.join(stateDir(config.repoRoot), 'editor', 'yardstick');
51
+ // Match the desktop's explicit no-config authority instead of discovering
52
+ // newly added JavaScript while applying a review from an unconfigured folder.
53
+ const configPath = config.configPath ?? path.join(config.repoRoot, '.runlist-desktop-no-config');
54
+ const safe = input => {
55
+ let file;
56
+ try { file = authorizeRepoGeneratedPath(input, config, { kind: 'Yardstick receipt' }).path; }
57
+ catch (error) { fail('unsafe-local-state', error.message); }
58
+ let current = config.repoRoot;
59
+ for (const part of path.relative(config.repoRoot, file).split(path.sep)) {
60
+ current = path.join(current, part);
61
+ if (existsSync(current) && lstatSync(current).isSymbolicLink()) fail('unsafe-local-state', 'Yardstick receipts may not traverse symlinks.');
62
+ }
63
+ return file;
64
+ };
65
+ const location = id => { if (!UUID.test(id ?? '')) fail('invalid-id', 'A UUID v4 operation is required.'); return safe(path.join(directory, `${id}.json`)); };
66
+ function storage() {
67
+ assertPrivateEditorStorage(config.repoRoot, directory);
68
+ const make = dir => {
69
+ safe(dir);
70
+ if (existsSync(dir)) { if (!lstatSync(dir).isDirectory()) fail('unsafe-local-state', 'Receipt storage must be a directory.'); return; }
71
+ make(path.dirname(dir)); mkdirSync(dir, { mode: 0o700 });
72
+ let fd;
73
+ try { fd = openSync(path.dirname(dir), 'r'); fsyncSync(fd); }
74
+ catch (error) { if (!['EINVAL', 'ENOTSUP', 'EPERM', 'EACCES', 'EBADF', 'EISDIR'].includes(error.code)) throw error; }
75
+ finally { if (fd !== undefined) closeSync(fd); }
76
+ };
77
+ make(directory);
78
+ }
79
+ function receipt(id) {
80
+ const file = location(id);
81
+ if (!existsSync(file)) return null;
82
+ if (!lstatSync(file).isFile() || lstatSync(file).size > 16 * 1024 * 1024) fail('state-corrupt', 'Inspect the yardstick receipt before continuing.');
83
+ try {
84
+ const value = JSON.parse(readFileSync(file, 'utf8'));
85
+ if (value.id !== id || value.schema !== 1 || !['reviewed', 'running', 'committed', 'failed', 'settled-unknown'].includes(value.state)
86
+ || !ACTIONS.has(value.action) || typeof value.path !== 'string' || typeof value.newPath !== 'string'
87
+ || value.file !== path.resolve(config.repoRoot, value.path) || !value.actor || !Array.isArray(value.guards)
88
+ || value.guards[0]?.path !== value.file || typeof value.guards[0]?.expectedContent !== 'string'
89
+ || sourceRevision(value.guards[0].expectedContent) !== value.expectedRevision
90
+ || value.reason !== null && typeof value.reason !== 'string' || value.into !== null && typeof value.into !== 'string') throw new Error('invalid');
91
+ return value;
92
+ } catch { fail('state-corrupt', 'Inspect the yardstick receipt before continuing.'); }
93
+ }
94
+ function write(value) {
95
+ const file = location(value.id), content = json(value);
96
+ if (Buffer.byteLength(content) > 16 * 1024 * 1024) fail('review-too-large', 'This review is too large to retain safely. Use the CLI for this assessment.');
97
+ if (existsSync(file)) replaceSnapshot(snapshotFile(file), content, { repoRoot: config.repoRoot, locked: true });
98
+ else createFileExclusive(file, content, { repoRoot: config.repoRoot, locked: true, mode: 0o600 });
99
+ }
100
+ function own(context, value) {
101
+ const current = actor(context);
102
+ if (current.kind !== 'human' || current.kind !== value.actor.kind || current.id !== value.actor.id) fail('forbidden', 'This yardstick review belongs to another actor.');
103
+ }
104
+ // Cross-domain guard: inspect all receipts, regardless of actor ownership.
105
+ // Retained reviews do not lock a source; only uncertain/running writes do.
106
+ function assertIdle(_context, input, exceptId = null) {
107
+ if (existsSync(directory)) {
108
+ safe(directory);
109
+ for (const name of readdirSync(directory).filter(n => n.endsWith('.json'))) {
110
+ const value = receipt(name.slice(0, -5));
111
+ if (value?.id !== exceptId && value?.state === 'running' && [value.path, value.newPath].includes(input)) fail('yardstick-repair-required', 'Inspect the interrupted yardstick operation before starting another change.');
112
+ }
113
+ }
114
+ }
115
+ function eligible(context, input, exceptId = null) {
116
+ const doc = read(context, input);
117
+ if (doc.type !== 'plan') fail('yardstick-unavailable', 'Yardstick assessments apply only to plans.');
118
+ if (!doc.editable) fail('yardstick-unavailable', 'Repair this plan’s source before recording a yardstick assessment.');
119
+ if (doc.metadata?.record_schema) fail('yardstick-unavailable', 'Structured plans can display the yardstick; their assessment and archive actions are not available yet.');
120
+ const claim = readPlanOwnership(doc.path, config);
121
+ if (claim?.corrupt || claim?.state === 'owned' || doc.status === 'in-session') fail('claim-conflict', 'This plan is claimed. Coordinate or release its claim through the CLI first.');
122
+ assertIdle(context, doc.path, exceptId);
123
+ pending(context, doc.path, exceptId);
124
+ return doc;
125
+ }
126
+ const comparison = doc => compareYardstick(doc.metadata, config, readYardstick(config), isArchivedPath(doc.path, config) && config.lifecycle.archiveStatuses.has(doc.status));
127
+ function information(context, input) {
128
+ const doc = read(context, input), compared = comparison(doc);
129
+ try {
130
+ eligible(context, input);
131
+ const actions = compared.goal.state === 'ready' ? [...ACTIONS] : ['clear'];
132
+ return { path: doc.path, comparison: compared, enabled: true, actions };
133
+ } catch (error) { return { path: doc.path, comparison: compared, enabled: false, actions: [], reason: error.message }; }
134
+ }
135
+ function argv(value) {
136
+ return [value.action === 'serves' ? 'mark' : value.action, value.file,
137
+ ...(value.action === 'serves' ? ['serves'] : []), ...(value.reason ? ['--reason', value.reason] : []),
138
+ ...(value.action === 'fold' ? ['--into', value.into] : [])];
139
+ }
140
+ const requestFor = (value, dryRun) => ({ repoRoot: config.repoRoot, configPath,
141
+ argv: argv(value), dryRun, guards: value.guards, ...(dryRun ? {} : { expectedDestination: path.resolve(config.repoRoot, value.newPath) }) });
142
+ const publicReview = value => ({ kind: 'yardstick', operationId: value.id, path: value.path, newPath: value.newPath,
143
+ action: value.action, reason: value.reason, into: value.into ? relative(config.repoRoot, value.into) : null, expectedRevision: value.expectedRevision,
144
+ assessment: value.assessment, comparison: value.comparison ?? null, report: value.report, state: value.state, result: value.result ?? null });
145
+ async function preview(context, request) {
146
+ if (!request || Object.keys(request).some(key => !['path', 'action', 'reason', 'into', 'expectedRevision', 'operationId'].includes(key))) fail('invalid-request', 'Supply a plan, assessment and source revision.');
147
+ const doc = eligible(context, request.path), who = actor(context);
148
+ if (who.kind !== 'human') fail('forbidden', 'The application yardstick runner is human scoped.');
149
+ location(request.operationId);
150
+ if (!ACTIONS.has(request.action)) fail('invalid-request', 'Choose Serves, Clear, Fold or Close.');
151
+ if (doc.revision !== request.expectedRevision) fail('revision-conflict', 'The plan changed. Reload it before reviewing an assessment.');
152
+ if (request.reason !== undefined && (typeof request.reason !== 'string' || !request.reason.trim() || request.reason.length > 4000 || request.reason.includes('\0'))) fail('invalid-request', 'Give a nonempty reason, up to 4,000 characters.');
153
+ if (['fold', 'close'].includes(request.action) && !request.reason?.trim()) fail('invalid-request', 'Closing or folding requires an owner reason.');
154
+ let into = null;
155
+ if (request.action === 'fold') {
156
+ if (typeof request.into !== 'string' || !request.into.trim()) fail('invalid-request', 'Select an existing replacement plan.');
157
+ const target = read(context, request.into);
158
+ if (target.type !== 'plan') fail('invalid-request', 'Select an existing replacement plan.');
159
+ into = path.resolve(config.repoRoot, target.path);
160
+ } else if (request.into !== undefined) fail('invalid-request', 'Only Fold can name a replacement plan.');
161
+ const file = path.resolve(config.repoRoot, doc.path), owner = path.join(stateDir(config.repoRoot), 'ownership', canonicalPlanIdentity(file, config).key + '.json');
162
+ const guards = [{ path: file, expectedContent: doc.source }, existsSync(owner) ? { path: owner, expectedContent: readFileSync(owner, 'utf8') } : { path: owner, absent: true }];
163
+ guards.push(existsSync(configPath) ? { path: configPath, expectedContent: readFileSync(configPath, 'utf8') } : { path: configPath, absent: true });
164
+ const reason = request.reason?.trim() ?? null;
165
+ const requestHash = createHash('sha256').update(JSON.stringify([doc.path, doc.revision, request.action, reason, into, who.id])).digest('hex');
166
+ const prior = receipt(request.operationId);
167
+ if (prior) { own(context, prior); if (prior.requestHash !== requestHash) fail('operation-reused', 'This ID already belongs to another yardstick review.'); return publicReview(prior); }
168
+ const value = { schema: 1, id: request.operationId, state: 'reviewed', actor: who, requestHash,
169
+ path: doc.path, file, action: request.action, reason, into, guards, expectedRevision: doc.revision, at: new Date().toISOString() };
170
+ const prepared = await runWorker(requestFor(value, true));
171
+ // Every replacement link must obey the same scope as the opened plan.
172
+ const known = new Set([...guards.map(guard => guard.path), readYardstick(config).snapshot?.path]);
173
+ for (const guard of prepared.guards) if (!known.has(guard.path)) {
174
+ const replacement = read(context, relative(config.repoRoot, guard.path));
175
+ if (replacement.type !== 'plan') fail('invalid-request', 'The replacement chain contains an unavailable plan.');
176
+ }
177
+ value.guards = prepared.guards;
178
+ value.newPath = prepared.result.path;
179
+ value.assessment = prepared.result.assessment;
180
+ value.comparison = prepared.result.comparison;
181
+ value.report = prepared.report;
182
+ storage();
183
+ return withPathLocks([location(value.id)], { repoRoot: config.repoRoot }, () => {
184
+ const concurrent = receipt(value.id);
185
+ if (concurrent) { own(context, concurrent); if (concurrent.requestHash !== requestHash) fail('operation-reused', 'This ID already belongs to another yardstick review.'); return publicReview(concurrent); }
186
+ write(value); return publicReview(value);
187
+ });
188
+ }
189
+ async function commit(context, { operationId }) {
190
+ storage();
191
+ const current = withPathLocks([location(operationId)], { repoRoot: config.repoRoot }, () => {
192
+ const value = receipt(operationId);
193
+ if (!value) fail('operation-missing', 'Review this yardstick change first.');
194
+ own(context, value);
195
+ if (value.state === 'committed') return value;
196
+ if (value.state === 'running') fail('yardstick-repair-required', 'Inspect the interrupted yardstick operation before applying another change.');
197
+ if (value.state === 'settled-unknown') fail('yardstick-settled', 'This operation was acknowledged without replay. Review a new change against the current source.');
198
+ if (value.state === 'failed') fail(value.failure.code, value.failure.message);
199
+ const doc = eligible(context, value.path, value.id);
200
+ if (doc.revision !== value.expectedRevision) fail('revision-conflict', 'The source changed since this review. Reload and review it again.');
201
+ value.state = 'running'; value.parentOwner = currentProcessOwner(); write(value); return value;
202
+ });
203
+ if (current.state === 'committed') return { ...publicReview(current), replayed: true };
204
+ const activeKey = config.repoRoot + ':' + operationId;
205
+ activeJobs.add(activeKey);
206
+ try {
207
+ const outcome = await runWorker(requestFor(current, false), owner => withPathLocks([location(operationId)], { repoRoot: config.repoRoot }, () => { const value = receipt(operationId); value.workerOwner = owner; write(value); }));
208
+ return withPathLocks([location(operationId)], { repoRoot: config.repoRoot }, () => {
209
+ const value = receipt(operationId);
210
+ if (value.state !== 'running') fail('yardstick-repair-required', 'The retained yardstick outcome changed while the runner was finishing. Inspect it before continuing.');
211
+ value.state = 'committed'; value.result = outcome.result; value.report = outcome.report; value.newPath = outcome.result.path; write(value); return publicReview(value);
212
+ });
213
+ } catch (error) {
214
+ withPathLocks([location(operationId)], { repoRoot: config.repoRoot }, () => {
215
+ const value = receipt(operationId); value.failure = { code: error.code ?? 'yardstick-failed', message: error.message };
216
+ if (error.code !== 'yardstick-uncertain' && existsSync(current.file) && sourceRevision(readFileSync(current.file, 'utf8')) === current.expectedRevision
217
+ && inspectTransactions(config.repoRoot).every(tx => tx.generation === 'old' && tx.resolvable)) value.state = 'failed';
218
+ write(value);
219
+ });
220
+ throw error;
221
+ } finally { activeJobs.delete(activeKey); }
222
+ }
223
+ function currentDocument(context, value) {
224
+ const destination = path.resolve(config.repoRoot, value.newPath);
225
+ return existsSync(destination) ? read(context, value.newPath) : existsSync(value.file) ? read(context, value.path) : null;
226
+ }
227
+ const running = value => activeJobs.has(config.repoRoot + ':' + value.id)
228
+ || (value.workerOwner ? processOwnerLiveness(value.workerOwner) !== 'dead' : value.parentOwner && processOwnerLiveness(value.parentOwner) !== 'dead');
229
+ function inspect(context, { operationId }) {
230
+ const value = receipt(operationId);
231
+ if (!value) return null;
232
+ own(context, value);
233
+ return { ...publicReview(value), canAcknowledge: value.state === 'running' && !running(value), failure: value.failure ?? null,
234
+ settlement: value.settlement ?? null, current: currentDocument(context, value) };
235
+ }
236
+ function settle(context, { operationId, expectedRevision, note }) {
237
+ storage();
238
+ const existing = receipt(operationId);
239
+ if (!existing) fail('operation-missing', 'There is no retained yardstick operation.');
240
+ own(context, existing);
241
+ return withPathLocks([location(operationId), existing.file, path.resolve(config.repoRoot, existing.newPath)], { repoRoot: config.repoRoot }, () => {
242
+ const value = receipt(operationId); own(context, value);
243
+ if (value.state !== 'running') fail('invalid-request', 'Only an uncertain yardstick operation can be acknowledged.');
244
+ if (running(value)) fail('operation-running', 'The yardstick runner is still active or cannot be verified as stopped.');
245
+ if (typeof note !== 'string' || !note.trim() || note.length > 4000) fail('invalid-request', 'Record what you inspected, up to 4,000 characters.');
246
+ if (inspectTransactions(config.repoRoot).length) fail('yardstick-repair-required', 'Inspect and resolve CLI transactions with runlist doctor --transactions first.');
247
+ const current = currentDocument(context, value);
248
+ if (!current || current.revision !== expectedRevision) fail('revision-conflict', 'The source changed. Inspect the operation again before acknowledging it.');
249
+ value.state = 'settled-unknown'; value.settlement = { by: actor(context), at: new Date().toISOString(), reason: note.trim(), outcome: 'unknown',
250
+ current: { path: current.path, revision: current.revision, status: current.status } }; write(value);
251
+ return { ...publicReview(value), current, settlement: value.settlement };
252
+ });
253
+ }
254
+ function list(context) {
255
+ safe(directory);
256
+ if (!existsSync(directory)) return { items: [], unavailable: 0 };
257
+ const items = []; let unavailable = 0;
258
+ for (const name of readdirSync(directory).filter(n => n.endsWith('.json') && UUID.test(n.slice(0, -5)))) try {
259
+ const value = receipt(name.slice(0, -5));
260
+ if (value.actor.id !== actor(context).id || !['reviewed', 'running', 'failed'].includes(value.state)) continue;
261
+ own(context, value); authorizeManagedDestination(path.resolve(config.repoRoot, value.newPath), config);
262
+ const doc = currentDocument(context, value);
263
+ if (!doc) { unavailable++; continue; }
264
+ items.push({ kind: 'yardstick', path: doc.path, operationId: value.id, state: value.state, action: value.action, at: value.at, available: true, newPath: value.newPath });
265
+ } catch { unavailable++; }
266
+ return { items, unavailable };
267
+ }
268
+ return { information, preview, commit, inspect, settle, list, assertIdle };
269
+ }
package/src/app.mjs ADDED
@@ -0,0 +1,102 @@
1
+ import {createCheckoutService,localActor,appError} from './app-service.mjs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { SourceEditError } from './source-editor.mjs';
7
+ import { resolveDocArg } from './index.mjs';
8
+
9
+ const assets = new URL('../assets/app/', import.meta.url);
10
+ const ttl = 12 * 60 * 60 * 1000;
11
+ const secret = () => randomBytes(32).toString('base64url');
12
+ function problem(code,message) { throw new SourceEditError(code,message); }
13
+ function relative(filePath,config) { return path.relative(config.repoRoot,filePath).split(path.sep).join('/'); }
14
+ async function bodyJson(req) {
15
+ if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] ?? '')) problem('invalid-request', 'Use a JSON request body.');
16
+ const chunks = []; let size = 0;
17
+ for await (const chunk of req) { size += chunk.length; if (size > 32 * 1024 * 1024) problem('invalid-request', 'Request is too large.'); chunks.push(chunk); }
18
+ try { const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('object'); return value; }
19
+ catch { problem('invalid-request', 'Invalid JSON request body.'); }
20
+ }
21
+
22
+ export async function startApp({ config, port = 5173, initialPath = null, actor = localActor() }) {
23
+ if (!Number.isInteger(port) || port < 0 || port > 65535) problem('invalid-port', 'Port must be an integer from 0 to 65535.');
24
+ if (actor.kind !== 'human' || !actor.id) problem('unauthenticated', 'The local application runs as an explicitly identified human.');
25
+ const capability = secret(), sessions = new Map();
26
+ let origin, cookieName;
27
+ function session(req) {
28
+ const cookie = (req.headers.cookie ?? '').split(';').map(s => s.trim()).find(s => s.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
29
+ const value = sessions.get(cookie);
30
+ if (!value || value.expires < Date.now()) { sessions.delete(cookie); problem('unauthenticated', 'Open the access link printed by runlist app to connect.'); }
31
+ return value;
32
+ }
33
+ const service=createCheckoutService({config,initialPath,actor,authenticate:req=>session(req).actor});
34
+ const server = createServer(async (req, res) => {
35
+ const send = (status, value, type = 'application/json; charset=utf-8') => {
36
+ res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" });
37
+ res.end(type.startsWith('application/json') ? JSON.stringify(value) : value);
38
+ };
39
+ try {
40
+ if (!origin || ![new URL(origin).host, `localhost:${server.address().port}`].includes(req.headers.host)) problem('forbidden', 'Invalid local application host.');
41
+ const url = new URL(req.url, origin);
42
+ if (req.method === 'GET' && url.pathname === '/') return send(200, readFileSync(new URL('index.html', assets)), 'text/html; charset=utf-8');
43
+ const staticFiles = { '/document-create.mjs':['document-create.mjs','text/javascript'], '/quick-navigation.mjs':['quick-navigation.mjs','text/javascript'], '/recovery-center.mjs':['recovery-center.mjs','text/javascript'], '/transport.mjs':['transport.mjs','text/javascript'], '/settings.mjs':['settings.mjs','text/javascript'], '/document-lifecycle.mjs':['document-lifecycle.mjs','text/javascript'], '/record-navigation.mjs':['record-navigation.mjs','text/javascript'], '/theme.mjs':['theme.mjs','text/javascript'], '/library-navigation.mjs':['library-navigation.mjs','text/javascript'], '/outline.mjs':['outline.mjs','text/javascript'], '/editor-navigation.mjs':['editor-navigation.mjs','text/javascript'], '/template-editor.mjs':['template-editor.mjs','text/javascript'], '/block-model.mjs':['block-model.mjs','text/javascript'], '/block-editor.mjs':['block-editor.mjs','text/javascript'], '/app.mjs': ['app.mjs','text/javascript'], '/shared.mjs':['shared.mjs','text/javascript'], '/app.css':['app.css','text/css'] };
44
+ staticFiles['/git-changes.mjs']=['git-changes.mjs','text/javascript'];
45
+ staticFiles['/git-commit.mjs']=['git-commit.mjs','text/javascript'];
46
+ if (req.method === 'GET' && staticFiles[url.pathname]) { const [file, type] = staticFiles[url.pathname]; return send(200, readFileSync(new URL(file, assets)), `${type}; charset=utf-8`); }
47
+ const requestOrigin = req.headers.origin;
48
+ const permittedOrigins = [origin, `http://localhost:${server.address().port}`];
49
+ if (requestOrigin && !permittedOrigins.includes(requestOrigin)) problem('forbidden', 'Cross-origin requests are refused.');
50
+ if (req.method === 'POST' && url.pathname === '/api/session') {
51
+ if (!permittedOrigins.includes(requestOrigin)) problem('forbidden', 'Session connection requires the local application origin.');
52
+ const body = await bodyJson(req);
53
+ if (body.token !== capability) problem('unauthenticated', 'This access link is invalid or belongs to a stopped server.');
54
+ for (const [key, value] of sessions) if (value.expires < Date.now()) sessions.delete(key);
55
+ if (sessions.size >= 100) problem('forbidden', 'Too many browser sessions; restart the local server.');
56
+ const token = secret(), value = { actor: { ...actor }, csrf: secret(), expires: Date.now() + ttl };
57
+ sessions.set(token, value);
58
+ res.setHeader('Set-Cookie', `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${ttl / 1000}`);
59
+ return send(200, { actor: value.actor, csrf: value.csrf });
60
+ }
61
+ const authenticated = session(req);
62
+ if (req.method === 'GET' && url.pathname === '/api/session') return send(200, { actor: authenticated.actor, csrf: authenticated.csrf });
63
+ let body;
64
+ if (req.method === 'POST') {
65
+ if (!permittedOrigins.includes(requestOrigin) || req.headers['x-runlist-csrf'] !== authenticated.csrf) problem('forbidden', 'Mutation requires the authenticated session and CSRF token.');
66
+ body=await bodyJson(req);
67
+ if (url.pathname === '/api/logout') { for (const [key,value] of sessions) if (value===authenticated) sessions.delete(key); return send(200,{disconnected:true}); }
68
+ }
69
+ return send(200,await service.request(req,url.pathname+url.search,body));
70
+ } catch (error) {
71
+ const code = error.code || 'request-failed';
72
+ const status = code === 'unauthenticated' ? 401 : code === 'forbidden' ? 403 : /conflict|reused|pending|repair|required|settled/.test(code) ? 409 : 400;
73
+ send(status, appError(error));
74
+ }
75
+ });
76
+ server.on('close',()=>service.close());
77
+ server.requestTimeout = 15_000;
78
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
79
+ origin = `http://127.0.0.1:${server.address().port}`;
80
+ cookieName = `runlist_session_${server.address().port}`;
81
+ const launchUrl = `${origin}/#connect=${capability}${initialPath ? `&path=${encodeURIComponent(initialPath)}` : ''}`;
82
+ return { server, origin, launchUrl, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
83
+ }
84
+
85
+ export async function runApp(argv, config, { dryRun = false } = {}) {
86
+ let port = 5173, input = null;
87
+ for (let i = 0; i < argv.length; i++) {
88
+ if (argv[i] === '--port') { port = Number(argv[++i]); continue; }
89
+ if (!argv[i].startsWith('-')) input = argv[i];
90
+ }
91
+ const initial = input ? resolveDocArg(input, config) : null;
92
+ const initialPath = initial ? relative(initial, config) : null;
93
+ if (!Number.isInteger(port) || port < 1 || port > 65535) problem('invalid-port', 'Use a port between 1 and 65535.');
94
+ if (dryRun) { process.stdout.write(`Would open the plan editor for ${config.repoRoot} on 127.0.0.1:${port}${initialPath ? ` (${initialPath})` : ''}.\n`); return; }
95
+ const app = await startApp({ config, port, initialPath });
96
+ process.stdout.write(`Runlist · ${config.repoRoot}\nOpen this local access link:\n${app.launchUrl}\nSave writes Markdown. Review local commits in Changes; share through Git. Ctrl+C stops the server.\n`);
97
+ await new Promise(resolve => {
98
+ const stop = () => { app.server.close(); app.server.closeIdleConnections(); };
99
+ process.once('SIGINT', stop); process.once('SIGTERM', stop);
100
+ app.server.once('close', () => { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); resolve(); });
101
+ });
102
+ }