@ikunin/sprintpilot 1.0.5 → 2.0.5
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.
- package/README.md +48 -1
- package/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +425 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// state-shard.js — per-story read/write primitive for state + decision-log.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// state-shard.js write --story <key> [--kind state|decision-log]
|
|
7
|
+
// (--field <path>=<value> | --json <json>)
|
|
8
|
+
// state-shard.js read --story <key> [--kind state|decision-log]
|
|
9
|
+
// [--format yaml|json]
|
|
10
|
+
// state-shard.js append --story <key> [--kind state|decision-log]
|
|
11
|
+
// --path <list-path> --entry <json>
|
|
12
|
+
// state-shard.js init --story <key> [--kind state|decision-log]
|
|
13
|
+
//
|
|
14
|
+
// Shard layout:
|
|
15
|
+
// <project-root>/_bmad-output/implementation-artifacts/
|
|
16
|
+
// .autopilot-state/<story>.yaml (kind=state, default)
|
|
17
|
+
// .decision-log/<story>.yaml (kind=decision-log)
|
|
18
|
+
//
|
|
19
|
+
// File format:
|
|
20
|
+
// Flat dotted-keys + JSON flow-form for arrays and objects-of-arrays.
|
|
21
|
+
// Still valid YAML ("a.b: 1" is a single-key mapping), but trivial to
|
|
22
|
+
// round-trip without a full YAML parser. This keeps the installer
|
|
23
|
+
// dep-free — user projects don't need js-yaml to run the script.
|
|
24
|
+
//
|
|
25
|
+
// Atomic writes: tmp sibling + rename(). POSIX rename is atomic inside a
|
|
26
|
+
// filesystem; readers never see a partial file.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
32
|
+
const log = require('../lib/runtime/log');
|
|
33
|
+
|
|
34
|
+
const STORY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
35
|
+
const VALID_KINDS = ['state', 'decision-log'];
|
|
36
|
+
const VALID_ACTIONS = ['write', 'read', 'append', 'init', 'batch', 'flush'];
|
|
37
|
+
const SCHEMA_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
// PR 6: critical-state keys that bypass buffering. Writing one of these
|
|
40
|
+
// via `batch` flushes the pending buffer first and then the write itself
|
|
41
|
+
// goes straight to the shard. Rationale: current_story / current_bmad_step
|
|
42
|
+
// / in_worktree / patch_commits are required for crash-resume correctness,
|
|
43
|
+
// so they cannot sit in an unflushed buffer when the process is killed.
|
|
44
|
+
const CRITICAL_KEYS = new Set([
|
|
45
|
+
'current_story',
|
|
46
|
+
'current_bmad_step',
|
|
47
|
+
'in_worktree',
|
|
48
|
+
'patch_commits',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const KIND_DIR = {
|
|
52
|
+
state: '.autopilot-state',
|
|
53
|
+
'decision-log': '.decision-log',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const PENDING_DIR = '.pending';
|
|
57
|
+
|
|
58
|
+
function help() {
|
|
59
|
+
log.out(
|
|
60
|
+
[
|
|
61
|
+
'Usage:',
|
|
62
|
+
' state-shard.js write --story <key> [--kind state|decision-log]',
|
|
63
|
+
' (--field <dotted.path>=<value> | --json <json>)',
|
|
64
|
+
' state-shard.js read --story <key> [--kind state|decision-log]',
|
|
65
|
+
' [--format yaml|json]',
|
|
66
|
+
' state-shard.js append --story <key> [--kind state|decision-log]',
|
|
67
|
+
' --path <list-path> --entry <json>',
|
|
68
|
+
' state-shard.js init --story <key> [--kind state|decision-log]',
|
|
69
|
+
'',
|
|
70
|
+
'Single-writer per story-key. No locks. Atomic via rename().',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateStory(s) {
|
|
76
|
+
if (!s || !STORY_RE.test(s)) {
|
|
77
|
+
return { ok: false, error: `invalid --story '${s}': must match ${STORY_RE}` };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, value: s };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateKind(k) {
|
|
83
|
+
const kind = k || 'state';
|
|
84
|
+
if (!VALID_KINDS.includes(kind)) {
|
|
85
|
+
return { ok: false, error: `invalid --kind '${k}': must be ${VALID_KINDS.join('|')}` };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, value: kind };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shardDir(projectRoot, kind) {
|
|
91
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', KIND_DIR[kind]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shardPath(projectRoot, story, kind) {
|
|
95
|
+
const dir = shardDir(projectRoot, kind);
|
|
96
|
+
const full = path.join(dir, `${story}.yaml`);
|
|
97
|
+
// Defense-in-depth: refuse any resolved path that escapes the shard dir.
|
|
98
|
+
const expectedPrefix = path.resolve(dir) + path.sep;
|
|
99
|
+
const resolved = path.resolve(full);
|
|
100
|
+
if (!resolved.startsWith(expectedPrefix)) {
|
|
101
|
+
throw new Error(`shard path escapes expected directory: ${resolved}`);
|
|
102
|
+
}
|
|
103
|
+
return full;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function nowStamp() {
|
|
107
|
+
return {
|
|
108
|
+
wall: new Date().toISOString(),
|
|
109
|
+
monotonic: process.hrtime.bigint().toString(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --------------------------------------------------------------------------
|
|
114
|
+
// Flat-YAML writer: each leaf is a single line "dotted.key: value".
|
|
115
|
+
// Arrays / nested arrays use JSON flow-form on the value side.
|
|
116
|
+
// --------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function yamlScalar(v) {
|
|
119
|
+
if (v === null || v === undefined) return 'null';
|
|
120
|
+
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
|
121
|
+
if (typeof v === 'bigint') return String(v);
|
|
122
|
+
const s = String(v);
|
|
123
|
+
const needsQuote =
|
|
124
|
+
s === '' ||
|
|
125
|
+
/^(true|false|null|~|yes|no|on|off)$/i.test(s) ||
|
|
126
|
+
/[:#\n\r]/.test(s) ||
|
|
127
|
+
/^[\s-]/.test(s) ||
|
|
128
|
+
/^-?\d/.test(s);
|
|
129
|
+
return needsQuote ? JSON.stringify(s) : s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function yamlDump(obj) {
|
|
133
|
+
const lines = [];
|
|
134
|
+
const emit = (value, prefix) => {
|
|
135
|
+
if (value === null || value === undefined) {
|
|
136
|
+
lines.push(`${prefix}: null`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(value)) {
|
|
140
|
+
// JSON flow-form — compact and exact round-trip.
|
|
141
|
+
lines.push(`${prefix}: ${JSON.stringify(value)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === 'object') {
|
|
145
|
+
const keys = Object.keys(value);
|
|
146
|
+
if (keys.length === 0) {
|
|
147
|
+
lines.push(`${prefix}: {}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const k of keys) emit(value[k], prefix === '' ? k : `${prefix}.${k}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
lines.push(`${prefix}: ${yamlScalar(value)}`);
|
|
154
|
+
};
|
|
155
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
156
|
+
for (const k of Object.keys(obj)) emit(obj[k], k);
|
|
157
|
+
}
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function yamlLoad(text) {
|
|
162
|
+
const root = {};
|
|
163
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
164
|
+
// Strip trailing comments only when preceded by whitespace so "#" inside
|
|
165
|
+
// a JSON-quoted value isn't eaten.
|
|
166
|
+
const line = stripTrailingComment(rawLine);
|
|
167
|
+
const trimmed = line.trim();
|
|
168
|
+
if (!trimmed) continue;
|
|
169
|
+
const colon = firstTopLevelColon(trimmed);
|
|
170
|
+
if (colon === -1) continue;
|
|
171
|
+
const key = trimmed.slice(0, colon).trim();
|
|
172
|
+
const raw = trimmed.slice(colon + 1).trim();
|
|
173
|
+
const value = parseValue(raw);
|
|
174
|
+
setByDottedPath(root, key, value);
|
|
175
|
+
}
|
|
176
|
+
return root;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stripTrailingComment(line) {
|
|
180
|
+
let inQuote = null;
|
|
181
|
+
for (let i = 0; i < line.length; i++) {
|
|
182
|
+
const c = line[i];
|
|
183
|
+
if (inQuote) {
|
|
184
|
+
if (c === '\\') {
|
|
185
|
+
i++;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (c === inQuote) inQuote = null;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (c === '"' || c === "'") {
|
|
192
|
+
inQuote = c;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
196
|
+
return line.slice(0, i);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return line;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function firstTopLevelColon(s) {
|
|
203
|
+
let inQuote = null;
|
|
204
|
+
for (let i = 0; i < s.length; i++) {
|
|
205
|
+
const c = s[i];
|
|
206
|
+
if (inQuote) {
|
|
207
|
+
if (c === '\\') {
|
|
208
|
+
i++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (c === inQuote) inQuote = null;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (c === '"' || c === "'") {
|
|
215
|
+
inQuote = c;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (c === ':') return i;
|
|
219
|
+
}
|
|
220
|
+
return -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseValue(raw) {
|
|
224
|
+
if (raw === '' || raw === 'null' || raw === '~') return null;
|
|
225
|
+
if (raw === 'true') return true;
|
|
226
|
+
if (raw === 'false') return false;
|
|
227
|
+
if (raw === '[]') return [];
|
|
228
|
+
if (raw === '{}') return {};
|
|
229
|
+
if (raw.startsWith('[') || raw.startsWith('{')) {
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(raw);
|
|
232
|
+
} catch {
|
|
233
|
+
return raw;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
237
|
+
try {
|
|
238
|
+
return raw.startsWith('"') ? JSON.parse(raw) : raw.slice(1, -1);
|
|
239
|
+
} catch {
|
|
240
|
+
return raw.slice(1, -1);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
244
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
245
|
+
return raw;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function setByDottedPath(obj, key, value) {
|
|
249
|
+
const parts = key.split('.');
|
|
250
|
+
let cur = obj;
|
|
251
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
252
|
+
const p = parts[i];
|
|
253
|
+
if (!cur[p] || typeof cur[p] !== 'object' || Array.isArray(cur[p])) {
|
|
254
|
+
cur[p] = {};
|
|
255
|
+
}
|
|
256
|
+
cur = cur[p];
|
|
257
|
+
}
|
|
258
|
+
cur[parts[parts.length - 1]] = value;
|
|
259
|
+
return obj;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getByDottedPath(obj, key) {
|
|
263
|
+
const parts = key.split('.');
|
|
264
|
+
let cur = obj;
|
|
265
|
+
for (const p of parts) {
|
|
266
|
+
if (!cur || typeof cur !== 'object') return undefined;
|
|
267
|
+
cur = cur[p];
|
|
268
|
+
}
|
|
269
|
+
return cur;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------------------
|
|
273
|
+
// Shard I/O
|
|
274
|
+
// --------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function readShard(projectRoot, story, kind) {
|
|
277
|
+
const file = shardPath(projectRoot, story, kind);
|
|
278
|
+
if (!fs.existsSync(file)) return null;
|
|
279
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
280
|
+
try {
|
|
281
|
+
return yamlLoad(raw);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
throw new Error(`failed to parse shard ${file}: ${e.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function writeShardAtomic(projectRoot, story, kind, obj) {
|
|
288
|
+
const file = shardPath(projectRoot, story, kind);
|
|
289
|
+
const dir = path.dirname(file);
|
|
290
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
291
|
+
const normalized = {
|
|
292
|
+
story,
|
|
293
|
+
schema_version: SCHEMA_VERSION,
|
|
294
|
+
updated_at: nowStamp(),
|
|
295
|
+
...stripReservedKeys(obj),
|
|
296
|
+
};
|
|
297
|
+
const body = `${yamlDump(normalized)}\n`;
|
|
298
|
+
// Unique tmp name per (pid, monotonic ns) so concurrent writers to
|
|
299
|
+
// different stories never collide on the tmp file. Same-story writers
|
|
300
|
+
// are single-writer by contract.
|
|
301
|
+
const tmp = `${file}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
302
|
+
fs.writeFileSync(tmp, body);
|
|
303
|
+
fs.renameSync(tmp, file);
|
|
304
|
+
return file;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function stripReservedKeys(obj) {
|
|
308
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return {};
|
|
309
|
+
const { story: _s, schema_version: _sv, updated_at: _u, ...rest } = obj;
|
|
310
|
+
return rest;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function appendToListAtPath(obj, dottedPath, entry) {
|
|
314
|
+
const parts = dottedPath.split('.').filter(Boolean);
|
|
315
|
+
if (parts.length === 0) throw new Error('--path required for append');
|
|
316
|
+
let cur = obj;
|
|
317
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
318
|
+
const p = parts[i];
|
|
319
|
+
if (!cur[p] || typeof cur[p] !== 'object' || Array.isArray(cur[p])) {
|
|
320
|
+
cur[p] = {};
|
|
321
|
+
}
|
|
322
|
+
cur = cur[p];
|
|
323
|
+
}
|
|
324
|
+
const last = parts[parts.length - 1];
|
|
325
|
+
if (!Array.isArray(cur[last])) cur[last] = [];
|
|
326
|
+
cur[last].push(entry);
|
|
327
|
+
return obj;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseFieldValue(raw) {
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(raw);
|
|
333
|
+
} catch {
|
|
334
|
+
return parseValue(raw);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function listShardStories(projectRoot, kind) {
|
|
339
|
+
const dir = shardDir(projectRoot, kind);
|
|
340
|
+
if (!fs.existsSync(dir)) return [];
|
|
341
|
+
return fs
|
|
342
|
+
.readdirSync(dir)
|
|
343
|
+
.filter((f) => f.endsWith('.yaml') && !f.startsWith('.tmp'))
|
|
344
|
+
.map((f) => f.slice(0, -'.yaml'.length));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --------------------------------------------------------------------------
|
|
348
|
+
// Pending buffer (PR 6 — coalesce state writes)
|
|
349
|
+
// --------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function pendingDir(projectRoot, kind) {
|
|
352
|
+
return path.join(projectRoot, '_bmad-output', 'implementation-artifacts', PENDING_DIR, KIND_DIR[kind]);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function pendingPath(projectRoot, story, kind) {
|
|
356
|
+
const dir = pendingDir(projectRoot, kind);
|
|
357
|
+
const full = path.join(dir, `${story}.yaml`);
|
|
358
|
+
const expectedPrefix = path.resolve(dir) + path.sep;
|
|
359
|
+
const resolved = path.resolve(full);
|
|
360
|
+
if (!resolved.startsWith(expectedPrefix)) {
|
|
361
|
+
throw new Error(`pending path escapes expected directory: ${resolved}`);
|
|
362
|
+
}
|
|
363
|
+
return full;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function readPending(projectRoot, story, kind) {
|
|
367
|
+
const file = pendingPath(projectRoot, story, kind);
|
|
368
|
+
if (!fs.existsSync(file)) return {};
|
|
369
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
370
|
+
try {
|
|
371
|
+
return yamlLoad(raw) || {};
|
|
372
|
+
} catch {
|
|
373
|
+
// Corrupt pending — drop it; the caller's write will overwrite atomically.
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function writePendingAtomic(projectRoot, story, kind, obj) {
|
|
379
|
+
const file = pendingPath(projectRoot, story, kind);
|
|
380
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
381
|
+
const body = `${yamlDump(obj)}\n`;
|
|
382
|
+
const tmp = `${file}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`;
|
|
383
|
+
fs.writeFileSync(tmp, body);
|
|
384
|
+
fs.renameSync(tmp, file);
|
|
385
|
+
return file;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function clearPending(projectRoot, story, kind) {
|
|
389
|
+
const file = pendingPath(projectRoot, story, kind);
|
|
390
|
+
try {
|
|
391
|
+
fs.unlinkSync(file);
|
|
392
|
+
} catch {
|
|
393
|
+
// Not present — nothing to clear.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function batchWrite(projectRoot, story, kind, partial) {
|
|
398
|
+
const pending = readPending(projectRoot, story, kind);
|
|
399
|
+
const merged = deepAssign(pending, partial);
|
|
400
|
+
writePendingAtomic(projectRoot, story, kind, merged);
|
|
401
|
+
return merged;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function flushPending(projectRoot, story, kind) {
|
|
405
|
+
const pending = readPending(projectRoot, story, kind);
|
|
406
|
+
if (!pending || Object.keys(pending).length === 0) {
|
|
407
|
+
clearPending(projectRoot, story, kind);
|
|
408
|
+
return { flushed: false, fields: 0 };
|
|
409
|
+
}
|
|
410
|
+
const existing = readShard(projectRoot, story, kind) || {};
|
|
411
|
+
const merged = deepAssign(stripReservedKeys(existing), pending);
|
|
412
|
+
writeShardAtomic(projectRoot, story, kind, merged);
|
|
413
|
+
clearPending(projectRoot, story, kind);
|
|
414
|
+
return { flushed: true, fields: Object.keys(pending).length };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function containsCriticalKey(partial) {
|
|
418
|
+
if (!partial || typeof partial !== 'object' || Array.isArray(partial)) return false;
|
|
419
|
+
for (const k of Object.keys(partial)) {
|
|
420
|
+
if (CRITICAL_KEYS.has(k)) return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --------------------------------------------------------------------------
|
|
426
|
+
// CLI
|
|
427
|
+
// --------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
function main() {
|
|
430
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
431
|
+
if (opts.help || positional.length === 0) {
|
|
432
|
+
help();
|
|
433
|
+
process.exit(opts.help ? 0 : 1);
|
|
434
|
+
}
|
|
435
|
+
const action = positional[0];
|
|
436
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
437
|
+
log.error(`unknown action '${action}'. Valid: ${VALID_ACTIONS.join(', ')}`);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
const story = validateStory(opts.story);
|
|
441
|
+
if (!story.ok) {
|
|
442
|
+
log.error(story.error);
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const kind = validateKind(opts.kind);
|
|
446
|
+
if (!kind.ok) {
|
|
447
|
+
log.error(kind.error);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
451
|
+
const format = opts.format || 'yaml';
|
|
452
|
+
|
|
453
|
+
const existing = (() => {
|
|
454
|
+
try {
|
|
455
|
+
return readShard(projectRoot, story.value, kind.value) || {};
|
|
456
|
+
} catch (e) {
|
|
457
|
+
log.error(e.message);
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
})();
|
|
461
|
+
|
|
462
|
+
if (action === 'read') {
|
|
463
|
+
const shard = readShard(projectRoot, story.value, kind.value);
|
|
464
|
+
if (!shard) process.exit(2);
|
|
465
|
+
if (format === 'json') process.stdout.write(`${JSON.stringify(shard)}\n`);
|
|
466
|
+
else process.stdout.write(`${yamlDump(shard)}\n`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (action === 'init') {
|
|
470
|
+
writeShardAtomic(projectRoot, story.value, kind.value, {});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (action === 'write' || action === 'batch') {
|
|
474
|
+
const partial = {};
|
|
475
|
+
if (opts.json !== undefined) {
|
|
476
|
+
let parsed;
|
|
477
|
+
try {
|
|
478
|
+
parsed = JSON.parse(opts.json);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
log.error(`--json is not valid JSON: ${e.message}`);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
484
|
+
log.error('--json must be a JSON object');
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
Object.assign(partial, parsed);
|
|
488
|
+
}
|
|
489
|
+
if (opts.field !== undefined) {
|
|
490
|
+
const eq = opts.field.indexOf('=');
|
|
491
|
+
if (eq === -1) {
|
|
492
|
+
log.error(`--field must be <path>=<value>, got '${opts.field}'`);
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
const p = opts.field.slice(0, eq);
|
|
496
|
+
const v = opts.field.slice(eq + 1);
|
|
497
|
+
setByDottedPath(partial, p, parseFieldValue(v));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (action === 'batch') {
|
|
501
|
+
// Critical keys bypass the buffer — flush pending, re-read the now-
|
|
502
|
+
// flushed shard, then write straight through so crash-recovery always
|
|
503
|
+
// sees both the prior buffered fields and the new critical fields.
|
|
504
|
+
if (containsCriticalKey(partial)) {
|
|
505
|
+
flushPending(projectRoot, story.value, kind.value);
|
|
506
|
+
const fresh = readShard(projectRoot, story.value, kind.value) || {};
|
|
507
|
+
const next = deepAssign(stripReservedKeys(fresh), partial);
|
|
508
|
+
writeShardAtomic(projectRoot, story.value, kind.value, next);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
batchWrite(projectRoot, story.value, kind.value, partial);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// action === 'write' — direct write to shard (also flushes any pending
|
|
515
|
+
// to keep flush-before-write invariant).
|
|
516
|
+
flushPending(projectRoot, story.value, kind.value);
|
|
517
|
+
const fresh = readShard(projectRoot, story.value, kind.value) || existing;
|
|
518
|
+
const next = deepAssign({ ...fresh }, partial);
|
|
519
|
+
writeShardAtomic(projectRoot, story.value, kind.value, next);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (action === 'flush') {
|
|
523
|
+
const res = flushPending(projectRoot, story.value, kind.value);
|
|
524
|
+
process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (action === 'append') {
|
|
528
|
+
if (!opts.path) {
|
|
529
|
+
log.error('--path is required for append');
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
if (!opts.entry) {
|
|
533
|
+
log.error('--entry is required for append');
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
let entry;
|
|
537
|
+
try {
|
|
538
|
+
entry = JSON.parse(opts.entry);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
log.error(`--entry is not valid JSON: ${e.message}`);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
const next = appendToListAtPath({ ...existing }, opts.path, entry);
|
|
544
|
+
writeShardAtomic(projectRoot, story.value, kind.value, next);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function deepAssign(target, source) {
|
|
549
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return source;
|
|
550
|
+
const out = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
|
|
551
|
+
for (const k of Object.keys(source)) {
|
|
552
|
+
const sv = source[k];
|
|
553
|
+
const tv = out[k];
|
|
554
|
+
if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
|
|
555
|
+
out[k] = deepAssign(tv, sv);
|
|
556
|
+
} else {
|
|
557
|
+
out[k] = sv;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
STORY_RE,
|
|
565
|
+
VALID_KINDS,
|
|
566
|
+
VALID_ACTIONS,
|
|
567
|
+
SCHEMA_VERSION,
|
|
568
|
+
KIND_DIR,
|
|
569
|
+
PENDING_DIR,
|
|
570
|
+
CRITICAL_KEYS,
|
|
571
|
+
validateStory,
|
|
572
|
+
validateKind,
|
|
573
|
+
shardDir,
|
|
574
|
+
shardPath,
|
|
575
|
+
pendingDir,
|
|
576
|
+
pendingPath,
|
|
577
|
+
nowStamp,
|
|
578
|
+
yamlDump,
|
|
579
|
+
yamlLoad,
|
|
580
|
+
parseValue,
|
|
581
|
+
setByDottedPath,
|
|
582
|
+
getByDottedPath,
|
|
583
|
+
readShard,
|
|
584
|
+
writeShardAtomic,
|
|
585
|
+
appendToListAtPath,
|
|
586
|
+
parseFieldValue,
|
|
587
|
+
listShardStories,
|
|
588
|
+
stripReservedKeys,
|
|
589
|
+
deepAssign,
|
|
590
|
+
stripTrailingComment,
|
|
591
|
+
firstTopLevelColon,
|
|
592
|
+
readPending,
|
|
593
|
+
writePendingAtomic,
|
|
594
|
+
clearPending,
|
|
595
|
+
batchWrite,
|
|
596
|
+
flushPending,
|
|
597
|
+
containsCriticalKey,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
if (require.main === module) {
|
|
601
|
+
main();
|
|
602
|
+
}
|