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,252 @@
1
+ // Windows-authored (CRLF) docs otherwise slip past the LF-only fence detection
2
+ // below and read as having NO frontmatter — silently dropping them from the
3
+ // managed set (no type, no status). Normalizing CRLF→LF at every parse/rewrite
4
+ // boundary is the fix; the per-line value parser already strips a trailing \r,
5
+ // so only the fence scan was blind. A managed doc settles to LF the first time a
6
+ // runlist verb rewrites it — content-preserving line-ending normalization, not
7
+ // corruption. For LF docs this is a no-op (the `\r` guard skips the replace), so
8
+ // existing behavior is byte-identical.
9
+ export function normalizeEol(text) {
10
+ return typeof text === 'string' && text.includes('\r') ? text.replace(/\r\n/g, '\n') : text;
11
+ }
12
+
13
+ export function extractFrontmatter(raw) {
14
+ const text = normalizeEol(raw);
15
+ if (!text.startsWith('---\n')) {
16
+ return { frontmatter: '', body: text, bodyLineOffset: 0 };
17
+ }
18
+
19
+ const endMarker = text.indexOf('\n---\n', 4);
20
+ if (endMarker === -1) {
21
+ return { frontmatter: '', body: text, bodyLineOffset: 0 };
22
+ }
23
+
24
+ return {
25
+ frontmatter: text.slice(4, endMarker),
26
+ body: text.slice(endMarker + 5),
27
+ bodyLineOffset: text.slice(0, endMarker + 5).split('\n').length - 1,
28
+ };
29
+ }
30
+
31
+ export function replaceFrontmatter(raw, newFrontmatter) {
32
+ const text = normalizeEol(raw);
33
+ // No frontmatter to replace: return the ORIGINAL bytes untouched (a no-op
34
+ // rewrite must not normalize a file the caller didn't intend to change).
35
+ if (!text.startsWith('---\n')) return raw;
36
+ const endMarker = text.indexOf('\n---\n', 4);
37
+ if (endMarker === -1) return raw;
38
+ const body = text.slice(endMarker + 5);
39
+ return `---\n${newFrontmatter}\n---\n${body}`;
40
+ }
41
+
42
+ // Parses our YAML subset. Optional `warnings` array receives non-fatal
43
+ // structural issues (e.g. duplicate keys) — caller decides whether to surface
44
+ // them. Default behavior is unchanged: keep first occurrence of a duplicate
45
+ // key, ignore subsequent ones.
46
+ //
47
+ // Supports:
48
+ // inline scalars `key: value`
49
+ // inline flow arrays `key: []` / `key: [a, b, "c, d"]`
50
+ // block arrays `key:\n - item\n - item`
51
+ // folded block scalar `key: >\n one line\n continues` → "one line continues"
52
+ // literal block scalar `key: |\n one\n two` → "one\ntwo"
53
+ // chomping indicators `>-`, `|-` (strip), `>+`, `|+` (keep), default (clip to one trailing \n)
54
+ //
55
+ // Supported-subset boundary (deliberately NOT a full YAML parser):
56
+ // - Scalars stay strings except literal `true`/`false`. Numbers, null, and
57
+ // dates are kept verbatim as strings; callers coerce where they need a type
58
+ // (so `1.0`, `2025-01-01`, version strings, and numeric-looking ids survive
59
+ // intact rather than silently changing type).
60
+ // - Duplicate keys keep the FIRST occurrence; later ones are ignored (and
61
+ // reported via the optional `warnings` array).
62
+ // - Nested maps / multi-level indentation are not parsed — only top-level
63
+ // keys, with one level of `- ` items under an array key.
64
+ export function parseSimpleFrontmatter(text, warnings) {
65
+ const data = {};
66
+ const seenDupKeys = new Set();
67
+ let currentArrayKey = null;
68
+ const lines = text.split('\n');
69
+
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const lineNum = i + 1;
72
+ const line = lines[i].replace(/\r$/, '');
73
+ if (!line.trim()) continue;
74
+
75
+ const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
76
+ if (keyMatch) {
77
+ const [, key, rawValue] = keyMatch;
78
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
79
+ currentArrayKey = null;
80
+ if (warnings && !seenDupKeys.has(key)) {
81
+ seenDupKeys.add(key);
82
+ warnings.push({ key, line: lineNum,
83
+ message: `Duplicate frontmatter key \`${key}\` at line ${lineNum}; keeping first occurrence, ignoring later values.` });
84
+ }
85
+ continue;
86
+ }
87
+
88
+ const trimmedValue = rawValue.trim();
89
+
90
+ // Block scalar marker: > or | with optional chomping indicator (-/+).
91
+ const blockMatch = trimmedValue.match(/^([>|])([-+])?\s*$/);
92
+ if (blockMatch) {
93
+ const [, style, chomp] = blockMatch;
94
+ const { value, consumed } = collectBlockScalar(lines, i + 1, style, chomp);
95
+ data[key] = value;
96
+ i += consumed;
97
+ currentArrayKey = null;
98
+ continue;
99
+ }
100
+
101
+ if (!trimmedValue) {
102
+ data[key] = [];
103
+ currentArrayKey = key;
104
+ continue;
105
+ }
106
+
107
+ const flowArray = parseFlowArray(trimmedValue);
108
+ if (flowArray !== null) {
109
+ data[key] = flowArray;
110
+ currentArrayKey = null;
111
+ continue;
112
+ }
113
+
114
+ data[key] = parseScalar(trimmedValue);
115
+ currentArrayKey = null;
116
+ continue;
117
+ }
118
+
119
+ if (currentArrayKey) {
120
+ const itemMatch = line.match(/^\s*-\s+(.*)$/);
121
+ if (itemMatch) {
122
+ data[currentArrayKey].push(parseScalar(itemMatch[1].trim()));
123
+ continue;
124
+ }
125
+ }
126
+ }
127
+
128
+ return data;
129
+ }
130
+
131
+ // Reads lines starting at startIdx and collects them as a YAML block scalar
132
+ // body. Stops when a line is encountered that is dedented to (or past) the
133
+ // key's indent level (zero in our frontmatter context). Returns the joined
134
+ // string and the number of lines consumed (for the caller to advance `i`).
135
+ function collectBlockScalar(lines, startIdx, style, chomp) {
136
+ // Determine content indent from the first non-blank line.
137
+ let contentIndent = null;
138
+ const collected = [];
139
+ let i = startIdx;
140
+ for (; i < lines.length; i++) {
141
+ const line = lines[i].replace(/\r$/, '');
142
+ if (line.trim() === '') {
143
+ collected.push(''); // preserve as blank for folding/literal rules
144
+ continue;
145
+ }
146
+ const indent = line.match(/^(\s*)/)[1].length;
147
+ if (contentIndent === null) {
148
+ // First non-blank content line establishes the indent.
149
+ // If it's at column 0, that's a sibling key — block was empty.
150
+ if (indent === 0) break;
151
+ contentIndent = indent;
152
+ collected.push(line.slice(contentIndent));
153
+ continue;
154
+ }
155
+ if (indent < contentIndent) {
156
+ // Dedented past content level — end of block scalar.
157
+ break;
158
+ }
159
+ collected.push(line.slice(contentIndent));
160
+ }
161
+
162
+ // Strip trailing blank lines we accidentally captured before the dedent
163
+ // (they belong to the document, not the scalar's chomping window).
164
+ while (collected.length > 0 && collected[collected.length - 1] === '') {
165
+ collected.pop();
166
+ }
167
+
168
+ // Join according to style.
169
+ let value;
170
+ if (style === '|') {
171
+ // Literal: each line preserved as-is, joined with \n.
172
+ value = collected.join('\n');
173
+ } else {
174
+ // Folded: single newline between non-blank lines folds to space;
175
+ // a blank-line run between content becomes a single \n (paragraph break).
176
+ value = '';
177
+ let hasContent = false;
178
+ let prevWasBlank = false;
179
+ for (const line of collected) {
180
+ if (line === '') {
181
+ if (hasContent && !prevWasBlank) value += '\n';
182
+ prevWasBlank = true;
183
+ } else {
184
+ if (hasContent && !prevWasBlank) value += ' ';
185
+ value += line;
186
+ hasContent = true;
187
+ prevWasBlank = false;
188
+ }
189
+ }
190
+ }
191
+
192
+ // Apply chomping: default = clip (single trailing \n if any content),
193
+ // '-' = strip (no trailing \n), '+' = keep (preserve all).
194
+ if (chomp === '-') {
195
+ value = value.replace(/\n+$/, '');
196
+ } else if (chomp === '+') {
197
+ if (!value.endsWith('\n')) value = value + '\n';
198
+ } else {
199
+ // Clip: strip multiple trailing newlines down to none for inline content
200
+ // (matches the practical expectation that `key: >` yields a string without
201
+ // trailing whitespace artifacts when used inline).
202
+ value = value.replace(/\n+$/, '');
203
+ }
204
+
205
+ return { value, consumed: i - startIdx };
206
+ }
207
+
208
+ // Parses a YAML flow sequence like `[]`, `[a, b]`, `[a, "b, c", 'd']`.
209
+ // Returns an array on success, or null if the value isn't a well-formed
210
+ // `[…]` flow sequence (caller falls back to scalar parsing).
211
+ function parseFlowArray(value) {
212
+ if (!value.startsWith('[') || !value.endsWith(']')) return null;
213
+ const inner = value.slice(1, -1);
214
+ if (!inner.trim()) return [];
215
+
216
+ const items = [];
217
+ let current = '';
218
+ let quote = null;
219
+ for (let i = 0; i < inner.length; i++) {
220
+ const c = inner[i];
221
+ if (quote) {
222
+ current += c;
223
+ if (c === quote) quote = null;
224
+ continue;
225
+ }
226
+ if (c === "'" || c === '"') {
227
+ quote = c;
228
+ current += c;
229
+ continue;
230
+ }
231
+ if (c === ',') {
232
+ items.push(parseScalar(current.trim()));
233
+ current = '';
234
+ continue;
235
+ }
236
+ current += c;
237
+ }
238
+ if (quote !== null) return null; // unterminated quote — not a valid flow array
239
+ items.push(parseScalar(current.trim()));
240
+ return items;
241
+ }
242
+
243
+ function parseScalar(value) {
244
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
245
+ try { return JSON.parse(value); } catch { return value.slice(1, -1); }
246
+ }
247
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
248
+ if (value === 'true') return true;
249
+ if (value === 'false') return false;
250
+ return value;
251
+ }
252
+
@@ -0,0 +1,32 @@
1
+ // Qualify one-shot clean drivers without executing them. Git retains its own
2
+ // required/optional failure semantics; arbitrary shell and process drivers stay gated.
3
+ import {existsSync,readFileSync,realpathSync} from 'node:fs';
4
+ import path from 'node:path';
5
+ import {hash,fail,safeFile} from './git-commit-store.mjs';
6
+ const unsupported=()=>fail('git-filter-unsupported','This clean filter needs separate qualification. Use a one-shot executable with literal arguments; process filters, shell expressions and working-tree encoding require Git.');
7
+ function words(command){
8
+ if(command.length>8192||/[\x00-\x1f\x7f$`;&|<>()\\]/.test(command))unsupported();
9
+ const result=[];let word='',quote=null,started=false;
10
+ for(const c of command){if(quote){if(c===quote)quote=null;else word+=c;started=true;}else if(c==='"'||c==="'"){quote=c;started=true;}else if('*?[]~#{}!'.includes(c))unsupported();else if(/\s/.test(c)){if(started){result.push(word);word='';started=false;}}else{word+=c;started=true;}}
11
+ if(quote)unsupported();if(started)result.push(word);
12
+ if(!result[0]||result.length>64||result.some(w=>w.includes('%')&&w!=='%f')||result.slice(1).some(w=>['-c','-e','--eval','--command'].includes(w)))unsupported();
13
+ return result;
14
+ }
15
+ function generation(file){const st=safeFile(file,16*1024*1024);return {file,hash:hash(readFileSync(file)),mode:st.mode&0o777};}
16
+ export function filterProfiles(r,run,environment){
17
+ const attrs=run(r,['check-attr','-z','--stdin','filter','working-tree-encoding'],{input:Buffer.from(r.paths.join('\0')+'\0')}).bytes.toString('utf8').split('\0'),names=new Set();
18
+ for(let i=0;i+2<attrs.length;i+=3){const [,attr,value]=attrs.slice(i,i+3);if(['unspecified','unset'].includes(value))continue;if(attr==='working-tree-encoding')unsupported();if(attr==='filter'&&value!=='set')names.add(value);}
19
+ const result=[];
20
+ for(const name of [...names].sort()){
21
+ const get=key=>{const p=run(r,['config','--get',`filter.${name}.${key}`],{allow:[0,1]});return p.code===0?p.text():'';};
22
+ if(get('process'))unsupported();const command=get('clean');if(!command)continue;
23
+ const argv=words(command);if(!argv[0].includes('/')&&new Set(['.',':','alias','bg','break','cd','command','continue','declare','echo','eval','exec','exit','export','fc','fg','getopts','hash','jobs','kill','local','printf','pwd','read','readonly','return','set','shift','test','time','times','trap','type','typeset','ulimit','umask','unalias','unset','wait','true','false']).has(argv[0]))unsupported();
24
+ const candidate=argv[0].includes('/')?path.resolve(r.root,argv[0]):(environment.PATH??'').split(path.delimiter).map(dir=>path.join(dir,argv[0])).find(file=>{try{return !!(safeFile(realpathSync(file)).mode&0o111);}catch{return false;}});
25
+ if(!candidate||!existsSync(candidate))unsupported();const executable=generation(realpathSync(candidate));if(!(executable.mode&0o111))unsupported();
26
+ // Bind literal file arguments (e.g. an interpreter's script) too. Indirect
27
+ // dependencies are trusted checkout code, like dependencies of commit hooks.
28
+ const files={};for(const arg of argv.slice(1)){if(arg==='%f'||arg.startsWith('-')||!arg)continue;const file=path.resolve(r.root,arg);if(existsSync(file))files[file]=generation(realpathSync(file));else if(arg.includes('/'))files[file]=null;}
29
+ result.push({name,command,executable,files});
30
+ }
31
+ return result;
32
+ }
@@ -0,0 +1,198 @@
1
+ // Guarded ordinary porcelain commits; unqualified execution profiles stay gated.
2
+ import {commitPlatformQualified,commitVersionQualified} from './git-commit-support.mjs';
3
+ import {spawnSync,spawn} from 'node:child_process';
4
+ import {readFileSync,writeFileSync,existsSync,lstatSync,statSync,fstatSync,realpathSync,openSync,closeSync,fsyncSync,linkSync,renameSync,unlinkSync,mkdirSync,constants} from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {gitEnvironment} from './app-git.mjs';
9
+ import {hash,fail,safeFile,syncDir,commitStore} from './git-commit-store.mjs';
10
+ import {processStartIdentity,currentProcessOwner} from './atomic-mutation.mjs';
11
+ import {signingProfile,prepareSigning,verifySignature} from './git-commit-signing.mjs';
12
+ import {filterProfiles} from './git-commit-filters.mjs';
13
+ const MAX_INDEX=2*1024*1024,MAX_SOURCE=8*1024*1024,MAX_REVIEW=1024*1024;
14
+ export const hooks=['pre-commit','prepare-commit-msg','commit-msg','reference-transaction','post-commit','post-index-change'];
15
+ const decode=b=>{try{return new TextDecoder('utf8',{fatal:true}).decode(b);}catch{fail('git-encoding','Git content is not valid UTF-8. Review it with Git.');}};
16
+ const same=(a,b)=>a?.exists===b?.exists&&a?.hash===b?.hash&&a?.mode===b?.mode;
17
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
18
+ const shell=value=>"'"+value.replaceAll("'","'\\''")+"'";
19
+ export function argumentsFor(args){return ['--no-optional-locks',...(args[0]==='check-ignore'?[]:['--literal-pathspecs']),'-c','core.fsmonitor=false','-c','core.untrackedCache=false','-c','gc.auto=0','-c','maintenance.auto=false',...args];}
20
+ export function run(r,args,{input,env={},allow=[0],max=16*1024*1024}={}){
21
+ const timeout=Math.min(10000,(r.deadline??Date.now()+10000)-Date.now());if(timeout<=0)fail('git-timeout','The Git job timed out. Inspect its retained operation.');
22
+ // Private index preparation is not a user commit and must never run hooks.
23
+ const internal=['add','read-tree','write-tree','update-index'].includes(args[0])?['-c','core.hooksPath=/dev/null',...args]:args;
24
+ const result=spawnSync(r.binary??'git',argumentsFor(internal),{cwd:r.root,env:{...gitEnvironment(),...env},input,timeout,maxBuffer:max,windowsHide:true});
25
+ if(result.error&&r.material?.filters?.length&&r.workerOwner?.pid===process.pid){const store=commitStore(r.root,r.actor);store.update(r.id,d=>{d.failure={code:'git-filter-limits',message:'The filtered Git step exceeded its limits. Its process group was stopped; no review was accepted.'};if(d.action==='preview')d.state='failed';});process.kill(-process.pid,'SIGKILL');}
26
+ if(result.error)fail(result.error.code==='ETIMEDOUT'?'git-timeout':'git-unavailable','Git stopped or exceeded its job limits. Inspect the retained operation.');
27
+ if(!allow.includes(result.status))fail('git-command-failed',`Git refused the ${args.find(arg=>!arg.startsWith('-'))??'local'} step. The receipt was kept; use Git to inspect this checkout.`);
28
+ return {code:result.status,bytes:result.stdout,text:()=>decode(result.stdout).trim()};
29
+ }
30
+ const text=(r,...args)=>run(r,args).text();
31
+ export function snapshot(file){
32
+ if(!existsSync(file))return {exists:false,hash:null,bytes:null,mode:null};const st=safeFile(file,MAX_INDEX),fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW);
33
+ try{const before=fstatSync(fd),bytes=readFileSync(fd),after=lstatSync(file);if(before.dev!==st.dev||before.ino!==st.ino||bytes.length>MAX_INDEX||before.ino!==after.ino||before.dev!==after.dev||before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)fail('git-review-changed','The index changed during inspection. Review again.');return {exists:true,hash:hash(bytes),bytes:bytes.toString('base64'),mode:st.mode&0o777};}finally{closeSync(fd);}
34
+ }
35
+ export function ownership(file){const st=safeFile(file,MAX_INDEX);return {dev:st.dev,ino:st.ino,hash:hash(readFileSync(file))};}
36
+ const matches=(file,owner)=>{try{return equal(ownership(file),owner);}catch{return false;}};
37
+ // Git's published index may refresh stat/cache-tree fields. Entries, stages and
38
+ // flags must match, and no unqualified index extension is permitted anywhere.
39
+ export function plainIndex(bytes,{intent=false}={}){
40
+ if(bytes.length<32||bytes.toString('ascii',0,4)!=='DIRC'||![2,3].includes(bytes.readUInt32BE(4)))fail('git-index-unsupported','This index format is not qualified for local commits. Use Git.');
41
+ const version=bytes.readUInt32BE(4),count=bytes.readUInt32BE(8);let at=12;
42
+ for(let i=0;i<count;i++){
43
+ const start=at;if(at+62>bytes.length-20)fail('git-index-unsupported','The index is incomplete. Use Git.');
44
+ const flags=bytes.readUInt16BE(at+60);if(flags&0xb000)fail('git-index-unsupported','Conflicted or assume-unchanged index entries require Git.');at+=62;
45
+ if(flags&0x4000){if(version!==3||at+2>bytes.length-20)fail('git-index-unsupported','Extended index flags require Git.');const extended=bytes.readUInt16BE(at);if(extended!==0&&!(intent&&extended===0x2000))fail('git-index-unsupported','Sparse, intent-to-add or extended index flags require Git.');at+=2;}
46
+ const end=bytes.indexOf(0,at);if(end<at||end>=bytes.length-20)fail('git-index-unsupported','The index path is incomplete. Use Git.');at=start+Math.ceil((end+1-start)/8)*8;
47
+ }
48
+ while(at<bytes.length-20){if(at+8>bytes.length-20)fail('git-index-unsupported','The index extension is incomplete.');const name=bytes.toString('ascii',at,at+4),size=bytes.readUInt32BE(at+4);if(name!=='TREE')fail('git-index-unsupported',`Index extension ${name} is not qualified. Use Git.`);at+=8+size;}
49
+ if(at!==bytes.length-20)fail('git-index-unsupported','The index has an invalid length. Use Git.');
50
+ const checksum=hash(bytes); // SHA-256 is the receipt generation, not Git's checksum.
51
+ return checksum;
52
+ }
53
+ export function semantics(r,index,{intent=false}={}){const snap=snapshot(index);if(!snap.exists)fail('git-index-unsupported','This checkout has no regular index. Use Git.');plainIndex(Buffer.from(snap.bytes,'base64'),{intent});const env={GIT_INDEX_FILE:index};return [run(r,['ls-files','--stage','-z'],{env}).bytes.toString('base64'),run(r,['ls-files','-v','-z'],{env}).bytes.toString('base64')];}
54
+ function fileMaterial(file,max=2*1024*1024){if(!existsSync(file))return null;const st=safeFile(file,max);return {hash:hash(readFileSync(file)),mode:st.mode&0o777};}
55
+ function configuredHooks(r){const explicit=run(r,['config','--path','--get','core.hooksPath'],{allow:[0,1]}),dir=explicit.code===0?path.resolve(r.root,explicit.text()):path.resolve(r.root,text(r,'rev-parse','--git-path','hooks'));const result={dir,files:{}};for(const kind of hooks)result.files[kind]=fileMaterial(path.join(dir,kind));return result;}
56
+ function material(r){
57
+ const records=run(r,['config','--null','--list','--show-origin','--show-scope']).bytes;
58
+ // NUL format: scope, origin, key\nvalue. Effective content plus every origin
59
+ // file catches includes, global/system and worktree config generations.
60
+ const fields=decode(records).split('\0'),files=new Set();for(let i=0;i+2<fields.length;i+=3)if(fields[i+1].startsWith('file:'))files.add(path.resolve(r.root,fields[i+1].slice(5)));
61
+ for(const file of [r.configPath,path.join(r.root,'.gitattributes'),path.join(r.common,'info','attributes')])if(file)files.add(file);
62
+ for(const name of r.paths){let here=path.dirname(path.resolve(r.root,name));while(here!==r.root){files.add(path.join(here,'.gitattributes'));here=path.dirname(here);}}
63
+ const global=run(r,['config','--path','--get','core.attributesFile'],{allow:[0,1]});if(global.code===0)files.add(path.resolve(r.root,global.text()));else if(os.homedir())files.add(path.join(process.env.XDG_CONFIG_HOME??path.join(os.homedir(),'.config'),'git','attributes'));
64
+ const values={};for(const file of [...files].sort())values[file]=fileMaterial(file);
65
+ const attrs=run(r,['check-attr','-z','--stdin','filter','working-tree-encoding'],{input:Buffer.from(r.paths.join('\0')+'\0')}).bytes;
66
+ return {effective:hash(records),files:values,attrs:hash(attrs),hooks:configuredHooks(r),signing:signingProfile(r,run),filters:filterProfiles(r,run,gitEnvironment())};
67
+ }
68
+ function sourceMaterial(r){const result={};for(const name of r.paths){const file=path.resolve(r.root,name);if(path.relative(r.root,file).startsWith('..'))fail('forbidden','A reviewed path escaped the checkout.');let here=r.root;for(const part of name.split('/')){here=path.join(here,part);if(existsSync(here)&&lstatSync(here).isSymbolicLink())fail('git-review-changed','A reviewed path now traverses a symlink.');}if(!existsSync(file)){result[name]=null;continue;}const st=safeFile(file,MAX_SOURCE),bytes=readFileSync(file);result[name]={hash:hash(bytes),mode:st.mode&0o777};}return result;}
69
+ function topology(r){const root=realpathSync(text(r,'rev-parse','--show-toplevel'));if(root!==r.root)fail('git-root','Open the canonical Git checkout root.');const gitdir=realpathSync(path.resolve(r.root,text(r,'rev-parse','--git-dir'))),common=realpathSync(path.resolve(r.root,text(r,'rev-parse','--git-common-dir'))),index=path.resolve(r.root,text(r,'rev-parse','--git-path','index'));if(index!==path.join(gitdir,'index'))fail('git-index-unsupported','A nonstandard index location requires Git.');return {gitdir,common,index,identity:[statSync(gitdir).dev,statSync(gitdir).ino,statSync(common).dev,statSync(common).ino]};}
70
+ function repoReady(r){if(text(r,'rev-parse','--show-object-format')!=='sha1')fail('git-format-unsupported','SHA-256 repositories require separate qualification. Use Git.');for(const name of ['MERGE_HEAD','CHERRY_PICK_HEAD','REVERT_HEAD','rebase-merge','rebase-apply','BISECT_START','AUTO_MERGE'])if(existsSync(path.resolve(r.root,text(r,'rev-parse','--git-path',name))))fail('git-operation-active','Finish the current Git operation first.');const encoding=run(r,['config','--get','i18n.commitEncoding'],{allow:[0,1]});if(encoding.code===0&&!/^utf-?8$/i.test(encoding.text()))fail('git-encoding','Non-UTF-8 commit messages require Git.');}
71
+ function version(r){if(!commitPlatformQualified())fail('git-platform-unqualified','Local commits are not yet qualified on this platform and architecture. Use Git; document editing and Changes remain available.');const result=text(r,'--version');if(!commitVersionQualified(result))fail('git-version-unqualified','This Git version is not qualified for guarded local commits on this platform. Use Git.');return result;}
72
+ function assertTopology(r){if(!equal(topology(r),r.topology)||r.gitdir!==r.topology.gitdir||r.common!==r.topology.common||r.index!==r.topology.index)fail('git-recovery-unavailable','Git directories moved or the receipt topology changed. Keep the receipt for manual inspection.');}
73
+ function assertReview(r,{prepared=false}={}){assertTopology(r);if(text(r,'--version')!==r.gitVersion||(!prepared&&existsSync(r.index+'.lock')))fail('git-review-changed','Git changed or an index lock is active. Inspect with Git before reviewing again.');if(text(r,'rev-parse','HEAD')!==r.head||text(r,'symbolic-ref','HEAD')!==r.branch||!equal(sourceMaterial(r),r.sources)||!equal(material(r),r.material)||!same(snapshot(r.index),prepared?r.prepared:r.before))fail('git-review-changed','The branch, saved files, configuration or staging changed. Review a new local commit.');repoReady(r);}
74
+ function blob(r,tree,name){const record=run(r,['ls-tree','-z',tree,'--',name]).bytes;if(!record.length)return '';const match=decode(record).match(/^100(?:644|755) blob ([a-f0-9]{40})\t/);if(!match)fail('git-review-unavailable','A selected object is not ordinary Markdown.');if(Number(text(r,'cat-file','-s',match[1]))>MAX_REVIEW)fail('git-limit','A selected file exceeds the inline commit review limit.');const value=decode(run(r,['cat-file','blob',match[1]],{max:MAX_REVIEW}).bytes);if(value.includes('\0')||value.split('\n').length>20000)fail('git-limit','A selected file cannot be reviewed inline. Use Git.');return value;}
75
+ function privateStorage(r,job){const relative=path.relative(r.root,path.dirname(job)).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','Git operation storage must be ignored and untracked.');}
76
+ function stageSelected(r,index){const env={GIT_INDEX_FILE:index},known=new Set(decode(run(r,['ls-files','-z','--',...r.paths],{env}).bytes).split('\0'));let paths=r.paths.filter(name=>known.has(name)||existsSync(path.join(r.root,name)));if(r.material.filters.length&&paths.length){const selectedKnown=paths.filter(name=>known.has(name));if(selectedKnown.length)run(r,['update-index','--force-remove','-z','--stdin'],{env,input:Buffer.from(selectedKnown.join('\0')+'\0')});paths=paths.filter(name=>existsSync(path.join(r.root,name)));}if(paths.length)run(r,['add','-A','--pathspec-from-file=-','--pathspec-file-nul'],{env,input:Buffer.from(paths.join('\0')+'\0')});}
77
+ export function previewProtocol(store,id){
78
+ let r=store.read(id);r.deadline=Date.now()+60000;r.gitVersion=version(r);r.topology=topology(r);Object.assign(r,r.topology);repoReady(r);privateStorage(r,store.job(id));
79
+ r.branch=text(r,'symbolic-ref','HEAD');if(r.branch!==`refs/heads/${r.selectedBranch}`||text(r,'rev-parse','HEAD')!==r.head)fail('git-review-changed','The selected branch changed. Refresh Changes.');
80
+ if(existsSync(r.index+'.lock'))fail('git-index-locked','Git has an active index lock. Inspect it with Git before reviewing a local commit.');r.before=snapshot(r.index);semantics(r,r.index);r.material=material(r);r.sources=sourceMaterial(r);
81
+ for(const name of r.paths)if((r.sources[name]?.hash??null)!==r.authorizedSources[name])fail('git-review-changed','A selected file changed after authorization. Refresh Changes.');
82
+ // Retain ownership/material before any trusted filter executes, including
83
+ // timeout/cancellation evidence. The real index and branch are untouched.
84
+ store.write(r);
85
+ const job=store.job(id),scratch=path.join(job,'review-index'),expected=path.join(job,'expected-index'),input=Buffer.from(r.paths.join('\0')+'\0'),env={GIT_INDEX_FILE:scratch};
86
+ run(r,['read-tree',r.head],{env});stageSelected(r,scratch);r.tree=textWithIndex(r,scratch,'write-tree');
87
+ writeFileSync(expected,Buffer.from(r.before.bytes,'base64'),{flag:'wx',mode:0o600});const ee={GIT_INDEX_FILE:expected};
88
+ const staged=decode(run(r,['diff','--no-ext-diff','--no-textconv','--no-renames','--cached','--name-only','-z',r.head,'--',...r.paths]).bytes).split('\0').filter(Boolean);
89
+ stageSelected(r,expected);
90
+ if(!run(r,['ls-files','--stage','-z','--',...r.paths],{env}).bytes.equals(run(r,['ls-files','--stage','-z','--',...r.paths],{env:ee}).bytes))fail('git-filter-unstable','The clean filter produced different Git blobs during review. No commit was accepted.');
91
+ if(staged.length&&!run(r,['ls-files','--stage','-z','--',...staged]).bytes.equals(run(r,['ls-files','--stage','-z','--',...staged],{env:ee}).bytes))fail('git-partial-staging','Selected files have partial staging. Use Git to keep that staging.');
92
+ r.expectedReal=semantics(r,expected);const known=new Set(decode(run(r,['ls-files','-z','--',...r.paths]).bytes).split('\0'));r.new=r.paths.filter(p=>!known.has(p)&&existsSync(path.join(r.root,p)));
93
+ r.review=[];let reviewBytes=2;for(const row of r.rows){const entry={path:row.path,original:row.original??null,kind:row.kind,before:blob(r,r.head,row.original??row.path),after:blob(r,r.tree,row.path)};reviewBytes+=Buffer.byteLength(JSON.stringify(entry))+1;if(reviewBytes>MAX_REVIEW)fail('git-limit','The selected diff exceeds the 1 MiB review limit. Select fewer documents or use Git.');r.review.push(entry);}
94
+ if(r.tree===text(r,'rev-parse',r.head+'^{tree}'))fail('git-empty','The reviewed Git tree has no document changes.');
95
+ assertReview(r);r.state='reviewed';r.reviewedAt=new Date().toISOString();r.expiresAt=Date.now()+30*60*1000;delete r.deadline;store.write(r);return r;
96
+ }
97
+ const textWithIndex=(r,index,...args)=>run(r,args,{env:{GIT_INDEX_FILE:index}}).text();
98
+ export function casIndex(store,r,expected,desired,phase){
99
+ const artifact=path.join(r.gitdir,`.runlist-commit-${r.id}-${phase}`),lock=r.index+'.lock';
100
+ writeFileSync(artifact,Buffer.from(desired.bytes??'','base64'),{flag:'wx',mode:desired.mode??0o600});const fd=openSync(artifact,'r');try{fsyncSync(fd);}finally{closeSync(fd);}const owner=ownership(artifact);
101
+ store.update(r.id,d=>{d.cas={phase,owner,expected:{exists:expected.exists,hash:expected.hash,mode:expected.mode},desired:{exists:desired.exists,hash:desired.hash,mode:desired.mode}};});let acquired=false;
102
+ try{linkSync(artifact,lock);acquired=true;syncDir(r.gitdir);store.update(r.id,d=>{d.cas.lock=owner;});if(!same(snapshot(r.index),expected))fail('git-index-changed','Another process changed Git staging. Its index was preserved.');if(desired.exists)renameSync(lock,r.index);else{if(existsSync(r.index))unlinkSync(r.index);unlinkSync(lock);}acquired=false;syncDir(r.gitdir);}
103
+ finally{if(acquired&&matches(lock,owner)){unlinkSync(lock);syncDir(r.gitdir);}if(matches(artifact,owner)){unlinkSync(artifact);syncDir(r.gitdir);}}
104
+ }
105
+ export function verifyCommit(r,oid){if(!/^[a-f0-9]{40}$/.test(oid))fail('git-object-unreviewed','Git proposed an invalid commit ID.');const raw=run(r,['cat-file','commit',oid],{max:2*1024*1024}).bytes,split=raw.indexOf('\n\n');if(split<0)fail('git-object-unreviewed','Git proposed an incomplete commit.');const headers=raw.subarray(0,split).toString('utf8').split('\n'),tree=headers.filter(x=>x.startsWith('tree ')),parents=headers.filter(x=>x.startsWith('parent '));if(!equal(tree,['tree '+r.tree])||!equal(parents,['parent '+r.head])||!raw.subarray(split+2).equals(Buffer.from(r.message)))fail('git-object-unreviewed','The actual commit differs from the reviewed tree, parent or message.');verifySignature(r,raw,commitStore(r.root,r.actor).job(r.id));}
106
+ export function hookProtocol(store,id,kind,args,input){
107
+ let r=store.read(id);r.deadline=Date.now()+10000;if(!hooks.includes(kind)||r.gitOwner?.pid!==process.ppid||!r.workerOwner||r.gitOwner.processStartIdentity!==processStartIdentity(process.ppid))fail('git-hook-owner','The guard was invoked outside its registered Git process.');
108
+ if(kind==='pre-commit'){
109
+ const temp=process.env.GIT_INDEX_FILE;if(!temp||path.dirname(temp)!==r.gitdir||!path.basename(temp).startsWith('next-index-'))fail('git-hook-owner','Git did not supply an owned temporary commit index.');
110
+ if(!equal(semantics(r,r.index+'.lock',{intent:true}),r.expectedReal))fail('git-index-unreviewed','Git prepared an unreviewed final index.');
111
+ const lock=ownership(r.index+'.lock');if(r.gitLock&&(lock.dev!==r.gitLock.dev||lock.ino!==r.gitLock.ino))fail('git-index-unreviewed','A configured hook replaced the Git-owned index lock.');
112
+ store.update(id,d=>{d.commitIndex=temp;d.tempOwner=ownership(temp);d.gitLock=ownership(r.index+'.lock');d.published=snapshot(r.index+'.lock');});
113
+ }
114
+ if(kind!=='reference-transaction')return;
115
+ const rows=decode(input).trim().split('\n').filter(Boolean).map(line=>line.split(' '));
116
+ if(args[0]==='prepared'){
117
+ if(r.commit&&rows.length===1&&rows[0][2]==='AUTO_MERGE'&&/^0{40}$/.test(rows[0][0])&&/^0{40}$/.test(rows[0][1])&&run(r,['rev-parse','--verify','--quiet','AUTO_MERGE'],{allow:[0,1]}).code===1)return;
118
+ // Git 2.47.3 supplies zero for symbolic HEAD, while the mandatory
119
+ // branch row carries the checked old OID. assertReview below verifies HEAD
120
+ // still resolves to this branch/parent; this never permits a zero branch CAS.
121
+ const symbolicHeadZero=r.gitVersion==='git version 2.47.3'&&process.platform==='linux'&&process.arch==='arm64';
122
+ if(rows.some(row=>row.length!==3||![r.branch,'HEAD'].includes(row[2])||(row[0]!==r.head&&!(symbolicHeadZero&&row[2]==='HEAD'&&/^0{40}$/.test(row[0]))))||rows.filter(row=>row[2]===r.branch).length!==1||rows.filter(row=>row[2]==='HEAD').length>1||new Set(rows.map(row=>row[1])).size!==1)fail('git-ref-unreviewed','Git proposed an unreviewed ref transaction.');
123
+ const oid=rows[0][1];verifyCommit(r,oid);assertReview(r,{prepared:true});
124
+ const lockedOwner=ownership(r.index+'.lock');if(!r.gitLock||lockedOwner.dev!==r.gitLock.dev||lockedOwner.ino!==r.gitLock.ino)fail('git-index-unreviewed','The Git-owned index lock was replaced.');
125
+ if(!equal(semantics(r,r.index+'.lock',{intent:true}),r.expectedReal)||textWithIndex(r,r.commitIndex,'write-tree')!==r.tree)fail('git-index-unreviewed','Git prepared unreviewed index content.');
126
+ store.update(id,d=>{d.candidate=oid;d.state='ref-prepared';d.tempOwner=ownership(r.commitIndex);d.gitLock=ownership(r.index+'.lock');d.published=snapshot(r.index+'.lock');});
127
+ }else if(args[0]==='committed'&&r.candidate&&rows.some(row=>row[1]===r.candidate&&row[2]===r.branch))store.update(id,d=>{d.commit=r.candidate;d.state='ref-committed';});
128
+ }
129
+ // Run the configured original at its real location, with Git's arguments,
130
+ // index/editor variables and cwd. Nested Git sees the original hooks directory.
131
+ // A hook is trusted checkout code, not a sandbox; its own external effects are
132
+ // never undone. The final publication guard remains authoritative.
133
+ export function hookInvocation(store,id,kind,args,input){
134
+ let r=store.read(id);if(!hooks.includes(kind)||r.gitOwner?.pid!==process.ppid||r.gitOwner.processStartIdentity!==processStartIdentity(process.ppid))fail('git-hook-owner','The hook was invoked outside its registered Git process.');
135
+ if(kind==='pre-commit'||kind==='reference-transaction'&&args[0]==='committed')hookProtocol(store,id,kind,args,input);
136
+ r=store.read(id);const configured=r.material?.hooks,entry=configured?.files[kind];
137
+ if(entry?.mode&0o111){
138
+ const file=path.join(configured.dir,kind);if(!equal(fileMaterial(file),entry))fail('git-review-changed','A configured hook changed after review. Review again.');
139
+ const env={...process.env,GIT_CONFIG_PARAMETERS:['core.fsmonitor=false','core.untrackedCache=false','gc.auto=0','maintenance.auto=false','core.hooksPath='+configured.dir].map(shell).join(' ')};delete env.GIT_CONFIG_COUNT;
140
+ const result=spawnSync(file,args,{cwd:r.root,env,input,stdio:['pipe','inherit','inherit'],timeout:10000,killSignal:'SIGKILL',windowsHide:true});
141
+ store.update(id,d=>{d.hookResults??=[];if(d.hookResults.length>=96)fail('git-limit','Too many configured hook invocations.');d.hookResults.push({kind,phase:kind==='reference-transaction'?args[0]:null,code:result.status,at:new Date().toISOString()});if(result.error||result.status!==0)d.failure={code:result.error?.code==='ETIMEDOUT'?'git-hook-timeout':'git-hook-rejected',message:`Configured ${kind} hook ${result.error?'stopped or timed out':'returned a failure'}. Inspect the retained outcome; its external changes are preserved.`};});
142
+ if(result.error?.code==='ETIMEDOUT'){process.kill(-r.workerOwner.pid,'SIGKILL');return;}
143
+ if(result.error||result.status!==0){if(['pre-commit','prepare-commit-msg','commit-msg'].includes(kind)||kind==='reference-transaction'&&['preparing','prepared'].includes(args[0]))fail('git-hook-rejected',`Configured ${kind} hook refused the commit.`);}
144
+ }
145
+ hookProtocol(store,id,kind,args,input);
146
+ }
147
+ export async function executeProtocol(store,id){
148
+ let r=store.read(id);r.deadline=Date.now()+60000;assertReview(r);if(Date.now()>r.expiresAt)fail('git-review-expired','This review expired. Review current files again.');
149
+ const script=fileURLToPath(new URL('./git-commit-worker.mjs',import.meta.url)),signerArgs=prepareSigning(store,r,script,shell);
150
+ let prepared=r.before;
151
+ if(r.new.length){const file=path.join(store.job(id),'intent-index');writeFileSync(file,Buffer.from(r.before.bytes,'base64'),{flag:'wx',mode:0o600});run(r,['add','--intent-to-add','--pathspec-from-file=-','--pathspec-file-nul'],{env:{GIT_INDEX_FILE:file},input:Buffer.from(r.new.join('\0')+'\0')});prepared=snapshot(file);}
152
+ store.update(id,d=>{d.prepared=prepared;d.state='preparing';});if(!same(prepared,r.before))casIndex(store,r,r.before,prepared,'prepare');
153
+ r=store.read(id);r.deadline=Date.now()+60000;assertReview(r,{prepared:true});
154
+ const job=store.job(id),wrapper=path.join(job,'hooks');mkdirSync(wrapper,{mode:0o700});
155
+ const envFile=path.join(job,'message');writeFileSync(envFile,r.message,{mode:0o600,flag:'wx'});
156
+ for(const kind of hooks)writeFileSync(path.join(wrapper,kind),'#!/bin/sh\nexec '+shell(process.execPath)+' --max-old-space-size=128 '+shell(script)+' --hook '+shell(r.root)+' '+shell(r.actor.id)+' '+shell(id)+' '+shell(kind)+' "$@"\n',{mode:0o700,flag:'wx'});
157
+ const args=argumentsFor([...signerArgs,'-c','core.hooksPath='+wrapper,'commit','--only','--pathspec-from-file=-','--pathspec-file-nul','--cleanup=verbatim','--file',envFile]);
158
+ await new Promise((resolve,reject)=>{
159
+ const child=spawn(r.binary??'git',args,{cwd:r.root,env:{...gitEnvironment(),GIT_REFLOG_ACTION:'runlist-local:'+id},stdio:['pipe','pipe','pipe'],windowsHide:true});let size=0,finished=false;
160
+ const stop=()=>{if(finished)return;finished=true;store.update(id,d=>{d.failure={code:'git-timeout',message:'Git exceeded its commit time/output limits. Its process group was stopped; inspect the retained outcome.'};});process.kill(-process.pid,'SIGKILL');};
161
+ const timer=setTimeout(stop,60000);const append=chunk=>{size+=chunk.length;if(size>128000)stop();};child.stdout.on('data',append);child.stderr.on('data',append);child.stdin.on('error',()=>{});
162
+ child.on('error',error=>{clearTimeout(timer);if(!finished){finished=true;reject(error);}});
163
+ child.on('close',code=>{clearTimeout(timer);if(finished)return;finished=true;store.update(id,d=>{d.exitCode=code;if(code!==0&&!d.failure)d.failure={code:'git-commit-failed',message:'Git or a configured hook/signer refused the local commit. Inspect the retained outcome before continuing.'};});resolve();});
164
+ store.update(id,d=>{d.state='executing';d.gitOwner={...currentProcessOwner(),pid:child.pid,processStartIdentity:processStartIdentity(child.pid)};});child.stdin.end(Buffer.from(r.paths.join('\0')+'\0'));
165
+ });
166
+ return inspectProtocol(store,id);
167
+ }
168
+ export function inspectProtocol(store,id){
169
+ let r=store.read(id);r.deadline=Date.now()+10000;if(!r.topology)return r;
170
+ assertTopology(r);
171
+ const oid=r.commit??r.candidate,ref=run(r,['rev-parse','--verify',r.branch],{allow:[0,128]}).text();store.update(id,d=>{d.observedRef=ref;d.observedIndex= snapshot(r.index).hash;});let state;
172
+ if(oid){verifyCommit(r,oid);if(ref===oid){const ready=equal(semantics(r,r.index,{intent:true}),r.expectedReal);state=ready?'committed':'committed-index-recovery';return store.update(id,d=>{d.state=state;d.commit=oid;d.indexReady=ready;});}state=r.commit?'committed-external-ref':ref===r.head?'uncertain-ref-publication':'uncertain-external-ref';}
173
+ else state=ref===r.head?'not-committed':'uncertain-external-ref';
174
+ return store.update(id,d=>{d.state=state;});
175
+ }
176
+ export function recoverProtocol(store,id){
177
+ let r=inspectProtocol(store,id);if(!['committed','committed-index-recovery','not-committed'].includes(r.state))return store.update(id,d=>{d.recovery='uncertain-outcome-preserved';});
178
+ const lock=r.index+'.lock';if(existsSync(lock)){const owner=r.gitLock??r.cas?.lock??r.cas?.owner;if(!owner||!matches(lock,owner))return store.update(id,d=>{d.recovery='foreign-or-unverified-lock-preserved';});unlinkSync(lock);syncDir(r.gitdir);}
179
+ if(r.commitIndex&&r.tempOwner&&matches(r.commitIndex,r.tempOwner)){unlinkSync(r.commitIndex);syncDir(r.gitdir);}
180
+ if(r.cas){const artifact=path.join(r.gitdir,`.runlist-commit-${r.id}-${r.cas.phase}`);if(matches(artifact,r.cas.owner)){unlinkSync(artifact);syncDir(r.gitdir);}}
181
+ const current=snapshot(r.index),prepared=r.prepared??r.before;
182
+ if(r.state==='committed-index-recovery'){
183
+ if(!same(current,prepared)||!r.published)return store.update(id,d=>{d.recovery='foreign-index-preserved';});
184
+ casIndex(store,r,current,r.published,'recover');return store.update(id,d=>{d.state='committed';d.indexReady=true;d.recovery='published-reviewed-index';d.resolved=true;});
185
+ }
186
+ if(r.state==='not-committed'){
187
+ if(!same(current,prepared)&&!same(current,r.before))return store.update(id,d=>{d.recovery='foreign-index-preserved';});
188
+ if(!same(current,r.before))casIndex(store,r,current,r.before,'recover');
189
+ }
190
+ return store.update(id,d=>{d.recovery=r.state==='not-committed'?'preparation-restored':'no-mutation-needed';d.resolved=true;});
191
+ }
192
+
193
+ export function settleProtocol(store,id){
194
+ const r=inspectProtocol(store,id),request=r.settlementRequest;
195
+ if(!request||!['uncertain-ref-publication','uncertain-external-ref','committed-external-ref'].includes(r.state))fail('git-settlement-unavailable','Only an inspected uncertain or externally moved commit can be acknowledged.');
196
+ if(r.observedRef!==request.expectedRef||r.observedIndex!==request.expectedIndexRevision||existsSync(r.index+'.lock'))fail('git-review-changed','The inspected ref/index changed or a lock remains. Inspect with Git before acknowledging.');
197
+ return store.update(id,d=>{d.resolved=true;d.settlement={by:d.actor,at:new Date().toISOString(),note:request.note,ref:d.observedRef,index:d.observedIndex};d.recovery='acknowledged-without-replay';});
198
+ }
@@ -0,0 +1,75 @@
1
+ // Explicit noninteractive signing profiles. No renderer-supplied executables,
2
+ // shell commands, agent forwarding, passphrases or UI credential operations.
3
+ import {spawnSync} from 'node:child_process';
4
+ import {readFileSync,writeFileSync,realpathSync,existsSync,openSync,closeSync,constants} from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import {hash,fail,safeFile} from './git-commit-store.mjs';
8
+ import {gitEnvironment} from './app-git.mjs';
9
+ import {processStartIdentity} from './atomic-mutation.mjs';
10
+ const equal=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
11
+ const material=file=>{const st=safeFile(file);return {hash:hash(readFileSync(file)),mode:st.mode&0o777};};
12
+ function privateFile(file,bytes){if(existsSync(file))safeFile(file);const fd=openSync(file,constants.O_WRONLY|constants.O_CREAT|constants.O_TRUNC|constants.O_NOFOLLOW,0o600);try{writeFileSync(fd,bytes);}finally{closeSync(fd);}}
13
+ function binary(value,root){
14
+ const candidates=value.includes('/')?[path.resolve(root,value)]:(gitEnvironment().PATH??'').split(path.delimiter).map(dir=>path.join(dir,value));
15
+ for(const candidate of candidates)if(existsSync(candidate)){const file=realpathSync(candidate),st=safeFile(file);if(st.mode&0o111)return {file,material:material(file)};}
16
+ fail('git-signing-unavailable','The configured signing executable is unavailable. Use your Git signing workflow.');
17
+ }
18
+ export function signingProfile(r,run){
19
+ const config=(name,pathValue=false)=>{const q=run(r,['config',...(pathValue?['--path']:[]),'--get',name],{allow:[0,1]});return q.code===0?q.text():null;};
20
+ const required=run(r,['config','--bool','--get','commit.gpgSign'],{allow:[0,1]});if(required.code!==0||required.text()!=='true')return {format:'none'};
21
+ const format=config('gpg.format')??'openpgp',key=config('user.signingkey',format==='ssh');
22
+ if(!key||key.includes('\0'))fail('git-signing-unsupported','Noninteractive signing requires an explicit user.signingkey. Configure it with Git.');
23
+ if(format==='ssh'){
24
+ if(/^(key::|ssh-)/.test(key))fail('git-signing-unsupported','SSH agent/public-key signing is not qualified. Use an explicit local private key or your Git workflow.');
25
+ const file=path.resolve(r.root,key);if(!existsSync(file))fail('git-signing-unsupported','The configured SSH private-key file is unavailable.');const keyMaterial=material(file);if(!readFileSync(file).subarray(0,80).toString().includes('BEGIN OPENSSH PRIVATE KEY'))fail('git-signing-unsupported','SSH signing requires a local OpenSSH private-key file. Agent and hardware signing remain separate.');
26
+ const program=binary(config('gpg.ssh.program',true)??'ssh-keygen',r.root);if(path.basename(program.file)!=='ssh-keygen')fail('git-signing-unsupported','Custom SSH signer commands are not qualified. Use ssh-keygen or your Git workflow.');
27
+ return {format,key:file,keyMaterial,program};
28
+ }
29
+ if(format!=='openpgp'||! /^[a-f0-9]{40}!?$/i.test(key))fail('git-signing-unsupported','Noninteractive OpenPGP signing requires a full key fingerprint. X.509 and other signer formats remain separate.');
30
+ const named=config('gpg.openpgp.program',true),legacy=config('gpg.program',true);if(named&&legacy&&named!==legacy)fail('git-signing-unsupported','Conflicting OpenPGP program aliases require your Git workflow; Runlist will not select or bypass either signer.');
31
+ const program=binary(named??legacy??'gpg',r.root);if(!/^gpg2?$/.test(path.basename(program.file)))fail('git-signing-unsupported','Custom OpenPGP signer commands are not qualified. Use GnuPG or your Git workflow.');
32
+ return {format,key,home:path.resolve(process.env.GNUPGHOME??path.join(os.homedir(),'.gnupg')),program};
33
+ }
34
+ export function signerEnvironment(profile){
35
+ const env=gitEnvironment();env.SSH_AUTH_SOCK='';env.SSH_ASKPASS='/usr/bin/false';env.SSH_ASKPASS_REQUIRE='force';env.DISPLAY='runlist-noninteractive';
36
+ if(profile.format==='openpgp')env.GNUPGHOME=profile.home;
37
+ return env;
38
+ }
39
+ const gpgArgs=['--batch','--no-tty','--no-autostart','--pinentry-mode','error','--no-auto-key-retrieve','--auto-key-locate','clear'];
40
+ function tool(profile,args,input){
41
+ if(!equal(material(profile.program.file),profile.program.material))fail('git-review-changed','The signing executable changed after review.');
42
+ const q=spawnSync(profile.program.file,[...(profile.format==='openpgp'?gpgArgs:[]),...args],{env:signerEnvironment(profile),input,timeout:10000,maxBuffer:128000,killSignal:'SIGKILL',windowsHide:true});
43
+ if(q.error||q.status!==0)fail('git-signing-failed','Noninteractive signing or signature verification failed. No credential dialog was requested; inspect the retained outcome and use your Git workflow.');
44
+ return q;
45
+ }
46
+ export function prepareSigning(store,r,worker,shell){
47
+ const profile=r.material.signing;if(profile.format==='none')return [];
48
+ if(profile.format==='ssh'){
49
+ const pub=tool(profile,['-y','-P','','-f',profile.key]).stdout.toString().trim();if(!/^ssh-ed25519 [A-Za-z0-9+/=]+(?: |$)/.test(pub))fail('git-signing-unsupported','Only local Ed25519 SSH signing keys are qualified.');
50
+ store.update(r.id,d=>{d.signingPublicKey=pub.split(' ').slice(0,2).join(' ');});
51
+ }
52
+ const wrapper=path.join(store.job(r.id),'signer');writeFileSync(wrapper,'#!/bin/sh\nexec '+shell(process.execPath)+' --max-old-space-size=128 '+shell(worker)+' --sign '+shell(r.root)+' '+shell(r.actor.id)+' '+shell(r.id)+' "$@"\n',{flag:'wx',mode:0o700});
53
+ return ['-c',`gpg.${profile.format}.program=${wrapper}`,...(profile.format==='openpgp'?['-c',`gpg.program=${wrapper}`]:[])];
54
+ }
55
+ export function signingInvocation(store,id,args){
56
+ const r=store.read(id),profile=r.material?.signing;if(!profile||profile.format==='none'||r.gitOwner?.pid!==process.ppid||r.gitOwner.processStartIdentity!==processStartIdentity(process.ppid))fail('git-signer-owner','The signer was invoked outside its registered Git process.');
57
+ if(!equal(material(profile.program.file),profile.program.material))fail('git-review-changed','The configured signer changed after review.');
58
+ const q=spawnSync(profile.program.file,[...(profile.format==='openpgp'?gpgArgs:[]),...args],{cwd:r.root,env:signerEnvironment(profile),stdio:'inherit',timeout:10000,killSignal:'SIGKILL',windowsHide:true});
59
+ if(q.error||q.status!==0){store.update(id,d=>{d.failure={code:'git-signing-failed',message:'The configured signer could not sign noninteractively. Its requirement was not bypassed. Inspect the retained outcome.'};});if(q.error?.code==='ETIMEDOUT'){process.kill(-r.workerOwner.pid,'SIGKILL');return;}fail('git-signing-failed','The noninteractive signer refused the commit.');}
60
+ }
61
+ export function verifySignature(r,raw,job){
62
+ const profile=r.material?.signing??{format:'none'},split=raw.indexOf('\n\n'),lines=raw.subarray(0,split).toString('utf8').split('\n'),unsigned=[],signature=[];let active=false,count=0;
63
+ for(const line of lines){if(line.startsWith('gpgsig ')){active=true;count++;signature.push(line.slice(7));}else if(active&&line.startsWith(' '))signature.push(line.slice(1));else{active=false;unsigned.push(line);}}
64
+ if(profile.format==='none'){if(count)fail('git-signature-unreviewed','An unreviewed signature was added.');return;}
65
+ if(count!==1)fail('git-signature-unreviewed','The configured signed commit has no unique signature.');
66
+ const payload=Buffer.concat([Buffer.from(unsigned.join('\n')+'\n\n'),raw.subarray(split+2)]),sig=signature.join('\n')+'\n',file=path.join(job,'verify-signature');privateFile(file,sig);
67
+ if(profile.format==='ssh'){
68
+ if(!r.signingPublicKey||!sig.startsWith('-----BEGIN SSH SIGNATURE-----'))fail('git-signature-unreviewed','The SSH signature differs from the reviewed signing profile.');
69
+ const allowed=path.join(job,'verify-signers');privateFile(allowed,'runlist '+r.signingPublicKey+'\n');tool(profile,['-Y','verify','-f',allowed,'-I','runlist','-n','git','-s',file],payload);
70
+ }else{
71
+ if(!sig.startsWith('-----BEGIN PGP SIGNATURE-----'))fail('git-signature-unreviewed','The OpenPGP signature differs from the reviewed signing profile.');
72
+ const q=tool(profile,['--status-fd=1','--verify',file,'-'],payload),valid=q.stdout.toString().split('\n').find(line=>line.startsWith('[GNUPG:] VALIDSIG ')),fields=valid?.slice(18).split(' ')??[],key=profile.key.replace(/!$/,'').toUpperCase();
73
+ if(!(profile.key.endsWith('!')?fields[0]===key:fields[0]===key||fields.at(-1)===key))fail('git-signature-unreviewed','The signature was made by another OpenPGP key.');
74
+ }
75
+ }
@@ -0,0 +1,26 @@
1
+ // Private, actor/checkout-bound Git receipts. No renderer-controlled locations.
2
+ import {existsSync,lstatSync,mkdirSync,readdirSync,readFileSync,realpathSync,openSync,writeFileSync,closeSync,fsyncSync,renameSync,unlinkSync} from 'node:fs';
3
+ import {randomUUID,createHash} from 'node:crypto';
4
+ import path from 'node:path';
5
+ import {stateDir} from './naming.mjs';
6
+ import {withPathLocks} from './atomic-mutation.mjs';
7
+ import {SourceEditError} from './source-editor.mjs';
8
+ export const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
9
+ export const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
10
+ export const fail=(code,message)=>{throw new SourceEditError(code,message);};
11
+ export function syncDir(dir){const fd=openSync(dir,'r');try{fsyncSync(fd);}finally{closeSync(fd);}}
12
+ export function safeFile(file,max=16*1024*1024){const st=lstatSync(file);if(!st.isFile()||st.isSymbolicLink()||st.size>max)fail('git-state-corrupt','Git operation storage is unsafe or exceeds its limit.');return st;}
13
+ export function atomicJson(file,value){const bytes=Buffer.from(JSON.stringify(value)+'\n');if(bytes.length>16*1024*1024)fail('git-limit','Git receipt exceeds its private storage limit.');const temp=file+'.tmp-'+randomUUID();const fd=openSync(temp,'wx',0o600);try{writeFileSync(fd,bytes);fsyncSync(fd);}finally{closeSync(fd);}try{renameSync(temp,file);syncDir(path.dirname(file));}finally{if(existsSync(temp))unlinkSync(temp);}}
14
+ export function commitStore(root,actor){
15
+ root=realpathSync(root);const directory=path.join(stateDir(root),'editor','git-commits');
16
+ function safe(file){if(path.relative(root,file).startsWith('..')||path.isAbsolute(path.relative(root,file)))fail('git-state-corrupt','Git storage escaped the checkout.');let here=root;for(const part of path.relative(root,file).split(path.sep)){here=path.join(here,part);if(existsSync(here)&&lstatSync(here).isSymbolicLink())fail('git-state-corrupt','Git operation storage may not traverse symlinks.');}return file;}
17
+ function job(id){if(!uuid.test(id??''))fail('invalid-id','Use a UUID v4 Git operation ID.');return safe(path.join(directory,id));}
18
+ const location=id=>path.join(job(id),'receipt.json');
19
+ function ensure(){safe(directory);const missing=[];let here=directory;while(!existsSync(here)){missing.push(here);here=path.dirname(here);}for(const dir of missing.reverse()){mkdirSync(dir,{mode:0o700});syncDir(path.dirname(dir));}if(!lstatSync(directory).isDirectory()||(lstatSync(directory).mode&0o077))fail('git-state-corrupt','Git receipt storage must be a private directory.');}
20
+ function read(id){const file=location(id);if(!existsSync(file))return null;const st=safeFile(file);if(st.mode&0o077)fail('git-state-corrupt','Git receipts must remain private.');let value;try{value=JSON.parse(readFileSync(file,'utf8'));}catch{fail('git-state-corrupt','Git receipt is unreadable. Keep it for manual inspection.');}if(value.schema!==1||value.id!==id||value.root!==root||!Array.isArray(value.paths)||value.paths.length>200||!value.actor||typeof value.state!=='string')fail('git-state-corrupt','Git receipt does not match this checkout.');if(value.actor.kind!=='human'||value.actor.id!==actor.id)fail('forbidden','This Git operation belongs to another human.');const {integrity,...payload}=value;if(integrity!==hash(JSON.stringify(payload)))fail('git-state-corrupt','The Git receipt generation is corrupt. Keep it for manual inspection.');if(!value.paths.length||value.paths.some(p=>typeof p!=='string'||!p.endsWith('.md')||p.includes('\0')||p.includes('\\')||path.posix.isAbsolute(p)||path.posix.normalize(p)!==p||p.split('/').some(part=>['..','.git','.runlist','.dotmd','prompts'].includes(part))))fail('git-state-corrupt','Git receipt paths are invalid.');return value;}
21
+ function write(value){const file=location(value.id);safe(file);if(existsSync(file))safeFile(file);delete value.integrity;value.integrity=hash(JSON.stringify(value));atomicJson(file,value);return value;}
22
+ function update(id,fn){return withPathLocks([location(id)],{repoRoot:root,timeoutMs:2000},()=>{const value=read(id);if(!value)fail('git-operation-missing','This Git operation is unavailable.');fn(value);return write(value);});}
23
+ function all(){safe(directory);if(!existsSync(directory))return [];const dirs=readdirSync(directory).filter(id=>uuid.test(id));if(dirs.length>500)fail('git-limit','There are more than 500 retained Git operations. Archive completed private receipts with your local tools before continuing.');return dirs.map(id=>read(id));}
24
+ function create(value){ensure();if(all().length>=500)fail('git-limit','Private Git receipt storage is full. Keep unfinished receipts; archive completed ones with your local tools.');mkdirSync(job(value.id),{mode:0o700});syncDir(directory);return write(value);}
25
+ return {root,directory,job,location,ensure,create,read,write,update,all,safe,exclusive:fn=>withPathLocks([directory],{repoRoot:root,timeoutMs:2000},fn)};
26
+ }