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,201 @@
1
+ import { die, isArchivedPath, truncate } from './util.mjs';
2
+ import { isCoordinationHub } from './runlist.mjs';
3
+ import { readPlanOwnership, ownershipLiveness } from './pickup.mjs';
4
+ import {
5
+ actionablePromptStatuses,
6
+ comparePromptDocs,
7
+ compareStrings,
8
+ resolveStatusMetadata,
9
+ statusMetadataFor,
10
+ } from './status-metadata.mjs';
11
+
12
+ export const AGENT_CONTEXT_SCHEMA = Object.freeze({ name: 'dotmd.agent-context', version: 1 });
13
+ export const DEFAULT_AGENT_CONTEXT_BYTES = 16 * 1024;
14
+ export const AGENT_CONTEXT_SECTIONS = Object.freeze(['statusVocabulary', 'counts', 'prompts', 'plans', 'issues']);
15
+
16
+ export function parseAgentContextOptions(args) {
17
+ const value = name => args.includes(name) ? args[args.indexOf(name) + 1] : undefined;
18
+ const rawBudget = value('--max-bytes');
19
+ const maxBytes = rawBudget === undefined ? DEFAULT_AGENT_CONTEXT_BYTES : Number(rawBudget);
20
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 2048) die('--max-bytes takes a whole number of bytes, at least 2048.');
21
+ const rawSections = value('--sections');
22
+ const sections = rawSections === undefined ? null : [...new Set(rawSections.split(',').map(s => s.trim()).filter(Boolean))];
23
+ if (sections && (!sections.length || sections.some(s => !AGENT_CONTEXT_SECTIONS.includes(s)))) {
24
+ die(`--sections is a comma-separated selection of ${AGENT_CONTEXT_SECTIONS.join(', ')}.`);
25
+ }
26
+ return { maxBytes, sections };
27
+ }
28
+
29
+ const LIMITS = Object.freeze({ vocabulary: 32, prompts: 8, plans: 12, hubs: 8, issues: 10 });
30
+
31
+ export function boundedCollection(items, limit) {
32
+ const selected = items.slice(0, limit);
33
+ return {
34
+ total: items.length,
35
+ shown: selected.length,
36
+ truncated: selected.length < items.length,
37
+ items: selected,
38
+ };
39
+ }
40
+
41
+ function compactDoc(doc, config) {
42
+ const blockers = (doc.blockers ?? []).map(item => truncate(String(item), 160));
43
+ const ownership = doc.type === 'plan' ? readPlanOwnership(doc.path, config) : null;
44
+ return {
45
+ path: doc.path,
46
+ title: truncate(doc.title ?? '', 160),
47
+ status: doc.status,
48
+ type: doc.type,
49
+ nextStep: doc.nextStep ? truncate(doc.nextStep, 300) : null,
50
+ blockers: boundedCollection(blockers, 3),
51
+ daysSinceUpdate: doc.daysSinceUpdate ?? null,
52
+ ...(doc.owner ? { owner: doc.owner } : {}),
53
+ ...(ownership ? { claim: ownership.corrupt ? { state: 'unverifiable', reason: ownership.reason } : {
54
+ state: ownership.state, sessionId: ownership.sessionId,
55
+ claimedAt: ownership.claimedAt, liveness: ownershipLiveness(ownership),
56
+ } } : {}),
57
+ };
58
+ }
59
+
60
+ function docCollection(docs, limit, config) {
61
+ return { total: docs.length, shown: Math.min(docs.length, limit), truncated: docs.length > limit, items: docs.slice(0, limit).map(doc => compactDoc(doc, config)) };
62
+ }
63
+
64
+ function enforceByteBudget(context, maxBytes) {
65
+ context.budget = { maxBytes, bytes: 0, truncated: false };
66
+ const measure = () => {
67
+ for (let i = 0; i < 4; i++) context.budget.bytes = Buffer.byteLength(JSON.stringify(context, null, 2) + '\n');
68
+ return context.budget.bytes;
69
+ };
70
+ const collections = [
71
+ context.plans?.stale, context.plans?.listed, context.plans?.hubs,
72
+ context.issues?.warnings,
73
+ ...Object.values(context.statusVocabulary ?? {}).reverse(),
74
+ context.issues?.errors, context.prompts?.actionable, context.plans?.focus,
75
+ ].filter(Boolean);
76
+ const protectedItem = (collection, item, i) => {
77
+ if (collection === context.plans?.focus || collection === context.plans?.listed) {
78
+ if (item.claim?.state === 'owned' || item.claim?.state === 'unverifiable' || item.owner) return true;
79
+ if (collection === context.plans?.focus && i === 0) return true;
80
+ }
81
+ return i === 0 && (collection === context.prompts?.actionable || collection === context.issues?.errors);
82
+ };
83
+ while (measure() > maxBytes) {
84
+ let removed = false;
85
+ for (const collection of collections) {
86
+ const i = collection.items.findLastIndex((item, i) => !protectedItem(collection, item, i));
87
+ if (i < 0) continue;
88
+ collection.items.splice(i, 1);
89
+ collection.shown = collection.items.length;
90
+ collection.truncated = collection.shown < collection.total;
91
+ context.budget.truncated = true;
92
+ removed = true;
93
+ break;
94
+ }
95
+ if (!removed) die(`Agent context needs ${context.budget.bytes} bytes to retain its counts, claims, next prompt and first action; increase --max-bytes or select fewer --sections.`);
96
+ }
97
+ return context;
98
+ }
99
+
100
+ function rankMap(metadata, type) {
101
+ return new Map((metadata.byType[type] ?? []).map(item => [item.name, item.rank]));
102
+ }
103
+
104
+ function compareByStatusAndPath(ranks) {
105
+ return (a, b) => (ranks.get(a.status) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(b.status) ?? Number.MAX_SAFE_INTEGER)
106
+ || compareStrings(a.path, b.path);
107
+ }
108
+
109
+ function issueItem(issue) {
110
+ return { path: issue.path ?? null, message: truncate(issue.message ?? String(issue), 300) };
111
+ }
112
+
113
+ export function buildAgentContext(index, config, options = {}) {
114
+ const maxBytes = options.maxBytes ?? DEFAULT_AGENT_CONTEXT_BYTES;
115
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 2048) die('--max-bytes takes a whole number of bytes, at least 2048.');
116
+ const sections = options.sections ?? AGENT_CONTEXT_SECTIONS;
117
+ if (!Array.isArray(sections) || !sections.length || sections.some(s => !AGENT_CONTEXT_SECTIONS.includes(s))) die('Unknown agent-context section.');
118
+ const metadata = resolveStatusMetadata(config);
119
+ const planRanks = rankMap(metadata, 'plan');
120
+ const closed = (doc) => isArchivedPath(doc.path, config)
121
+ || config.lifecycle.archiveStatuses.has(doc.status)
122
+ || config.lifecycle.terminalStatuses.has(doc.status);
123
+ const livePlans = index.docs.filter(doc => doc.type === 'plan' && !closed(doc));
124
+ const hubs = livePlans.filter(isCoordinationHub).sort((a, b) => compareStrings(a.path, b.path));
125
+ const leaves = livePlans.filter(doc => !isCoordinationHub(doc));
126
+ const focus = leaves
127
+ .filter(doc => statusMetadataFor(config, 'plan', doc.status)?.context === 'expanded')
128
+ .sort(compareByStatusAndPath(planRanks));
129
+ const listed = leaves
130
+ .filter(doc => statusMetadataFor(config, 'plan', doc.status)?.context === 'listed')
131
+ .sort(compareByStatusAndPath(planRanks));
132
+ const stale = leaves
133
+ .filter(doc => doc.isStale && !statusMetadataFor(config, 'plan', doc.status)?.skipStale)
134
+ .sort((a, b) => (b.daysSinceUpdate ?? -1) - (a.daysSinceUpdate ?? -1)
135
+ || compareByStatusAndPath(planRanks)(a, b));
136
+
137
+ const promptStatuses = actionablePromptStatuses(config);
138
+ const prompts = index.docs
139
+ .filter(doc => doc.type === 'prompt' && promptStatuses.has(doc.status) && !closed(doc))
140
+ .sort(comparePromptDocs);
141
+
142
+ const vocabulary = {};
143
+ const vocabularyTypes = options.types?.length
144
+ ? metadata.typeOrder.filter(type => options.types.includes(type))
145
+ : metadata.typeOrder;
146
+ for (const type of vocabularyTypes) {
147
+ vocabulary[type] = boundedCollection(
148
+ (metadata.byType[type] ?? []).map(item => ({
149
+ name: item.name,
150
+ context: item.context,
151
+ staleDays: item.staleDays,
152
+ startable: item.startable,
153
+ terminal: item.terminal,
154
+ archive: item.archive,
155
+ })),
156
+ LIMITS.vocabulary,
157
+ );
158
+ }
159
+
160
+ const issueSort = (a, b) => compareStrings(a.path ?? '', b.path ?? '')
161
+ || compareStrings(a.message ?? '', b.message ?? '');
162
+ const errors = [...index.errors].sort(issueSort).map(issueItem);
163
+ const warnings = [...index.warnings].sort(issueSort).map(issueItem);
164
+
165
+ const context = {
166
+ schema: AGENT_CONTEXT_SCHEMA,
167
+ generatedAt: options.generatedAt ?? new Date().toISOString(),
168
+ scope: {
169
+ roots: options.roots?.length ? [...options.roots] : null,
170
+ types: options.types?.length ? [...options.types] : null,
171
+ ...(options.sections ? { sections: [...sections] } : {}),
172
+ },
173
+ statusVocabulary: vocabulary,
174
+ counts: {
175
+ documents: index.docs.length,
176
+ byStatus: { ...index.countsByStatus },
177
+ byType: Object.fromEntries(Object.entries(index.countsByType).map(([type, counts]) => [type, { ...counts }])),
178
+ errors: index.errors.length,
179
+ warnings: index.warnings.length,
180
+ },
181
+ prompts: {
182
+ actionable: docCollection(prompts, LIMITS.prompts, config),
183
+ next: prompts[0] ? compactDoc(prompts[0], config) : null,
184
+ },
185
+ plans: {
186
+ focus: docCollection(focus, LIMITS.plans, config),
187
+ listed: docCollection(listed, LIMITS.plans, config),
188
+ stale: docCollection(stale, LIMITS.plans, config),
189
+ hubs: docCollection(hubs, LIMITS.hubs, config),
190
+ },
191
+ issues: {
192
+ errors: boundedCollection(errors, LIMITS.issues),
193
+ warnings: boundedCollection(warnings, LIMITS.issues),
194
+ },
195
+ ...(options.skippedHooks?.length ? {
196
+ validationPreview: { status: 'built-in-only', skippedHooks: [...options.skippedHooks] },
197
+ } : {}),
198
+ };
199
+ for (const section of AGENT_CONTEXT_SECTIONS) if (!sections.includes(section)) delete context[section];
200
+ return enforceByteBudget(context, maxBytes);
201
+ }
package/src/ai.mjs ADDED
@@ -0,0 +1,30 @@
1
+ import { generate } from './model.mjs';
2
+
3
+ // Model-backed text for summaries and lint. The server applies each model's
4
+ // own chat template, so prompts are plain system and user messages.
5
+ // `runlist model` shows which model runs and where.
6
+
7
+ export function runModel(prompt, opts = {}) {
8
+ const { system, ...rest } = opts;
9
+ const messages = system ? [{ role: 'system', content: system }, { role: 'user', content: prompt }] : [{ role: 'user', content: prompt }];
10
+ const text = generate(messages, rest);
11
+ return text ? text.replace(/\*\*/g, '').replace(/^#+\s*/gm, '').trim() : null;
12
+ }
13
+
14
+ export function summarizeDocBody(bodyText, meta, opts = {}) {
15
+ if (!bodyText?.trim()) return null;
16
+ const prompt = `Write a 2-3 sentence plain text summary of this document. State what it covers, its current state (${meta.status}), and what remains to be done. No markdown formatting. No bold, headers, or bullets. Do not start with "This document" or "Here is".
17
+
18
+ Title: ${meta.title}
19
+ ${bodyText.slice(0, 6000)}`;
20
+ return runModel(prompt, {
21
+ system: 'You write brief, direct summaries. No preamble. No filler. Start with the subject immediately.',
22
+ maxTokens: 200,
23
+ ...opts,
24
+ });
25
+ }
26
+
27
+ export function summarizeDiffText(diffText, filePath, model) {
28
+ const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
29
+ return runModel(prompt, { model, maxTokens: 150 });
30
+ }
@@ -0,0 +1,169 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {existsSync, lstatSync, mkdirSync, readFileSync, readdirSync} from 'node:fs';
3
+ import path from 'node:path';
4
+ import {appDocumentTemplates, catchAllRoot, renderAppDocument} from './new.mjs';
5
+ import {readTemplateStore} from './template-store.mjs';
6
+ import {parseDocFile} from './index.mjs';
7
+ import {extractFrontmatter, parseSimpleFrontmatter} from './frontmatter.mjs';
8
+ import {authorizeManagedDestination, authorizeRepoGeneratedPath} from './managed-path.mjs';
9
+ import {createFileExclusive, replaceSnapshot, snapshotFile, withPathLocks} from './atomic-mutation.mjs';
10
+ import {SourceEditError, assertPrivateEditorStorage, sourceRevision} from './source-editor.mjs';
11
+ import {stateDir} from './naming.mjs';
12
+ import {parseNativeRecord} from './native-record.mjs';
13
+ import {recordSettings,recordSettingsPath,recordSettingsText,renderNewRecord,assertRecordTrackable} from './record-create.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 fail=(code,message)=>{throw new SourceEditError(code,message);};
17
+ const hash=value=>createHash('sha256').update(value).digest('hex');
18
+ const json=value=>JSON.stringify(value,null,2)+'\n';
19
+
20
+ /** Reviewed, exclusive creation. The renderer never supplies the committed source. */
21
+ export function createAppDocuments({config,actor,library}) {
22
+ const directory=path.join(stateDir(config.repoRoot),'editor','creations');
23
+ const initialConfig=config.configPath?hash(readFileSync(config.configPath)):null;
24
+ function safe(file) {
25
+ const checked=authorizeRepoGeneratedPath(file,config).path;
26
+ let current=config.repoRoot;
27
+ for(const part of path.relative(config.repoRoot,checked).split(path.sep)) {
28
+ current=path.join(current,part);
29
+ try{if(lstatSync(current).isSymbolicLink())fail('unsafe-local-state','Creation reviews may not traverse symlinks.');}
30
+ catch(error){if(error.code!=='ENOENT')throw error;}
31
+ }
32
+ return checked;
33
+ }
34
+ function location(id){if(!uuid.test(id??''))fail('invalid-id','A UUID v4 creation operation is required.');return safe(path.join(directory,id+'.json'));}
35
+ function read(id) {
36
+ const file=location(id);if(!existsSync(file))return null;
37
+ try {
38
+ if(!lstatSync(file).isFile()||lstatSync(file).size>2*1024*1024)throw new Error('size');
39
+ const value=JSON.parse(readFileSync(file,'utf8'));
40
+ if(value.schema!==1||value.id!==id||!['reviewed','prepared','committed','discarded'].includes(value.state)||!value.actor||typeof value.path!=='string'||sourceRevision(value.source)!==value.revision)throw new Error('schema');
41
+ if(value.native!==undefined){
42
+ const parsed=parseNativeRecord(value.source),record=parsed.record;
43
+ if(value.native!==true||!parsed.ok||!['flag','decision'].includes(record.type)||record.status!=='open'||record.record_data.created_by.id!==value.actor.id||record.record_data.created_by.kind!==value.actor.kind||typeof value.recordShared!=='boolean'||value.recordGuard!==null&&!/^sha256:[a-f0-9]{64}$/.test(value.recordGuard))throw new Error('native schema');
44
+ if(value.setup){
45
+ if(value.setup.path!=='runlist.records.json'||typeof value.setup.source!=='string'||Buffer.byteLength(value.setup.source)>16384)throw new Error('setup');
46
+ const settings=JSON.parse(value.setup.source),folder=record.type==='flag'?'flags':'decisions';
47
+ if(settings.schema!==1||settings.repositoryId!==record.record_data.repository_id||settings.shared!==false||value.path.slice(0,value.path.lastIndexOf('/'))!==`${settings.root}/${folder}`)throw new Error('setup identity');
48
+ }
49
+ }
50
+ return value;
51
+ }catch{fail('state-corrupt','The creation review needs inspection; it will not be overwritten.');}
52
+ }
53
+ function own(context,value){const who=actor(context);if(who.kind!=='human'||who.id!==value.actor.id||who.kind!==value.actor.kind)fail('forbidden','This creation review belongs to another user.');}
54
+ function write(value) {
55
+ safe(directory);assertPrivateEditorStorage(config.repoRoot,directory);mkdirSync(directory,{recursive:true,mode:0o700});safe(directory);
56
+ const file=location(value.id);
57
+ if(existsSync(file))replaceSnapshot(snapshotFile(file),json(value),{repoRoot:config.repoRoot,locked:true});
58
+ else createFileExclusive(file,json(value),{repoRoot:config.repoRoot,mode:0o600,locked:true});
59
+ }
60
+ function destination(relative) {
61
+ if(typeof relative!=='string'||path.isAbsolute(relative)||relative.includes('\\')||relative.split('/').some(p=>!p||p==='.'||p==='..'||p==='prompts'||p==='.git'||config.excludeDirs?.has(p)))fail('invalid-path','Choose a Markdown destination within the configured document roots.');
62
+ const file=path.resolve(config.repoRoot,relative);
63
+ if(file===config.indexPath)fail('invalid-path','The generated index is not a document destination.');
64
+ return authorizeManagedDestination(file,config).path;
65
+ }
66
+ const guards=()=>{const current=config.configPath?hash(readFileSync(config.configPath)):null;if(current!==initialConfig)fail('creation-review-stale','Configuration changed. Reopen the checkout before creating a document.');return {templates:readTemplateStore(config).revision,config:current};};
67
+ async function options(){
68
+ const catalog=appDocumentTemplates(config),roots=(config.docsRoots??[config.docsRoot]).map(root=>path.relative(config.repoRoot,root).split(path.sep).join('/'));
69
+ const templates=catalog.templates.filter(t=>config.validTypes.has(t.type)).map(t=>{
70
+ const target=t.type==='plan'?(config.docsRoots??[config.docsRoot]).find(r=>path.basename(r)==='plans'):null;
71
+ const folder=target??(t.type==='plan'?path.join(catchAllRoot(config),'plans'):catchAllRoot(config));
72
+ const statuses=[...(config.typeStatuses?.get(t.type)??config.validStatuses)].filter(s=>s!=='in-session'&&!config.lifecycle.archiveStatuses.has(s));
73
+ return {...t,folder:path.relative(config.repoRoot,folder).split(path.sep).join('/'),statuses,defaultStatus:statuses.includes(t.defaultStatus)?t.defaultStatus:statuses[0]};
74
+ });
75
+ let records=null,recordError=null;
76
+ try{
77
+ records=recordSettings(config,safe,destination);
78
+ if(records.needsSetup){const existing=(await library?.all()??[]).flatMap(row=>row.recordRepositoryId?[row.recordRepositoryId]:[]);records=recordSettings(config,safe,destination,existing);}
79
+ for(const type of ['flag','decision'])templates.push({name:type,type,native:true,origin:'Native Markdown',description:type==='flag'?'A finding to triage, with its original context.':'An open question with described alternatives and consequences.',folder:`${records.root}/${type==='flag'?'flags':'decisions'}`,statuses:['open'],defaultStatus:'open'});
80
+ }catch(error){recordError=error.message;}
81
+ return {...catalog,roots,templates,records,recordError};
82
+ }
83
+ function publicReview(value){const parsed=value.native?parseNativeRecord(value.source):null;return {operationId:value.id,state:value.state,path:value.path,source:value.source,revision:value.revision,diagnostics:value.diagnostics,...(parsed?{record:parsed.record,setup:value.setup??null}:{})};}
84
+ async function preview(context,request) {
85
+ const who=actor(context);if(who.kind!=='human'||!who.id)fail('forbidden','Document creation requires a local human.');
86
+ const catalog=await options(),template=catalog.templates.find(t=>t.name===request.template);
87
+ if(!template)fail('invalid-template',catalog.recordError??'Choose an available document or record template.');
88
+ if(typeof request.title!=='string'||!request.title.trim()||request.title.length>200||/[\r\n\0]/.test(request.title))fail('invalid-request','Give the document a single-line title, up to 200 characters.');
89
+ if(typeof request.filename!=='string'||!/^[a-z0-9][a-z0-9_-]{0,119}\.md$/.test(request.filename))fail('invalid-path','Use a Markdown filename with letters, numbers, hyphens or underscores.');
90
+ if(!template.statuses.includes(request.status))fail('invalid-status','Choose a configured initial status.');
91
+ if(typeof request.body!=='string'||Buffer.byteLength(request.body)>256*1024||request.body.includes('\0'))fail('invalid-source','Initial content must be text smaller than 256 KiB.');
92
+ const fields=[request.template,request.title,request.filename,request.folder,request.status,request.body];
93
+ if(template.native)fields.push(request.severity,request.options);
94
+ const requestHash=hash(JSON.stringify(fields));
95
+ const prior=read(request.operationId);if(prior){own(context,prior);if(prior.requestHash!==requestHash)fail('operation-reused','This creation ID already belongs to another review.');return publicReview(prior);}
96
+ const relative=`${request.folder}/${request.filename}`,file=destination(relative);
97
+ if(existsSync(file))fail('creation-conflict','A file already exists at this destination. Choose another filename.');
98
+ const today=new Date().toISOString();
99
+ let source,setup=null,native=false,recordGuard=null,recordShared=false,diagnostics=[];
100
+ if(template.native){
101
+ if(request.folder!==template.folder)fail('invalid-path','Create records in their configured flag or decision folder.');
102
+ const settings=catalog.records;assertRecordTrackable(config,file,settings.shared);
103
+ ({source,setup}=renderNewRecord(request,who,settings,today));native=true;recordGuard=settings.revision;recordShared=settings.shared;
104
+ if(setup)assertRecordTrackable(config,safe(recordSettingsPath(config)),settings.shared);
105
+ }else{
106
+ source=renderAppDocument(request.template,{title:request.title.trim(),status:request.status,today,bodyInput:request.body},config);
107
+ const warnings=[],fm=parseSimpleFrontmatter(extractFrontmatter(source).frontmatter,warnings);
108
+ if(warnings.length||fm.type!==template.type||fm.status!==request.status||fm.record_schema!==undefined)fail('invalid-template','Repair the template metadata before creating a document.');
109
+ const parsed=parseDocFile(file,config,{source});diagnostics=[...parsed.errors,...parsed.warnings].map(d=>({level:d.level,message:d.message}));
110
+ if(parsed.errors.length)throw new SourceEditError('invalid-record','The new document fails checkout validation.',{diagnostics});
111
+ }
112
+ const value={schema:1,id:request.operationId,state:'reviewed',actor:who,path:relative,source,revision:sourceRevision(source),requestHash,guards:guards(),diagnostics,at:today,...(native?{native,setup,recordGuard,recordShared}:{})};
113
+ return withPathLocks([location(value.id)],{repoRoot:config.repoRoot},()=>{
114
+ const prior=read(value.id);if(prior){own(context,prior);if(prior.requestHash!==requestHash)fail('operation-reused','This creation ID already belongs to another review.');return publicReview(prior);}
115
+ write(value);return publicReview(value);
116
+ });
117
+ }
118
+ function inspect(context,{operationId}) {
119
+ const value=read(operationId);if(!value)return null;own(context,value);const file=destination(value.path);
120
+ let currentRevision=null;
121
+ if(existsSync(file)) {if(lstatSync(file).isSymbolicLink()||!lstatSync(file).isFile()||lstatSync(file).size>8*1024*1024)fail('creation-conflict','The destination is unavailable for inspection.');currentRevision=sourceRevision(readFileSync(file,'utf8'));}
122
+ return {...publicReview(value),currentRevision};
123
+ }
124
+ function commit(context,{operationId}) {
125
+ const prior=read(operationId);if(!prior)fail('operation-missing','Review the document before creating it.');own(context,prior);
126
+ return withPathLocks([location(operationId),destination(prior.path),...(prior.native?[safe(recordSettingsPath(config))]:[])],{repoRoot:config.repoRoot},()=>{
127
+ const value=read(operationId);own(context,value);const file=destination(value.path);
128
+ if(value.state==='discarded')fail('creation-discarded','This review was discarded. Prepare a new document.');
129
+ if(value.state==='committed')return {...publicReview(value),replayed:true};
130
+ if(value.native){
131
+ if(JSON.stringify(value.guards)!==JSON.stringify(guards()))fail('creation-review-stale','Configuration changed. Review a new record.');
132
+ const metadataSource=recordSettingsText(config,safe),current=metadataSource===null?null:sourceRevision(metadataSource);
133
+ const recovered=value.state==='prepared'&&value.setup&&current===sourceRevision(value.setup.source);
134
+ if(current!==value.recordGuard&&!recovered)fail('creation-review-stale','Record settings changed. Review the record again.');
135
+ assertRecordTrackable(config,file,value.recordShared);
136
+ }
137
+ if(value.state==='prepared'&&existsSync(file)) {
138
+ const current=inspect(context,{operationId});
139
+ if(current.currentRevision!==value.revision)fail('creation-uncertain','The destination differs from the reviewed source. Inspect it; it will not be overwritten.');
140
+ } else {
141
+ if(existsSync(file))fail('creation-conflict','Another file appeared at this destination. It will not be overwritten.');
142
+ if(JSON.stringify(value.guards)!==JSON.stringify(guards()))fail('creation-review-stale','Configuration or templates changed. Review a new document before creating it.');
143
+ value.state='prepared';write(value);
144
+ if(value.setup){const metadata=safe(recordSettingsPath(config)),source=recordSettingsText(config,safe);if(source===null)createFileExclusive(metadata,value.setup.source,{repoRoot:config.repoRoot,locked:true});else if(sourceRevision(source)!==sourceRevision(value.setup.source))fail('creation-uncertain','Record setup differs from this review. Inspect it before continuing.');}
145
+ mkdirSync(path.dirname(file),{recursive:true});destination(value.path);
146
+ createFileExclusive(file,value.source,{repoRoot:config.repoRoot,locked:true});
147
+ }
148
+ if(value.setup){const metadata=safe(recordSettingsPath(config)),source=recordSettingsText(config,safe);if(source===null)createFileExclusive(metadata,value.setup.source,{repoRoot:config.repoRoot,locked:true});else if(sourceRevision(source)!==sourceRevision(value.setup.source))fail('creation-uncertain','Record setup differs from this review. Inspect it before continuing.');}
149
+ value.state='committed';write(value);return publicReview(value);
150
+ });
151
+ }
152
+ function discard(context,{operationId}) {
153
+ return withPathLocks([location(operationId)],{repoRoot:config.repoRoot},()=>{
154
+ const value=read(operationId);if(!value)return {discarded:true};own(context,value);
155
+ if(!['reviewed','discarded'].includes(value.state))fail('creation-uncertain','Inspect this creation outcome before dismissing it.');
156
+ value.state='discarded';write(value);return {discarded:true};
157
+ });
158
+ }
159
+ function list(context) {
160
+ safe(directory);if(!existsSync(directory))return {items:[],unavailable:0};
161
+ const items=[];let unavailable=0;
162
+ for(const name of readdirSync(directory).filter(n=>n.endsWith('.json')&&uuid.test(n.slice(0,-5)))) {
163
+ try{const value=read(name.slice(0,-5));if(value.actor.id!==actor(context).id||!['reviewed','prepared'].includes(value.state))continue;own(context,value);destination(value.path);items.push({kind:'creation',path:value.path,operationId:value.id,state:value.state,at:value.at,available:true});}
164
+ catch{unavailable++;}
165
+ }
166
+ return {items,unavailable};
167
+ }
168
+ return {options,preview,commit,inspect,discard,list};
169
+ }
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setImmediate as yieldThread } from 'node:timers/promises';
3
+ import { assembleFilingCoverage, filingLiveHub, filingOptions, filingParentTarget, prepareFilingHub } from './filing.mjs';
4
+ import { SourceEditError } from './source-editor.mjs';
5
+
6
+ const detached = value => JSON.parse(JSON.stringify(value));
7
+ const number = (params, key, fallback, max) => {
8
+ const value = Number(params.get(key) ?? fallback);
9
+ return Number.isInteger(value) && value >= 0 ? Math.min(value, max) : fallback;
10
+ };
11
+ const multiple = plan => plan.rows.length > 1 || new Set(plan.rows.flatMap(row => row.categories)).size > 1;
12
+ const noCategory = plan => plan.rows.some(row => !row.categories.length);
13
+
14
+ /** One private, read-only report per checkout. No scan happens during creation. */
15
+ export function createAppFiling({ config, library, readSource }) {
16
+ let scan = null, epoch = 0, advancing = null;
17
+ function invalidate() { epoch++; scan = null; }
18
+ function summary(current = scan) {
19
+ if (!filingOptions(config.filing)) return { enabled: false, state: 'disabled', complete: false, reason: 'Filing reports are not enabled in this repository configuration.' };
20
+ if (!current) return { enabled: true, state: 'idle', complete: false };
21
+ return { enabled: true, scanId: current.id, generation: current.generation, state: current.report ? current.report.complete ? 'complete' : 'incomplete' : 'scanning',
22
+ complete: current.report?.complete ?? false, progress: { done: current.index, total: current.documents.length },
23
+ totals: current.report?.complete ? current.report.totals : null,
24
+ failures: current.failures.slice(0, 50), failureCount: current.failures.length };
25
+ }
26
+ async function inventory(force = false) { return library.filingInventory({ refresh: force }); }
27
+ function requireScan(params) {
28
+ if (!scan || params.get('scanId') !== scan.id) throw new SourceEditError('filing-scan-stale', 'The filing report changed. Refresh it before continuing.');
29
+ return scan;
30
+ }
31
+ function finish(current) {
32
+ current.report = assembleFilingCoverage(current.documents, config, current.rows, current.parents,
33
+ { complete: current.inventoryComplete, failures: current.failures });
34
+ current.rows = null;
35
+ current.parents = null;
36
+ }
37
+ async function start(params) {
38
+ const ticket = epoch;
39
+ const observed = await inventory(params.get('refresh') === '1');
40
+ if (ticket !== epoch) throw new SourceEditError('filing-scan-stale', 'Saved files changed. Refresh the filing report.');
41
+ if (scan?.generation === observed.generation && params.get('refresh') !== '1') return summary();
42
+ const documents = observed.documents.map(doc => detached({ ...doc, path: doc.repoPath ?? doc.path }));
43
+ scan = { id: randomUUID(), epoch, generation: observed.generation, documents, byPath: new Map(documents.map(doc => [doc.path, doc])), index: 0, rows: [], parents: new Map(), revisions: new Map(),
44
+ inventoryComplete: observed.complete, failures: detached(observed.failures ?? []), report: null };
45
+ if (!documents.length) finish(scan);
46
+ return summary();
47
+ }
48
+ async function currentScan(params) {
49
+ const current = requireScan(params), observed = await inventory();
50
+ if (current !== scan || observed.generation !== current.generation || current.epoch !== epoch) {
51
+ if (current === scan) invalidate();
52
+ throw new SourceEditError('filing-scan-stale', 'Saved files changed. Refresh the filing report.');
53
+ }
54
+ return current;
55
+ }
56
+ async function advance(params, context) {
57
+ if (advancing) { await advancing; return summary(await currentScan(params)); }
58
+ const current = await currentScan(params);
59
+ if (current.report) return summary(current);
60
+ advancing = (async () => {
61
+ const end = Math.min(current.documents.length, current.index + 8);
62
+ while (current.index < end) {
63
+ const doc = current.documents[current.index++];
64
+ current.parents.set(doc.path, filingParentTarget(doc, config));
65
+ if (filingLiveHub(doc, config)) {
66
+ try {
67
+ const opened = await readSource(doc.path, context);
68
+ if (doc.stamp && opened.stamp !== doc.stamp) throw new Error('changed since inventory');
69
+ if (Buffer.byteLength(opened.source) > 8 * 1024 * 1024) throw new Error('oversized');
70
+ const rows = prepareFilingHub(doc, opened.source, config);
71
+ if (current.rows.length + rows.length > 100_000) throw new Error('too many rows');
72
+ current.rows.push(...detached(rows));
73
+ current.revisions.set(doc.path, opened.revision);
74
+ } catch {
75
+ current.failures.push({ path: doc.path, message: 'Hub source is unavailable, changed, unsafe or exceeds report limits.' });
76
+ }
77
+ }
78
+ await yieldThread();
79
+ if (current !== scan || current.epoch !== epoch) throw new SourceEditError('filing-scan-stale', 'Saved files changed. Refresh the filing report.');
80
+ }
81
+ await currentScan(params);
82
+ if (current.index === current.documents.length) finish(current);
83
+ return summary(current);
84
+ })();
85
+ try { return await advancing; } finally { advancing = null; }
86
+ }
87
+ function planSummary(plan, current) {
88
+ const doc = current.byPath.get(plan.path);
89
+ return { path: plan.path, title: String(doc?.title ?? plan.path).slice(0, 300), status: plan.status,
90
+ filing: current.report.complete ? plan.filing : plan.rows.length ? 'observed' : 'unknown',
91
+ filedThrough: plan.filedThrough, homeCount: plan.rows.length,
92
+ multiplyFiled: current.report.complete ? multiple(plan) : null, noCategory: current.report.complete ? noCategory(plan) : null };
93
+ }
94
+ async function page(params) {
95
+ const current = await currentScan(params);
96
+ if (!current.report) return { ...summary(current), plans: [], total: 0, offset: 0, limit: 50, hasMore: false };
97
+ const group = params.get('group') ?? 'all';
98
+ if (!['all', 'unfiled', 'multiple', 'no-category', 'parent', 'direct'].includes(group)) throw new SourceEditError('invalid-request', 'Choose a supported filing filter.');
99
+ const words = (params.get('q') ?? '').slice(0, 500).toLowerCase().trim().split(/\s+/).filter(Boolean);
100
+ const documents = new Map(current.documents.map(doc => [doc.path, doc]));
101
+ const matches = current.report.plans.filter(plan =>
102
+ (group === 'all' || current.report.complete && (group === 'unfiled' ? plan.filing === 'unfiled' : group === 'multiple' ? multiple(plan) : group === 'no-category' ? noCategory(plan) : plan.filing === group)) &&
103
+ words.every(word => `${plan.path} ${documents.get(plan.path)?.title ?? ''} ${plan.status}`.toLowerCase().includes(word))).sort((a, b) => a.path.localeCompare(b.path));
104
+ const limit = Math.max(1, number(params, 'limit', 50, 50));
105
+ const offset = Math.min(number(params, 'offset', 0, 10_000_000), Math.max(0, Math.floor((matches.length - 1) / limit) * limit));
106
+ return { ...summary(current), plans: matches.slice(offset, offset + limit).map(plan => planSummary(plan, current)), total: matches.length, offset, limit, hasMore: offset + limit < matches.length,
107
+ reason: !current.report.complete && group !== 'all' ? 'Filing findings are unavailable until a complete scan can be made. Select All plans to inspect observed evidence.' : null };
108
+ }
109
+ async function detail(params, context) {
110
+ const current = await currentScan(params);
111
+ const plan = current.report?.plans.find(plan => plan.path === params.get('path'));
112
+ if (!plan) throw new SourceEditError('filing-plan-unavailable', 'This plan is unavailable in the filing report. Refresh the report.');
113
+ const limit = Math.max(1, number(params, 'homeLimit', 20, 20));
114
+ const offset = Math.min(number(params, 'homeOffset', 0, 100_000), Math.max(0, Math.floor((plan.rows.length - 1) / limit) * limit));
115
+ const sources = new Map(), homes = [];
116
+ for (const row of plan.rows.slice(offset, offset + limit)) {
117
+ if (!sources.has(row.hub)) {
118
+ try { sources.set(row.hub, (await readSource(row.hub, context)).revision === current.revisions.get(row.hub) ? 'unchanged' : 'changed'); }
119
+ catch { sources.set(row.hub, 'unavailable'); }
120
+ }
121
+ homes.push({ ...row, categories: row.categories.slice(0, 100).map(category => category.slice(0, 300)),
122
+ categoriesShortened: row.categories.length > 100 || row.categories.some(category => category.length > 300),
123
+ category: row.category?.slice(0, 300) ?? null, revision: current.revisions.get(row.hub), current: sources.get(row.hub) });
124
+ }
125
+ await currentScan(params);
126
+ return { ...summary(current), ...planSummary(plan, current), homes, totalHomes: plan.rows.length, homeOffset: offset, homeLimit: limit, hasMoreHomes: offset + limit < plan.rows.length };
127
+ }
128
+ async function request(params = new URLSearchParams(), context) {
129
+ if (!filingOptions(config.filing)) return summary();
130
+ const op = params.get('op') ?? 'status';
131
+ if (op === 'start') return start(params);
132
+ if (op === 'advance') return advance(params, context);
133
+ if (op === 'status') return params.has('scanId') ? summary(await currentScan(params)) : summary();
134
+ if (op === 'page') return page(params);
135
+ if (op === 'detail') return detail(params, context);
136
+ throw new SourceEditError('invalid-request', 'Choose a supported filing report operation.');
137
+ }
138
+ return { request, invalidate };
139
+ }
@@ -0,0 +1,48 @@
1
+ import {commitPlatformQualified} from './git-commit-support.mjs';
2
+ import {fork} from 'node:child_process';
3
+ import {realpathSync} from 'node:fs';
4
+ import {randomUUID} from 'node:crypto';
5
+ import path from 'node:path';
6
+ import {gitEnvironment} from './app-git.mjs';
7
+ import {commitStore,uuid,fail,hash} from './git-commit-store.mjs';
8
+ import {run} from './git-commit-protocol.mjs';
9
+ import {currentProcessOwner,processStartIdentity,processOwnerLiveness} from './atomic-mutation.mjs';
10
+ // Jobs survive renderer/helper disconnect; no operation ID is ever replayed.
11
+ const children=new Map();
12
+ function stopped(r){if(!r.workerOwner)return r.state==='reviewed'||r.state==='failed';if(processOwnerLiveness(r.workerOwner)!=='dead')return false;try{process.kill(-r.workerOwner.pid,0);return false;}catch(e){return e.code==='ESRCH';}}
13
+ const publicOperation=(r,live)=>({operationId:r.id,state:r.state,running:live,path:r.rows[0]?.path??r.paths[0],paths:r.paths,message:r.message,branch:r.selectedBranch,head:r.head,tree:r.tree??null,commitId:r.commit??null,indexReady:r.indexReady??false,failure:r.failure??null,recovery:r.recovery??null,resolved:!!r.resolved,at:r.at,review:r.state==='reviewed'?r.review:[],execution:{hooks:Object.entries(r.material?.hooks?.files??{}).filter(([,entry])=>entry?.mode&0o111).map(([kind])=>kind),signing:r.material?.signing?.format??'none',filters:(r.material?.filters??[]).map(profile=>profile.name)},canCommit:r.state==='reviewed'&&!live,canRecover:!live&&r.action!=='preview'&&!r.resolved&&['not-committed','committed-index-recovery','committed'].includes(r.state),observedRef:r.observedRef??null,observedIndex:r.observedIndex??null,canSettle:!live&&!r.resolved&&['uncertain-ref-publication','uncertain-external-ref','committed-external-ref'].includes(r.state)&&typeof r.observedRef==='string'&&typeof r.observedIndex==='string',expiresAt:r.expiresAt??null});
14
+ export function createAppGitCommits({config,actor,authenticate,git}){
15
+ const root=realpathSync(config.repoRoot),store=commitStore(root,actor);
16
+ const identity=req=>{const candidate=authenticate(req);if(candidate?.kind!=='human'||candidate.id!==actor.id)fail('forbidden','Git commits belong to the authenticated checkout owner.');};
17
+ const platform=()=>{if(!commitPlatformQualified())fail('git-platform-unqualified','Local commits are not yet qualified on this platform and architecture. Use Git; editing and Changes remain available.');};
18
+ function storage(){const r={root};const relative=path.relative(root,store.directory).split(path.sep).join('/');if(run(r,['check-ignore','-q','--',relative+'/'],{allow:[0,1]}).code!==0||run(r,['ls-files','-z','--',relative]).bytes.length)fail('unsafe-local-state','Private Git operations must be ignored and untracked.');store.ensure();}
19
+ function live(r){return children.has(root+':'+r.id)||!stopped(r);}
20
+ function blocker(except){return store.all().find(r=>r.id!==except&&(live(r)||r.action!=='preview'&&!r.resolved&&!(r.state==='committed'&&r.indexReady)));}
21
+ function launch(id,action){
22
+ const token=randomUUID();let child;
23
+ store.update(id,r=>{r.action=action;r.launchToken=token;r.state=action==='preview'?'reviewing':action==='execute'?'starting':r.state;r.parentOwner=currentProcessOwner();delete r.finishedAt;});
24
+ try{
25
+ child=fork(new URL('./git-commit-worker.mjs',import.meta.url),[],{env:gitEnvironment(),detached:true,stdio:['ignore','ignore','ignore','ipc'],execArgv:['--max-old-space-size=192','--max-semi-space-size=4']});
26
+ const key=root+':'+id;children.set(key,child);
27
+ store.update(id,r=>{r.workerOwner={...currentProcessOwner(),pid:child.pid,processStartIdentity:processStartIdentity(child.pid)};});
28
+ const timer=setTimeout(()=>{try{if(processOwnerLiveness(store.read(id)?.workerOwner)==='live')process.kill(-child.pid,'SIGKILL');}catch{}},70000);timer.unref();
29
+ child.once('exit',()=>{clearTimeout(timer);children.delete(key);try{store.update(id,r=>{if(r.action==='preview'&&r.state==='reviewing'){r.state='failed';r.failure={code:r.cancelRequestedAt?'git-cancelled':'git-review-interrupted',message:r.cancelRequestedAt?'The review was stopped. Start a new review when ready.':'The review stopped before completion. Start a new review; no commit was executed.'};}});}catch{}});child.once('error',()=>{clearTimeout(timer);children.delete(key);try{store.update(id,r=>{r.failure={code:'git-job-start',message:'The Git job could not start. Inspect its retained receipt.'};});}catch{}});
30
+ child.send({root,actor,id,action,token});child.unref();child.channel?.unref();
31
+ }catch(error){if(child)try{process.kill(-child.pid,'SIGKILL');}catch{}store.update(id,r=>{r.failure={code:'git-job-start',message:'The Git job could not start. Inspect its retained receipt.'};if(action==='preview')r.state='failed';});throw error;}
32
+ return publicOperation(store.read(id),true);
33
+ }
34
+ async function preview(req,request){
35
+ identity(req);platform();if(!uuid.test(request.operationId??''))fail('invalid-id','Use a UUID v4 local commit review ID.');
36
+ if(typeof request.message!=='string'||!request.message.trim()||Buffer.byteLength(request.message)>8192||request.message.includes('\0')||Buffer.from(request.message).toString('utf8')!==request.message)fail('invalid-request','Write a UTF-8 commit message of at most 8 KiB.');
37
+ const message=request.message.replace(/\n?$/,'\n');if(Buffer.byteLength(message)>8192)fail('invalid-request','The complete commit message must fit in 8 KiB, including its final newline.');const requestHash=hash(JSON.stringify([request.paths,message]));
38
+ const previous=store.read(request.operationId);if(previous){if(previous.requestHash!==requestHash)fail('operation-reused','This operation ID already belongs to another review.');return publicOperation(previous,live(previous));}
39
+ const selection=await git.selection(req,request.paths);identity(req);storage();
40
+ return store.exclusive(()=>{const prior=store.read(request.operationId);if(prior){if(prior.requestHash!==requestHash)fail('operation-reused','This ID already belongs to another review.');return publicOperation(prior,live(prior));}if(blocker())fail('git-operation-active','Inspect the retained Git operation before starting another review.');store.create({schema:1,id:request.operationId,root,actor,requestHash,state:'reviewing',action:'preview',paths:selection.paths,authorizedSources:selection.authorizedSources,rows:selection.rows.map(row=>({path:row.path,original:row.original??null,kind:row.kind})),head:selection.head,selectedBranch:selection.branch,message,configPath:config.configPath??null,at:new Date().toISOString()});return launch(request.operationId,'preview');});
41
+ }
42
+ function inspect(req,id){identity(req);const r=store.read(id);if(!r)return null;return publicOperation(r,live(r));}
43
+ function start(req,{operationId}){identity(req);platform();storage();return store.exclusive(()=>{const r=store.read(operationId);if(!r)fail('git-operation-missing','Review a local commit first.');if(r.state!=='reviewed'||live(r))return publicOperation(r,live(r));if(Date.now()>r.expiresAt)fail('git-review-expired','This review expired. Review current files again.');if(blocker(operationId))fail('git-operation-active','Another retained Git operation needs inspection first.');return launch(operationId,'execute');});}
44
+ function job(req,{operationId,...request},action){identity(req);platform();storage();return store.exclusive(()=>{const r=store.read(operationId);if(!r)fail('git-operation-missing','The retained Git operation is missing.');if(live(r))fail('git-operation-running','The Git process group is still active or cannot be verified as stopped.');if(r.action==='preview')return publicOperation(r,false);if(action==='settle'){if(typeof request.note!=='string'||!request.note.trim()||request.note.length>4000||!/^[a-f0-9]{64}$/.test(request.expectedIndexRevision??'')||request.expectedRef!==r.observedRef||request.expectedIndexRevision!==r.observedIndex)fail('invalid-request','Acknowledge the current inspected ref/index with a note up to 4,000 characters.');store.update(operationId,d=>{d.settlementRequest={note:request.note.trim(),expectedRef:request.expectedRef,expectedIndexRevision:request.expectedIndexRevision};});}if(r.resolved&&action==='recover')return publicOperation(r,false);return launch(operationId,action);});}
45
+ function cancel(req,{operationId}){identity(req);platform();const r=store.read(operationId);if(!r)fail('git-operation-missing','The retained Git operation is missing.');if(live(r)){if(processOwnerLiveness(r.workerOwner)!=='live')fail('git-owner-unverified','The worker identity cannot be verified. Keep its receipt and inspect with Git.');store.update(operationId,d=>{d.cancelRequestedAt=new Date().toISOString();});try{process.kill(-r.workerOwner.pid,'SIGKILL');}catch(error){if(error.code!=='ESRCH')throw error;}}return publicOperation(store.read(operationId),live(store.read(operationId)));}
46
+ function list(req){identity(req);const items=[];let unavailable=0;try{for(const r of store.all())if(!r.resolved&&(r.state!=='committed'||!r.indexReady))items.push({kind:'git-commit',path:r.rows[0]?.path??r.paths[0],operationId:r.id,state:r.state,at:r.at,available:true});}catch(error){if(error.code==='forbidden')throw error;unavailable++;}return {items,unavailable};}
47
+ return {preview,start,inspect,inspectJob:(req,body)=>job(req,body,'inspect'),recover:(req,body)=>job(req,body,'recover'),cancel,settle:(req,body)=>job(req,body,'settle'),list};
48
+ }