empirical-sdd 0.20.0

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/dist/index.js ADDED
@@ -0,0 +1,3224 @@
1
+ var __create = Object.create;
2
+ var __getProtoOf = Object.getPrototypeOf;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toESMCache_node;
10
+ var __toESMCache_esm;
11
+ var __toESM = (mod, isNodeMode, target) => {
12
+ var canCache = mod != null && typeof mod === "object";
13
+ if (canCache) {
14
+ var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
15
+ var cached = cache.get(mod);
16
+ if (cached)
17
+ return cached;
18
+ }
19
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
20
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
21
+ for (let key of __getOwnPropNames(mod))
22
+ if (!__hasOwnProp.call(to, key))
23
+ __defProp(to, key, {
24
+ get: __accessProp.bind(mod, key),
25
+ enumerable: true
26
+ });
27
+ if (canCache)
28
+ cache.set(mod, to);
29
+ return to;
30
+ };
31
+ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
32
+ var __returnValue = (v) => v;
33
+ function __exportSetter(name, newValue) {
34
+ this[name] = __returnValue.bind(null, newValue);
35
+ }
36
+ var __export = (target, all) => {
37
+ for (var name in all)
38
+ __defProp(target, name, {
39
+ get: all[name],
40
+ enumerable: true,
41
+ configurable: true,
42
+ set: __exportSetter.bind(all, name)
43
+ });
44
+ };
45
+
46
+ // src/core.ts
47
+ import { mkdir as mkdir2, readFile as readFile5 } from "node:fs/promises";
48
+ import { createHash as createHash3 } from "node:crypto";
49
+ import { basename as basename2, join as join5, resolve as resolve4 } from "node:path";
50
+
51
+ // src/errors.ts
52
+ class EmpiricalError extends Error {
53
+ code;
54
+ details;
55
+ constructor(code, message, details) {
56
+ super(message);
57
+ this.name = "EmpiricalError";
58
+ this.code = code;
59
+ this.details = details;
60
+ }
61
+ }
62
+ function asErrorMessage(error) {
63
+ return error instanceof Error ? error.message : String(error);
64
+ }
65
+
66
+ // src/integrations.ts
67
+ import { lstat as lstat2, readFile as readFile2 } from "node:fs/promises";
68
+ import { homedir } from "node:os";
69
+ import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
70
+
71
+ // src/storage.ts
72
+ import { chmod, lstat, open, readFile, readdir, rename, rm, rmdir, stat, writeFile, mkdir } from "node:fs/promises";
73
+ import { randomUUID } from "node:crypto";
74
+ import { dirname, join, resolve } from "node:path";
75
+
76
+ // src/types.ts
77
+ var SCHEMA_VERSION = 4;
78
+ var PRODUCT_VERSION = "0.20.0";
79
+ var POLICY_SCHEMA_VERSION = 1;
80
+
81
+ // src/storage.ts
82
+ var EMPIRICAL_DIR = ".empirical";
83
+ var LOCK_STALE_AFTER_MS = 30000;
84
+ var LOCK_WAIT_MS = 5000;
85
+
86
+ class ProjectStore {
87
+ root;
88
+ feature;
89
+ constructor(root, feature = null) {
90
+ this.root = resolve(root);
91
+ if (feature !== null)
92
+ assertFeatureId(feature);
93
+ this.feature = feature;
94
+ }
95
+ get directory() {
96
+ return join(this.root, EMPIRICAL_DIR);
97
+ }
98
+ get configPath() {
99
+ return join(this.directory, "config.json");
100
+ }
101
+ get policyPath() {
102
+ return join(this.directory, "policy.json");
103
+ }
104
+ get stateDirectory() {
105
+ return this.specDirectory(this.requireFeature());
106
+ }
107
+ get statePath() {
108
+ return join(this.stateDirectory, "state.json");
109
+ }
110
+ get eventsDirectory() {
111
+ return join(this.stateDirectory, "events");
112
+ }
113
+ get capabilitiesDirectory() {
114
+ return join(this.directory, "capabilities");
115
+ }
116
+ forFeature(feature) {
117
+ return new ProjectStore(this.root, feature);
118
+ }
119
+ capabilityDirectory(capability) {
120
+ assertCapabilityId(capability);
121
+ return join(this.capabilitiesDirectory, capability);
122
+ }
123
+ capabilitySpecPath(capability) {
124
+ return join(this.capabilityDirectory(capability), "spec.md");
125
+ }
126
+ specDirectory(feature) {
127
+ assertFeatureId(feature);
128
+ return join(this.directory, "specs", feature);
129
+ }
130
+ specPath(feature) {
131
+ return join(this.specDirectory(feature), "spec.md");
132
+ }
133
+ evidencePath(feature) {
134
+ return join(this.specDirectory(feature), "evidence.json");
135
+ }
136
+ deltaDirectory(feature) {
137
+ return join(this.specDirectory(feature), "deltas");
138
+ }
139
+ async exists() {
140
+ return isFile(this.configPath);
141
+ }
142
+ async ensureLayout() {
143
+ await this.assertProjectPathSafe();
144
+ if (this.feature) {
145
+ await this.assertFeaturePathSafe(this.feature, [this.statePath, this.eventsDirectory]);
146
+ }
147
+ await mkdir(join(this.directory, "specs"), { recursive: true });
148
+ await mkdir(this.capabilitiesDirectory, { recursive: true });
149
+ if (this.feature)
150
+ await mkdir(this.eventsDirectory, { recursive: true });
151
+ }
152
+ async loadPolicy() {
153
+ if (!await isFile(this.policyPath))
154
+ return defaultPolicy();
155
+ return normalizePolicy(await readJson(this.policyPath, "INVALID_POLICY"));
156
+ }
157
+ async writePolicy(policy) {
158
+ await this.withResourceLock("policy", async () => {
159
+ await writeJsonAtomic(this.policyPath, normalizePolicy(policy));
160
+ });
161
+ }
162
+ async loadConfig() {
163
+ const config = await readJson(this.configPath, "PROJECT_NOT_INITIALIZED");
164
+ return normalizeConfig(config);
165
+ }
166
+ async loadState(recover = true) {
167
+ if (!this.feature) {
168
+ const active = await this.activeFeature(recover);
169
+ if (active)
170
+ return this.forFeature(active).loadState(recover);
171
+ const config = await this.loadConfig();
172
+ return idleState(config.profile);
173
+ }
174
+ await this.assertFeaturePathSafe(this.feature, [this.statePath, this.eventsDirectory]);
175
+ const projected = normalizeState(await readJson(this.statePath, "PROJECT_NOT_INITIALIZED"));
176
+ const event = await this.latestEvent();
177
+ if (event && event.revision > projected.revision) {
178
+ if (recover)
179
+ await writeJsonAtomic(this.statePath, event.state);
180
+ return event.state;
181
+ }
182
+ return projected;
183
+ }
184
+ async writeConfig(config) {
185
+ await this.ensureLayout();
186
+ await writeJsonAtomic(this.configPath, normalizeConfig(config));
187
+ await this.ensureProjectMetadata();
188
+ }
189
+ async writeInitial(config) {
190
+ await this.writeConfig(config);
191
+ }
192
+ async writeInitialFeature(state, actor = "empirical-start", summary) {
193
+ if (!this.feature)
194
+ throw new EmpiricalError("FEATURE_REQUIRED", "Feature-scoped state needs a feature store");
195
+ if (await isFile(this.statePath)) {
196
+ throw new EmpiricalError("FEATURE_EXISTS", `Feature ${this.feature} already has workflow state`);
197
+ }
198
+ await this.ensureLayout();
199
+ await this.commitInitialState(state, actor, summary ?? `Started ${this.feature}`);
200
+ }
201
+ async configure(update) {
202
+ return this.withResourceLock("policy", async () => {
203
+ const current = await this.loadConfig();
204
+ const next = normalizeConfig({
205
+ ...current,
206
+ ...update,
207
+ isolation: { ...current.isolation, ...update.isolation },
208
+ decisions: { ...current.decisions, ...update.decisions }
209
+ });
210
+ await writeJsonAtomic(this.configPath, next);
211
+ return next;
212
+ });
213
+ }
214
+ async activeFeature(recover = true) {
215
+ const active = [];
216
+ for (const feature of await this.listFeatureIds()) {
217
+ const scoped = this.forFeature(feature);
218
+ if (!await isFile(scoped.statePath))
219
+ continue;
220
+ const state = await scoped.loadState(recover);
221
+ if (state.phase !== "done" && (state.status === "waiting" || state.status === "awaiting_human" || state.status === "blocked")) {
222
+ active.push(feature);
223
+ }
224
+ }
225
+ if (active.length > 1) {
226
+ throw new EmpiricalError("MULTIPLE_ACTIVE_FEATURES", `This checkout has multiple active features: ${active.join(", ")}`, { features: active });
227
+ }
228
+ return active[0] ?? null;
229
+ }
230
+ async listFeatureIds() {
231
+ const directory = join(this.directory, "specs");
232
+ if (await isSymbolicLink(directory)) {
233
+ throw new EmpiricalError("UNSAFE_SPEC_PATH", `Feature storage cannot use symbolic links: ${directory}`);
234
+ }
235
+ let entries;
236
+ try {
237
+ entries = await readdir(directory, { withFileTypes: true });
238
+ } catch (error) {
239
+ if (error.code === "ENOENT")
240
+ return [];
241
+ throw error;
242
+ }
243
+ return entries.filter((entry) => entry.isDirectory() && isFeatureId(entry.name)).map((entry) => entry.name).sort();
244
+ }
245
+ async transition(expectedRevision, actor, summary, mutate) {
246
+ const committed = await this.transaction(async (current) => {
247
+ if (current.revision !== expectedRevision) {
248
+ throw new EmpiricalError("STALE_REVISION", `Expected revision ${expectedRevision}, but the project is at ${current.revision}`, { expectedRevision, actualRevision: current.revision });
249
+ }
250
+ return {
251
+ actor,
252
+ summary,
253
+ state: mutate(structuredClone(current)),
254
+ value: undefined
255
+ };
256
+ });
257
+ return committed.state;
258
+ }
259
+ async transaction(prepare) {
260
+ this.requireFeature();
261
+ await this.ensureProjectMetadata();
262
+ return this.withLock(async () => {
263
+ const current = await this.loadState();
264
+ const prepared = await prepare(structuredClone(current));
265
+ const now = new Date().toISOString();
266
+ const next = prepared.state;
267
+ next.schemaVersion = SCHEMA_VERSION;
268
+ next.revision = current.revision + 1;
269
+ next.updatedAt = now;
270
+ const event = {
271
+ schemaVersion: SCHEMA_VERSION,
272
+ revision: next.revision,
273
+ previousRevision: current.revision,
274
+ actor: prepared.actor,
275
+ summary: prepared.summary,
276
+ createdAt: now,
277
+ state: next
278
+ };
279
+ await this.ensureCurrentConfigSchema();
280
+ await prepared.validate?.();
281
+ let rollback;
282
+ let eventWritten = false;
283
+ try {
284
+ rollback = await prepared.effect?.();
285
+ await writeJsonAtomic(this.eventPath(event.revision), event);
286
+ eventWritten = true;
287
+ await writeJsonAtomic(this.statePath, next);
288
+ return { state: next, value: prepared.value };
289
+ } catch (error) {
290
+ if (eventWritten) {
291
+ try {
292
+ await rm(this.eventPath(event.revision), { force: true });
293
+ } catch (cleanupError) {
294
+ throw new EmpiricalError("TRANSACTION_RECOVERY_REQUIRED", "The transition event committed but its state projection failed; the next read will recover it", { error: errorMessage(error), cleanupError: errorMessage(cleanupError) });
295
+ }
296
+ }
297
+ if (rollback) {
298
+ try {
299
+ await rollback();
300
+ } catch (rollbackError) {
301
+ throw new EmpiricalError("TRANSACTION_ROLLBACK_FAILED", "The state transition failed and its external effect could not be fully rolled back", { error: errorMessage(error), rollbackError: errorMessage(rollbackError) });
302
+ }
303
+ }
304
+ throw error;
305
+ }
306
+ });
307
+ }
308
+ async migrateSchema() {
309
+ const project = new ProjectStore(this.root);
310
+ await project.ensureProjectMetadata();
311
+ return project.withResourceLock("specs", async () => {
312
+ const rawConfig = await readJson(project.configPath, "PROJECT_NOT_INITIALIZED");
313
+ const configVersion = schemaVersion(rawConfig);
314
+ const config = normalizeConfig(rawConfig);
315
+ let changed = JSON.stringify(rawConfig) !== JSON.stringify(config);
316
+ if (changed)
317
+ await writeJsonAtomic(project.configPath, config);
318
+ const legacyStatePath = join(project.directory, "state.json");
319
+ const legacyEvents = join(project.directory, "events");
320
+ let stateVersion = null;
321
+ let migratedFeature = null;
322
+ if (await isSymbolicLink(legacyStatePath) || await isSymbolicLink(legacyEvents)) {
323
+ throw new EmpiricalError("UNSAFE_MIGRATION_PATH", "Workflow migration cannot follow symbolic links");
324
+ }
325
+ const eventNames = await legacyEventNames(legacyEvents);
326
+ const hasLegacyState = await isFile(legacyStatePath);
327
+ if (!hasLegacyState && eventNames.length) {
328
+ throw new EmpiricalError("MIGRATION_CONFLICT", "Cannot migrate root transition history because its state projection is missing");
329
+ }
330
+ if (hasLegacyState) {
331
+ const rawState = await readJson(legacyStatePath, "PROJECT_NOT_INITIALIZED");
332
+ stateVersion = schemaVersion(rawState);
333
+ const state = normalizeState(rawState);
334
+ const eventsByFeature = new Map;
335
+ const desiredState = new Map;
336
+ for (const name of eventNames) {
337
+ const sourcePath = join(legacyEvents, name);
338
+ if (await isSymbolicLink(sourcePath)) {
339
+ throw new EmpiricalError("UNSAFE_MIGRATION_PATH", `Workflow migration cannot follow ${sourcePath}`);
340
+ }
341
+ const event = normalizeEvent(await readJson(sourcePath, "INVALID_EVENT"));
342
+ const feature = event.state.activeFeature;
343
+ if (!feature) {
344
+ throw new EmpiricalError("MIGRATION_CONFLICT", `Cannot assign root transition event ${name} to a feature`);
345
+ }
346
+ assertFeatureId(feature);
347
+ const records = eventsByFeature.get(feature) ?? [];
348
+ records.push({ name, event });
349
+ eventsByFeature.set(feature, records);
350
+ const current = desiredState.get(feature);
351
+ if (!current || event.state.revision > current.revision)
352
+ desiredState.set(feature, event.state);
353
+ }
354
+ if (state.activeFeature) {
355
+ assertFeatureId(state.activeFeature);
356
+ desiredState.set(state.activeFeature, state);
357
+ migratedFeature = state.activeFeature;
358
+ }
359
+ const features = [...new Set([...eventsByFeature.keys(), ...desiredState.keys()])].sort();
360
+ for (const feature of features) {
361
+ const scoped = project.forFeature(feature);
362
+ const specPath = project.specPath(feature);
363
+ await scoped.assertFeaturePathSafe(feature, [specPath, scoped.statePath, scoped.eventsDirectory]);
364
+ if (!await isFile(specPath)) {
365
+ throw new EmpiricalError("MIGRATION_CONFLICT", `Cannot migrate ${feature}: its specification is missing`);
366
+ }
367
+ for (const { name, event } of eventsByFeature.get(feature) ?? []) {
368
+ const targetPath = join(scoped.eventsDirectory, name);
369
+ if (await isSymbolicLink(targetPath)) {
370
+ throw new EmpiricalError("UNSAFE_MIGRATION_PATH", `Workflow migration cannot follow ${targetPath}`);
371
+ }
372
+ if (await isFile(targetPath)) {
373
+ const existing = normalizeEvent(await readJson(targetPath, "INVALID_EVENT"));
374
+ if (JSON.stringify(existing) !== JSON.stringify(event)) {
375
+ throw new EmpiricalError("MIGRATION_CONFLICT", `Transition event ${name} conflicts with ${feature} history`);
376
+ }
377
+ }
378
+ }
379
+ const projected = desiredState.get(feature);
380
+ if (projected && await isFile(scoped.statePath)) {
381
+ const existing = normalizeState(await readJson(scoped.statePath, "PROJECT_NOT_INITIALIZED"));
382
+ if (existing.revision === projected.revision && JSON.stringify(existing) !== JSON.stringify(projected)) {
383
+ throw new EmpiricalError("MIGRATION_CONFLICT", `Feature ${feature} has conflicting workflow state`);
384
+ }
385
+ }
386
+ }
387
+ for (const feature of features) {
388
+ const scoped = project.forFeature(feature);
389
+ await scoped.ensureLayout();
390
+ for (const { name, event } of eventsByFeature.get(feature) ?? []) {
391
+ const targetPath = join(scoped.eventsDirectory, name);
392
+ if (!await isFile(targetPath))
393
+ await writeJsonAtomic(targetPath, event);
394
+ }
395
+ const projected = desiredState.get(feature);
396
+ if (!projected)
397
+ continue;
398
+ const existing = await isFile(scoped.statePath) ? normalizeState(await readJson(scoped.statePath, "PROJECT_NOT_INITIALIZED")) : null;
399
+ if (!existing || existing.revision < projected.revision) {
400
+ await writeJsonAtomic(scoped.statePath, projected);
401
+ }
402
+ }
403
+ await rm(legacyStatePath, { force: true });
404
+ await rm(join(project.directory, "state.lock"), { force: true });
405
+ await rm(legacyEvents, { recursive: true, force: true });
406
+ changed = true;
407
+ }
408
+ for (const feature of await project.listFeatureIds()) {
409
+ const scoped = project.forFeature(feature);
410
+ await scoped.assertFeaturePathSafe(feature, [scoped.statePath, scoped.eventsDirectory]);
411
+ if (!await isFile(scoped.statePath))
412
+ continue;
413
+ const raw = await readJson(scoped.statePath, "PROJECT_NOT_INITIALIZED");
414
+ const normalized = normalizeState(raw);
415
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
416
+ await writeJsonAtomic(scoped.statePath, normalized);
417
+ changed = true;
418
+ }
419
+ }
420
+ return {
421
+ changed,
422
+ from: { config: configVersion, state: stateVersion },
423
+ to: SCHEMA_VERSION,
424
+ migratedFeature
425
+ };
426
+ });
427
+ }
428
+ async writeSpec(feature, contents) {
429
+ const path = this.specPath(feature);
430
+ await this.assertFeaturePathSafe(feature, [path]);
431
+ await mkdir(dirname(path), { recursive: true });
432
+ await writeTextAtomic(path, contents);
433
+ }
434
+ async readSpec(feature) {
435
+ await this.assertFeaturePathSafe(feature, [this.specPath(feature)]);
436
+ try {
437
+ return await readFile(this.specPath(feature), "utf8");
438
+ } catch (error) {
439
+ throw new EmpiricalError("SPEC_NOT_FOUND", `Missing specification for ${feature}`, error);
440
+ }
441
+ }
442
+ async writeEvidence(feature, evidence) {
443
+ await this.assertFeaturePathSafe(feature, [this.evidencePath(feature)]);
444
+ await writeJsonAtomic(this.evidencePath(feature), evidence);
445
+ }
446
+ async readEvidence(feature) {
447
+ await this.assertFeaturePathSafe(feature, [this.evidencePath(feature)]);
448
+ if (!await isFile(this.evidencePath(feature)))
449
+ return [];
450
+ return readJson(this.evidencePath(feature), "INVALID_EVIDENCE");
451
+ }
452
+ async listCapabilityNames() {
453
+ await this.assertCapabilityPathSafe();
454
+ let entries;
455
+ try {
456
+ entries = await readdir(this.capabilitiesDirectory, { withFileTypes: true });
457
+ } catch (error) {
458
+ if (error.code === "ENOENT")
459
+ return [];
460
+ throw error;
461
+ }
462
+ return entries.filter((entry) => entry.isDirectory() && isCapabilityId(entry.name)).map((entry) => entry.name).sort();
463
+ }
464
+ async readCapability(capability) {
465
+ await this.assertCapabilityPathSafe(capability);
466
+ const path = this.capabilitySpecPath(capability);
467
+ return await isFile(path) ? readFile(path, "utf8") : null;
468
+ }
469
+ async writeCapability(capability, contents) {
470
+ await this.assertCapabilityPathSafe(capability);
471
+ await writeTextAtomic(this.capabilitySpecPath(capability), contents);
472
+ }
473
+ async removeCapability(capability) {
474
+ await this.assertCapabilityPathSafe(capability);
475
+ await rm(this.capabilitySpecPath(capability), { force: true });
476
+ await rmdir(this.capabilityDirectory(capability)).catch((error) => {
477
+ if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY" && error.code !== "EEXIST")
478
+ throw error;
479
+ });
480
+ }
481
+ async withResourceLock(resource, operation) {
482
+ return withFileLock(join(this.directory, `${resource}.lock`), operation);
483
+ }
484
+ async assertCurrentSchemaReadOnly() {
485
+ await this.assertProjectPathSafe();
486
+ const config = await readJson(this.configPath, "PROJECT_NOT_INITIALIZED");
487
+ if (config.schemaVersion !== SCHEMA_VERSION || await pathExists(join(this.directory, "state.json")) || await pathExists(join(this.directory, "events"))) {
488
+ throw new EmpiricalError("MIGRATION_REQUIRED", "This read-only operation requires schema 4; run empirical migrate first");
489
+ }
490
+ }
491
+ eventPath(revision) {
492
+ return join(this.eventsDirectory, `${String(revision).padStart(8, "0")}.json`);
493
+ }
494
+ async latestEvent() {
495
+ let names;
496
+ try {
497
+ names = (await readdir(this.eventsDirectory)).filter((name2) => /^[0-9]{8}\.json$/.test(name2)).sort();
498
+ } catch (error) {
499
+ if (error.code === "ENOENT")
500
+ return null;
501
+ throw new EmpiricalError("INVALID_EVENT", `Could not inspect ${this.eventsDirectory}`, error);
502
+ }
503
+ const name = names.at(-1);
504
+ if (!name)
505
+ return null;
506
+ const path = join(this.eventsDirectory, name);
507
+ if (await isSymbolicLink(path)) {
508
+ throw new EmpiricalError("UNSAFE_SPEC_PATH", `Feature storage cannot use symbolic links: ${path}`);
509
+ }
510
+ return normalizeEvent(await readJson(path, "INVALID_EVENT"));
511
+ }
512
+ async commitInitialState(state, actor, summary) {
513
+ const event = {
514
+ schemaVersion: SCHEMA_VERSION,
515
+ revision: state.revision,
516
+ previousRevision: -1,
517
+ actor,
518
+ summary,
519
+ createdAt: state.updatedAt,
520
+ state
521
+ };
522
+ await writeJsonAtomic(this.eventPath(state.revision), event);
523
+ await writeJsonAtomic(this.statePath, state);
524
+ }
525
+ async withLock(operation) {
526
+ return withFileLock(join(this.stateDirectory, "state.lock"), operation);
527
+ }
528
+ requireFeature() {
529
+ if (!this.feature) {
530
+ throw new EmpiricalError("FEATURE_REQUIRED", "This operation requires a feature-scoped store");
531
+ }
532
+ return this.feature;
533
+ }
534
+ async assertCapabilityPathSafe(capability) {
535
+ const paths = [
536
+ this.capabilitiesDirectory,
537
+ ...capability ? [this.capabilityDirectory(capability), this.capabilitySpecPath(capability)] : []
538
+ ];
539
+ for (const path of paths) {
540
+ if (await isSymbolicLink(path)) {
541
+ throw new EmpiricalError("UNSAFE_CAPABILITY_PATH", `Capability storage cannot use symbolic links: ${path}`);
542
+ }
543
+ }
544
+ }
545
+ async assertFeaturePathSafe(feature, additional = []) {
546
+ assertFeatureId(feature);
547
+ const paths = [
548
+ this.directory,
549
+ join(this.directory, "specs"),
550
+ this.specDirectory(feature),
551
+ ...additional
552
+ ];
553
+ for (const path of paths) {
554
+ if (await isSymbolicLink(path)) {
555
+ throw new EmpiricalError("UNSAFE_SPEC_PATH", `Feature storage cannot use symbolic links: ${path}`);
556
+ }
557
+ }
558
+ }
559
+ async assertProjectPathSafe() {
560
+ const paths = [
561
+ this.directory,
562
+ join(this.directory, "specs"),
563
+ this.capabilitiesDirectory,
564
+ this.configPath,
565
+ this.policyPath
566
+ ];
567
+ for (const path of paths) {
568
+ if (await isSymbolicLink(path)) {
569
+ throw new EmpiricalError("UNSAFE_PROJECT_PATH", `Empirical storage cannot use symbolic links: ${path}`);
570
+ }
571
+ }
572
+ }
573
+ async ensureProjectMetadata() {
574
+ await this.assertProjectPathSafe();
575
+ await mkdir(this.directory, { recursive: true });
576
+ if (!await isFile(this.policyPath)) {
577
+ await this.withResourceLock("policy", async () => {
578
+ if (!await isFile(this.policyPath))
579
+ await writeJsonAtomic(this.policyPath, defaultPolicy());
580
+ });
581
+ }
582
+ }
583
+ async ensureCurrentConfigSchema() {
584
+ const raw = await readJson(this.configPath, "PROJECT_NOT_INITIALIZED");
585
+ schemaVersion(raw);
586
+ const normalized = normalizeConfig(raw);
587
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
588
+ await writeJsonAtomic(this.configPath, normalized);
589
+ }
590
+ }
591
+ }
592
+ async function withFileLock(lockPath, operation) {
593
+ await mkdir(dirname(lockPath), { recursive: true });
594
+ const deadline = Date.now() + LOCK_WAIT_MS;
595
+ const token = randomUUID();
596
+ let handle;
597
+ let lastError;
598
+ while (!handle) {
599
+ try {
600
+ handle = await open(lockPath, "wx");
601
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, token })}
602
+ `, "utf8");
603
+ await handle.sync();
604
+ break;
605
+ } catch (error) {
606
+ lastError = error;
607
+ if (handle) {
608
+ const incomplete = handle;
609
+ handle = undefined;
610
+ const details = await incomplete.stat().catch(() => null);
611
+ const observed = details ? await inspectLock(lockPath).catch(() => null) : null;
612
+ await incomplete.close().catch(() => {
613
+ return;
614
+ });
615
+ if (details && observed && details.dev === observed.dev && details.ino === observed.ino) {
616
+ await removeLockIfUnchanged(lockPath, observed);
617
+ }
618
+ throw error;
619
+ }
620
+ const code = error.code;
621
+ if (!isRetryableLockOpenError(error))
622
+ throw error;
623
+ if (code === "EEXIST") {
624
+ try {
625
+ const observed = await inspectLock(lockPath);
626
+ if (observed && Date.now() - observed.mtimeMs > LOCK_STALE_AFTER_MS && (observed.pid === null || !processIsAlive(observed.pid)) && await recoverStaleLock(lockPath, observed)) {
627
+ continue;
628
+ }
629
+ } catch {}
630
+ }
631
+ if (Date.now() >= deadline) {
632
+ throw new EmpiricalError("PROJECT_BUSY", "Another Empirical client is updating this repository; retry shortly", lastError);
633
+ }
634
+ await new Promise((resolve2) => setTimeout(resolve2, 10));
635
+ }
636
+ }
637
+ const heartbeat = setInterval(() => {
638
+ const now = new Date;
639
+ handle?.utimes(now, now).catch(() => {
640
+ return;
641
+ });
642
+ }, LOCK_STALE_AFTER_MS / 3);
643
+ heartbeat.unref();
644
+ try {
645
+ return await operation();
646
+ } finally {
647
+ clearInterval(heartbeat);
648
+ const owned = await handle.stat().catch(() => null);
649
+ await handle.close();
650
+ if (owned) {
651
+ await removeLockIfUnchanged(lockPath, {
652
+ dev: owned.dev,
653
+ ino: owned.ino,
654
+ mtimeMs: owned.mtimeMs,
655
+ token,
656
+ pid: process.pid
657
+ });
658
+ }
659
+ }
660
+ }
661
+ function isRetryableLockOpenError(error, platform = process.platform) {
662
+ const code = error.code;
663
+ return code === "EEXIST" || platform === "win32" && (code === "EPERM" || code === "EACCES");
664
+ }
665
+ async function inspectLock(path) {
666
+ let handle;
667
+ try {
668
+ handle = await open(path, "r");
669
+ const details = await handle.stat();
670
+ const raw = await handle.readFile("utf8");
671
+ let token = null;
672
+ let pid = null;
673
+ try {
674
+ const owner = JSON.parse(raw);
675
+ if (typeof owner.token === "string")
676
+ token = owner.token;
677
+ if (typeof owner.pid === "number" && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
678
+ pid = owner.pid;
679
+ }
680
+ } catch {}
681
+ return { dev: details.dev, ino: details.ino, mtimeMs: details.mtimeMs, token, pid };
682
+ } catch (error) {
683
+ if (error.code === "ENOENT")
684
+ return null;
685
+ throw error;
686
+ } finally {
687
+ await handle?.close();
688
+ }
689
+ }
690
+ async function removeLockIfUnchanged(path, expected) {
691
+ const current = await inspectLock(path);
692
+ if (!current || !sameLock(current, expected))
693
+ return false;
694
+ await rm(path, { force: true });
695
+ return true;
696
+ }
697
+ async function recoverStaleLock(path, expected) {
698
+ const recoveryPath = `${path}.recovery`;
699
+ const recoveryToken = randomUUID();
700
+ let recoveryHandle;
701
+ try {
702
+ recoveryHandle = await open(recoveryPath, "wx");
703
+ } catch (error) {
704
+ if (error.code === "EEXIST") {
705
+ const abandoned = await inspectLock(recoveryPath);
706
+ if (abandoned && Date.now() - abandoned.mtimeMs > LOCK_STALE_AFTER_MS && (abandoned.pid === null || !processIsAlive(abandoned.pid))) {
707
+ await removeLockIfUnchanged(recoveryPath, abandoned);
708
+ }
709
+ return false;
710
+ }
711
+ throw error;
712
+ }
713
+ let recoveryOwner = null;
714
+ try {
715
+ await recoveryHandle.writeFile(`${JSON.stringify({ pid: process.pid, token: recoveryToken })}
716
+ `, "utf8");
717
+ await recoveryHandle.sync();
718
+ const details = await recoveryHandle.stat();
719
+ recoveryOwner = {
720
+ dev: details.dev,
721
+ ino: details.ino,
722
+ mtimeMs: details.mtimeMs,
723
+ token: recoveryToken,
724
+ pid: process.pid
725
+ };
726
+ const current = await inspectLock(path);
727
+ if (!current || !sameLock(current, expected) || Date.now() - current.mtimeMs <= LOCK_STALE_AFTER_MS || current.pid !== null && processIsAlive(current.pid)) {
728
+ return false;
729
+ }
730
+ await rm(path, { force: true });
731
+ return true;
732
+ } finally {
733
+ await recoveryHandle.close().catch(() => {
734
+ return;
735
+ });
736
+ if (recoveryOwner)
737
+ await removeLockIfUnchanged(recoveryPath, recoveryOwner);
738
+ else
739
+ await rm(recoveryPath, { force: true });
740
+ }
741
+ }
742
+ function sameLock(left, right) {
743
+ return left.dev === right.dev && left.ino === right.ino && left.token === right.token && left.pid === right.pid;
744
+ }
745
+ function processIsAlive(pid) {
746
+ try {
747
+ process.kill(pid, 0);
748
+ return true;
749
+ } catch (error) {
750
+ return error.code === "EPERM";
751
+ }
752
+ }
753
+ function errorMessage(error) {
754
+ return error instanceof Error ? error.message : String(error);
755
+ }
756
+ async function discoverProject(start) {
757
+ let current = resolve(start);
758
+ while (true) {
759
+ const store = new ProjectStore(current);
760
+ if (await store.exists())
761
+ return store;
762
+ const parent = dirname(current);
763
+ if (parent === current)
764
+ break;
765
+ current = parent;
766
+ }
767
+ throw new EmpiricalError("PROJECT_NOT_INITIALIZED", "No .empirical project found; run empirical init or empirical adopt");
768
+ }
769
+ async function writeJsonAtomic(path, value) {
770
+ await writeTextAtomic(path, `${JSON.stringify(value, null, 2)}
771
+ `);
772
+ }
773
+ async function writeTextAtomic(path, contents) {
774
+ await mkdir(dirname(path), { recursive: true });
775
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
776
+ const existingMode = await stat(path).then((details) => details.mode & 4095, () => null);
777
+ try {
778
+ await writeFile(temporary, contents, "utf8");
779
+ if (existingMode !== null)
780
+ await chmod(temporary, existingMode);
781
+ await rename(temporary, path);
782
+ } catch (error) {
783
+ await rm(temporary, { force: true });
784
+ throw error;
785
+ }
786
+ }
787
+ async function isFile(path) {
788
+ try {
789
+ return (await stat(path)).isFile();
790
+ } catch {
791
+ return false;
792
+ }
793
+ }
794
+ async function isSymbolicLink(path) {
795
+ try {
796
+ return (await lstat(path)).isSymbolicLink();
797
+ } catch {
798
+ return false;
799
+ }
800
+ }
801
+ async function pathExists(path) {
802
+ try {
803
+ await lstat(path);
804
+ return true;
805
+ } catch (error) {
806
+ if (error.code === "ENOENT")
807
+ return false;
808
+ throw error;
809
+ }
810
+ }
811
+ async function legacyEventNames(directory) {
812
+ try {
813
+ return (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name).filter((name) => /^[0-9]{8}\.json$/.test(name)).sort();
814
+ } catch (error) {
815
+ if (error.code === "ENOENT")
816
+ return [];
817
+ throw error;
818
+ }
819
+ }
820
+ async function readJson(path, code = "INVALID_JSON") {
821
+ try {
822
+ return JSON.parse(await readFile(path, "utf8"));
823
+ } catch (error) {
824
+ throw new EmpiricalError(code, `Could not read ${path}`, error);
825
+ }
826
+ }
827
+ function assertFeatureId(feature) {
828
+ if (!isFeatureId(feature)) {
829
+ throw new EmpiricalError("INVALID_FEATURE", `Invalid feature id: ${feature}`);
830
+ }
831
+ }
832
+ function isFeatureId(value) {
833
+ return /^[a-z0-9][a-z0-9-]*$/.test(value) && value.length <= 80;
834
+ }
835
+ function assertCapabilityId(capability) {
836
+ if (!isCapabilityId(capability)) {
837
+ throw new EmpiricalError("INVALID_CAPABILITY", `Invalid capability '${capability}'; use lowercase kebab-case`);
838
+ }
839
+ }
840
+ function isCapabilityId(value) {
841
+ return /^[a-z0-9][a-z0-9-]*$/.test(value);
842
+ }
843
+ function defaultPolicy() {
844
+ return { schemaVersion: POLICY_SCHEMA_VERSION, context: [], phases: {} };
845
+ }
846
+ function normalizePolicy(policy) {
847
+ if (policy.schemaVersion !== POLICY_SCHEMA_VERSION || !Array.isArray(policy.context) || !isRecord(policy.phases)) {
848
+ throw new EmpiricalError("INVALID_POLICY", "Unsupported or malformed project policy");
849
+ }
850
+ const context = policy.context.map((item) => requiredPolicyText(item, "context"));
851
+ const phases = {};
852
+ const validPhases = new Set([
853
+ "idle",
854
+ "shape",
855
+ "specify",
856
+ "design",
857
+ "plan",
858
+ "implement",
859
+ "verify",
860
+ "review",
861
+ "archive",
862
+ "done"
863
+ ]);
864
+ for (const [phase, guidance] of Object.entries(policy.phases)) {
865
+ if (!validPhases.has(phase) || !Array.isArray(guidance)) {
866
+ throw new EmpiricalError("INVALID_POLICY", `Invalid policy phase '${phase}'`);
867
+ }
868
+ phases[phase] = guidance.map((item) => requiredPolicyText(item, phase));
869
+ }
870
+ return { schemaVersion: POLICY_SCHEMA_VERSION, context, phases };
871
+ }
872
+ function requiredPolicyText(value, field) {
873
+ if (typeof value !== "string" || !value.trim()) {
874
+ throw new EmpiricalError("INVALID_POLICY", `Policy ${field} entries must be non-empty strings`);
875
+ }
876
+ return value.trim();
877
+ }
878
+ function isRecord(value) {
879
+ return typeof value === "object" && value !== null && !Array.isArray(value);
880
+ }
881
+ function normalizeConfig(config) {
882
+ assertSupportedSchema(config);
883
+ const value = config;
884
+ const isolation = isRecord(value.isolation) ? value.isolation : {};
885
+ const decisions = isRecord(value.decisions) ? value.decisions : {};
886
+ const mode = isolation.mode === "off" ? "off" : "ask";
887
+ const baseBranch = typeof isolation.baseBranch === "string" && isolation.baseBranch.trim() ? isolation.baseBranch.trim() : "auto";
888
+ const worktreePath = typeof isolation.worktreePath === "string" && isolation.worktreePath.trim() ? isolation.worktreePath.trim() : "../{repo}-{feature}";
889
+ const branchPattern = typeof isolation.branchPattern === "string" && isolation.branchPattern.trim() ? isolation.branchPattern.trim() : "{type}/{feature}";
890
+ validateWorktreeTemplates(worktreePath, branchPattern);
891
+ return {
892
+ ...config,
893
+ schemaVersion: SCHEMA_VERSION,
894
+ profile: normalizeProfile(config.profile),
895
+ isolation: { mode, baseBranch, worktreePath, branchPattern },
896
+ decisions: { complexRecords: decisions.complexRecords === "off" ? "off" : "required" },
897
+ setupComplete: typeof value.setupComplete === "boolean" ? value.setupComplete : false
898
+ };
899
+ }
900
+ function normalizeState(state) {
901
+ assertSupportedSchema(state);
902
+ return {
903
+ ...state,
904
+ schemaVersion: SCHEMA_VERSION,
905
+ profile: normalizeProfile(state.profile),
906
+ specDigest: typeof state.specDigest === "string" ? state.specDigest : null,
907
+ capabilityArchiveRequired: typeof state.capabilityArchiveRequired === "boolean" ? state.capabilityArchiveRequired : false,
908
+ capabilityDeltaDigest: typeof state.capabilityDeltaDigest === "string" ? state.capabilityDeltaDigest : null,
909
+ evidence: Array.isArray(state.evidence) ? state.evidence : []
910
+ };
911
+ }
912
+ function validateWorktreeTemplates(worktreePath, branchPattern) {
913
+ if (!worktreePath.includes("{feature}")) {
914
+ throw new EmpiricalError("INVALID_CONFIG", "Worktree path template must contain {feature}");
915
+ }
916
+ if (!branchPattern.includes("{feature}") || !branchPattern.includes("{type}")) {
917
+ throw new EmpiricalError("INVALID_CONFIG", "Branch pattern must contain {type} and {feature}");
918
+ }
919
+ const allowed = (value) => value.replaceAll("{repo}", "").replaceAll("{feature}", "").replaceAll("{type}", "");
920
+ if (/[\0\r\n]/.test(worktreePath) || /[\0\r\n]/.test(branchPattern) || /[{}]/.test(allowed(worktreePath)) || /[{}]/.test(allowed(branchPattern))) {
921
+ throw new EmpiricalError("INVALID_CONFIG", "Worktree templates contain unsupported placeholders or control characters");
922
+ }
923
+ }
924
+ function idleState(profile) {
925
+ return {
926
+ schemaVersion: SCHEMA_VERSION,
927
+ revision: 0,
928
+ activeFeature: null,
929
+ request: null,
930
+ profile,
931
+ phase: "idle",
932
+ status: "idle",
933
+ repairAttempts: 0,
934
+ message: null,
935
+ implementationActor: null,
936
+ specDigest: null,
937
+ capabilityArchiveRequired: false,
938
+ capabilityDeltaDigest: null,
939
+ evidence: [],
940
+ updatedAt: new Date(0).toISOString()
941
+ };
942
+ }
943
+ function normalizeProfile(profile) {
944
+ if (profile === "strong")
945
+ return "complex";
946
+ if (profile === "fast" || profile === "complex" || profile === "quick")
947
+ return profile;
948
+ throw new EmpiricalError("INVALID_PROFILE", `Unknown persisted workflow '${String(profile)}'`);
949
+ }
950
+ function normalizeEvent(event) {
951
+ assertSupportedSchema(event);
952
+ return {
953
+ ...event,
954
+ schemaVersion: SCHEMA_VERSION,
955
+ state: normalizeState(event.state)
956
+ };
957
+ }
958
+ function assertSupportedSchema(value) {
959
+ if (value.schemaVersion !== 1 && value.schemaVersion !== 2 && value.schemaVersion !== 3 && value.schemaVersion !== SCHEMA_VERSION) {
960
+ throw new EmpiricalError("MIGRATION_REQUIRED", `Project schema ${String(value.schemaVersion)} is not supported; run empirical migrate`);
961
+ }
962
+ }
963
+ function schemaVersion(value) {
964
+ assertSupportedSchema(value);
965
+ return value.schemaVersion;
966
+ }
967
+
968
+ // src/integrations.ts
969
+ var START = "<!-- empirical-sdd:start -->";
970
+ var END = "<!-- empirical-sdd:end -->";
971
+ var MANAGED_FILE_MARKER = "empirical-sdd:managed-file";
972
+ var SOCRATIC_AGENT_GUIDANCE = `For genuinely vague work, retrieve repository and living-spec context with empirical_explore or empirical explore "<problem>", then conduct the original five Socratic passes in the current conversation: problem/user, observable outcome, boundaries/non-goals, failure/risk, and verification. Ask one question at a time, add only a material follow-up, show the complete refined contract, and wait for explicit human approval before starting Fast or Complex. Do not merely repeat the packet's generic questions.`;
973
+ var COMMAND_REFERENCE = `Command reference:
974
+ - Socratic discovery: \`empirical explore "<idea>"\`
975
+ - Socratic discovery, then launch Codex: \`empirical explore "<idea>" --agent codex\`
976
+ - Direct tiny change: \`empirical fast "<request>"\`
977
+ - Direct substantial or UI change: \`empirical complex "<request>"\`
978
+ - Approved unrelated work: \`empirical worktree create "<request>" --workflow fast|complex\`
979
+ - Resume active work: \`empirical loop\`
980
+ - Explain state and accepted decisions: \`empirical explain\`
981
+
982
+ The \`--agent codex\` form is a human terminal entrypoint. Agents must continue in
983
+ their current runtime and use the MCP equivalents when available.`;
984
+ var ENTRYPOINT_NAMES = [
985
+ "empirical",
986
+ "empirical-explore",
987
+ "empirical-fast",
988
+ "empirical-complex",
989
+ "empirical-loop"
990
+ ];
991
+ var DEDICATED_ENTRYPOINTS = [
992
+ {
993
+ name: "empirical-explore",
994
+ description: "Conduct Empirical's five-pass Socratic discovery before starting work.",
995
+ instructions: `Treat the text attached to this invocation as the initial idea. If it is empty,
996
+ ask for the idea first. Retrieve repository and living-spec context with
997
+ empirical_explore or \`empirical explore "<idea>" --no-interview\`, then conduct
998
+ the original five Socratic passes in this conversation: problem/user,
999
+ observable outcome, boundaries/non-goals, failure/risk, and verification. Ask
1000
+ one question at a time and add only a material follow-up. Save or preserve the
1001
+ answers when the host supports it, show the complete refined request, and wait
1002
+ for explicit human approval. After approval, choose Fast only for explicit,
1003
+ tiny, localized, reversible, low-risk non-UI work; choose Complex otherwise.
1004
+ Start through empirical_fast or empirical_complex (CLI fallback: \`empirical
1005
+ fast "<request>"\` or \`empirical complex "<request>"\`) and consume every
1006
+ returned action until Done, Blocked, or awaiting human input.`
1007
+ },
1008
+ {
1009
+ name: "empirical-fast",
1010
+ description: "Run an eligible tiny change through Empirical Fast.",
1011
+ instructions: `Treat the text attached to this invocation as the requested change. Fast is
1012
+ allowed only when the behavior is explicit and the change is tiny, localized,
1013
+ reversible, low-risk, and non-UI. If any condition is false or unclear, use
1014
+ Empirical Complex instead and explain the routing. Otherwise start with
1015
+ empirical_fast or \`empirical fast "<request>"\`. Execute the returned action,
1016
+ provide its focused test and diff-review evidence, complete the exact revision,
1017
+ and consume the response until Done, Blocked, or awaiting human input.`
1018
+ },
1019
+ {
1020
+ name: "empirical-complex",
1021
+ description: "Run a substantial or UI change through Empirical Complex.",
1022
+ instructions: `Treat the text attached to this invocation as the requested change. Start with
1023
+ empirical_complex or \`empirical complex "<request>"\`. Execute the returned
1024
+ Specify, Design, Plan, Implement, Verify, Review, and Archive actions in order,
1025
+ complete each exact revision with all required evidence, and consume every
1026
+ response directly until Done, Blocked, or awaiting human input. Maintain the
1027
+ feature decision record and never weaken acceptance criteria or verification gates.`
1028
+ },
1029
+ {
1030
+ name: "empirical-loop",
1031
+ description: "Resume the active Empirical workflow without starting new work.",
1032
+ instructions: `Resume the active workflow with empirical_loop or \`empirical loop\`. Loop takes
1033
+ no new request or profile. Execute the exact current action, complete its
1034
+ revision with every required artifact and evidence
1035
+ item, and consume each response directly until Done, Blocked, or awaiting human
1036
+ input. Never replace the active feature with text attached to this invocation.`
1037
+ }
1038
+ ];
1039
+ var PROJECT_ENTRYPOINT_REPORTS = [
1040
+ {
1041
+ id: "codex",
1042
+ agent: "Codex",
1043
+ kind: "skill",
1044
+ artifactRoot: ".agents/skills",
1045
+ invocations: ENTRYPOINT_NAMES.map((name) => `$${name}`),
1046
+ reload: "Restart or reopen Codex so it rescans project skills; invoke them with $."
1047
+ },
1048
+ {
1049
+ id: "claude",
1050
+ agent: "Claude Code",
1051
+ kind: "slash-command",
1052
+ artifactRoot: ".claude/skills",
1053
+ invocations: ENTRYPOINT_NAMES.map((name) => `/${name}`),
1054
+ reload: "Restart Claude Code if .claude/skills was created after the session started."
1055
+ },
1056
+ {
1057
+ id: "cursor",
1058
+ agent: "Cursor",
1059
+ kind: "slash-command",
1060
+ artifactRoot: ".cursor/commands",
1061
+ invocations: ENTRYPOINT_NAMES.map((name) => `/${name}`),
1062
+ reload: "Open a new chat or reload the workspace, then type / to discover commands."
1063
+ },
1064
+ {
1065
+ id: "gemini",
1066
+ agent: "Gemini CLI",
1067
+ kind: "slash-command",
1068
+ artifactRoot: ".gemini/commands",
1069
+ invocations: ENTRYPOINT_NAMES.map((name) => `/${name}`),
1070
+ reload: "Run /commands reload, then /commands list."
1071
+ },
1072
+ {
1073
+ id: "windsurf",
1074
+ agent: "Windsurf",
1075
+ kind: "slash-command",
1076
+ artifactRoot: ".windsurf/workflows",
1077
+ invocations: ENTRYPOINT_NAMES.map((name) => `/${name}`),
1078
+ reload: "Start a new Cascade session or reload the workspace, then type /."
1079
+ }
1080
+ ];
1081
+ var GUIDANCE = `${START}
1082
+ ## Empirical SDD
1083
+
1084
+ Automatically use Empirical when the user asks to build, add, implement, change,
1085
+ fix, refactor, remove, migrate, upgrade, update tests, or continue repository
1086
+ work. The user does not need to mention Empirical.
1087
+
1088
+ 1. Use the current agent; never launch another AI runtime.
1089
+ 2. ${SOCRATIC_AGENT_GUIDANCE}
1090
+ 3. For concrete new work, choose Fast only when it is explicit, tiny, localized,
1091
+ reversible, low-risk, and non-UI. Choose Complex for everything else.
1092
+ 4. Start through \`empirical_fast\` or \`empirical_complex\`. Without MCP, run
1093
+ \`empirical fast "<the user's request>"\` or
1094
+ \`empirical complex "<the user's request>"\`.
1095
+ 5. Resume active work through \`empirical_loop\` or \`empirical loop\`; loop
1096
+ takes no request or profile.
1097
+ 6. If unrelated work returns a worktree proposal, show its base, branch, path,
1098
+ and command; wait for explicit human approval, then execute the returned
1099
+ worktree creation operation. Execute each action and complete its exact revision
1100
+ with all required evidence. Each completion response is already the next action; do not call
1101
+ status, next, or loop redundantly.
1102
+ 7. When Review returns Archive, apply its validated capability deltas with the
1103
+ returned archive operation. Continue until Done, Blocked, or genuinely awaiting
1104
+ human input. For Fast, trust the criterion in the returned packet, inspect only relevant project
1105
+ files, combine the focused test and diff review, and use the returned
1106
+ completion command. Do not reread Empirical internals or add redundant checks.
1107
+
1108
+ ${COMMAND_REFERENCE}
1109
+
1110
+ Quick exists only for legacy compatibility. Do not select it for new work or
1111
+ add profile/JSON controls to the normal workflow.
1112
+
1113
+ Do not invent workflow state or weaken verification evidence. The committed
1114
+ \`.empirical/\` directory is the source of truth.
1115
+ ${END}`;
1116
+ var SKILL = `---
1117
+ name: empirical
1118
+ description: Automatically run this repository's Empirical workflow for requests to build, add, implement, change, fix, refactor, remove, migrate, upgrade, test, or continue code. Resume unfinished work; skip read-only explanation or inspection.
1119
+ ---
1120
+
1121
+ <!-- ${MANAGED_FILE_MARKER} -->
1122
+ # Empirical workflow
1123
+
1124
+ Use the current host agent to execute the work. Never launch another AI agent,
1125
+ daemon, or runtime.
1126
+
1127
+ 1. Treat the user's ordinary coding request as the workflow request. The user
1128
+ does not choose a command or profile.
1129
+ 2. ${SOCRATIC_AGENT_GUIDANCE}
1130
+ 3. For concrete work, choose Fast only when the behavior is explicit and the change
1131
+ is tiny, localized, reversible, low-risk, and non-UI. Choose Complex otherwise,
1132
+ including UI, security, authentication, permissions, payments, destructive
1133
+ operations, migrations, dependencies, public APIs, infrastructure,
1134
+ architecture, or cross-cutting work.
1135
+ 4. Start new work with \`empirical_fast\` or \`empirical_complex\`. If MCP is
1136
+ unavailable, run \`empirical fast "<request>"\` or
1137
+ \`empirical complex "<request>"\`.
1138
+ 5. If work is already active, resume it with \`empirical_loop\` or
1139
+ \`empirical loop\`. Loop takes no request or profile.
1140
+ 6. If unrelated work returns a worktree proposal, show its base, branch, path,
1141
+ and command, wait for explicit human approval, then call the returned creation
1142
+ operation. Execute the action and complete the exact revision with every
1143
+ required evidence item. For Fast, trust the generated criterion in the
1144
+ packet, inspect only relevant project files, implement directly, combine the
1145
+ focused test and diff review when practical, and use the returned completion
1146
+ command. Do not reread Empirical state/spec files or add redundant checks.
1147
+ 7. Treat each Fast, Complex, Complete, or Archive response as the next action.
1148
+ After Review, archive validated deltas into living capability specifications.
1149
+ 8. Stop only at \`done\`, \`blocked\`, or \`awaiting_human\`. Explain a blocker or
1150
+ required decision clearly. Keep Fast updates and checks proportional.
1151
+
1152
+ ${COMMAND_REFERENCE}
1153
+
1154
+ Quick exists only to resume legacy workflow state. Do not choose it for new
1155
+ work or add profile/JSON controls to the normal path.
1156
+
1157
+ Never replace unrelated active work, invent state, or weaken acceptance criteria
1158
+ or evidence. The committed \`.empirical/\` directory is the source of truth.
1159
+ `;
1160
+ var CURSOR_COMMAND = `<!-- ${MANAGED_FILE_MARKER} -->
1161
+ # Empirical
1162
+
1163
+ Run the request attached to this command through the repository's Empirical
1164
+ workflow. If there is no new request, resume the active feature.
1165
+
1166
+ Use the current Cursor agent. ${SOCRATIC_AGENT_GUIDANCE} For concrete work,
1167
+ choose Fast only for explicit, tiny,
1168
+ localized, reversible, low-risk non-UI changes and Complex otherwise. Start with
1169
+ \`empirical_fast\` or \`empirical_complex\`; fall back to
1170
+ \`empirical fast "<request>"\` or \`empirical complex "<request>"\`. Resume active
1171
+ work with \`empirical_loop\` or \`empirical loop\`. Execute
1172
+ each returned action, complete exact revisions with evidence, archive after Review, and consume the
1173
+ response directly as the next action. Never select legacy Quick for new work,
1174
+ add profile/JSON controls, or launch another AI runtime.
1175
+
1176
+ ${COMMAND_REFERENCE}
1177
+ `;
1178
+ var GEMINI_COMMAND = `# ${MANAGED_FILE_MARKER}
1179
+ description = "Start or resume Empirical and continue in the current agent until a terminal state."
1180
+ prompt = """
1181
+ Run the request attached to this command through the repository's Empirical workflow. If there is no new request, resume the active feature.
1182
+
1183
+ Use the current Gemini agent. ${SOCRATIC_AGENT_GUIDANCE} For concrete work, choose Fast only for explicit, tiny, localized, reversible, low-risk non-UI changes and Complex otherwise. Start with empirical_fast or empirical_complex; fall back to empirical fast "<request>" or empirical complex "<request>". If unrelated work returns a worktree proposal, show it and wait for explicit approval before creation. Resume active work with empirical_loop or empirical loop. Complete exact revisions with evidence, archive after Review, and consume every response directly. Never select legacy Quick for new work, add profile/JSON controls, or launch another AI runtime.
1184
+
1185
+ ${COMMAND_REFERENCE}
1186
+ """
1187
+ `;
1188
+ var WINDSURF_WORKFLOW = `<!-- ${MANAGED_FILE_MARKER} -->
1189
+ # Empirical
1190
+
1191
+ Start or resume the repository's Empirical workflow for the current request.
1192
+
1193
+ 1. Use the current Cascade agent; never launch another AI runtime.
1194
+ 2. ${SOCRATIC_AGENT_GUIDANCE}
1195
+ 3. For concrete work, choose Fast only for explicit, tiny, localized, reversible,
1196
+ low-risk non-UI changes and Complex otherwise.
1197
+ 4. Start with \`empirical_fast\` or \`empirical_complex\`; fall back to
1198
+ \`empirical fast "<request>"\` or \`empirical complex "<request>"\`.
1199
+ 5. Resume active work with \`empirical_loop\` or \`empirical loop\`.
1200
+ 6. If unrelated work returns a worktree proposal, show it and wait for explicit
1201
+ approval before creation. Execute the action and complete its exact revision
1202
+ with all required evidence.
1203
+ 7. Archive validated capability deltas after Review and consume every response.
1204
+ 8. Stop only at Done, Blocked, or awaiting human input.
1205
+
1206
+ ${COMMAND_REFERENCE}
1207
+
1208
+ Never select legacy Quick for new work or add profile/JSON controls to the
1209
+ normal workflow.
1210
+ `;
1211
+ function renderAgentSkill(entrypoint) {
1212
+ return `---
1213
+ name: ${entrypoint.name}
1214
+ description: ${entrypoint.description}
1215
+ ---
1216
+
1217
+ <!-- ${MANAGED_FILE_MARKER} -->
1218
+ # ${entrypoint.name}
1219
+
1220
+ Use the current host agent; never launch another AI runtime.
1221
+
1222
+ ${entrypoint.instructions}
1223
+ `;
1224
+ }
1225
+ function renderCursorCommand(entrypoint) {
1226
+ return `<!-- ${MANAGED_FILE_MARKER} -->
1227
+ # ${entrypoint.name}
1228
+
1229
+ Use the current Cursor agent; never launch another AI runtime.
1230
+
1231
+ ${entrypoint.instructions}
1232
+ `;
1233
+ }
1234
+ function renderGeminiCommand(entrypoint) {
1235
+ return `# ${MANAGED_FILE_MARKER}
1236
+ description = "${entrypoint.description}"
1237
+ prompt = """
1238
+ Use the current Gemini agent; never launch another AI runtime.
1239
+
1240
+ ${entrypoint.instructions}
1241
+
1242
+ Invocation arguments:
1243
+ {{args}}
1244
+ """
1245
+ `;
1246
+ }
1247
+ function renderWindsurfWorkflow(entrypoint) {
1248
+ return `<!-- ${MANAGED_FILE_MARKER} -->
1249
+ # ${entrypoint.name}
1250
+
1251
+ Use the current Cascade agent; never launch another AI runtime.
1252
+
1253
+ ${entrypoint.instructions}
1254
+ `;
1255
+ }
1256
+ function projectEntrypointReports() {
1257
+ return PROJECT_ENTRYPOINT_REPORTS.map((entrypoint) => ({
1258
+ ...entrypoint,
1259
+ invocations: [...entrypoint.invocations]
1260
+ }));
1261
+ }
1262
+ var MCP_SERVER = {
1263
+ command: "empirical",
1264
+ args: ["mcp"]
1265
+ };
1266
+ async function installProjectIntegrations(root) {
1267
+ const report = {
1268
+ scope: "project",
1269
+ created: [],
1270
+ updated: [],
1271
+ preserved: [],
1272
+ entrypoints: projectEntrypointReports()
1273
+ };
1274
+ await mergeMarkdown(root, join2(root, "AGENTS.md"), GUIDANCE, report);
1275
+ await mergeMarkdown(root, join2(root, "CLAUDE.md"), GUIDANCE, report);
1276
+ await mergeMarkdown(root, join2(root, "GEMINI.md"), GUIDANCE, report);
1277
+ await writeManagedFile(root, join2(root, ".agents", "skills", "empirical", "SKILL.md"), SKILL, report);
1278
+ await writeManagedFile(root, join2(root, ".claude", "skills", "empirical", "SKILL.md"), SKILL, report);
1279
+ await writeManagedFile(root, join2(root, ".cursor", "commands", "empirical.md"), CURSOR_COMMAND, report);
1280
+ await writeManagedFile(root, join2(root, ".gemini", "commands", "empirical.toml"), GEMINI_COMMAND, report);
1281
+ await writeManagedFile(root, join2(root, ".windsurf", "workflows", "empirical.md"), WINDSURF_WORKFLOW, report);
1282
+ for (const entrypoint of DEDICATED_ENTRYPOINTS) {
1283
+ await writeManagedFile(root, join2(root, ".agents", "skills", entrypoint.name, "SKILL.md"), renderAgentSkill(entrypoint), report);
1284
+ await writeManagedFile(root, join2(root, ".claude", "skills", entrypoint.name, "SKILL.md"), renderAgentSkill(entrypoint), report);
1285
+ await writeManagedFile(root, join2(root, ".cursor", "commands", `${entrypoint.name}.md`), renderCursorCommand(entrypoint), report);
1286
+ await writeManagedFile(root, join2(root, ".gemini", "commands", `${entrypoint.name}.toml`), renderGeminiCommand(entrypoint), report);
1287
+ await writeManagedFile(root, join2(root, ".windsurf", "workflows", `${entrypoint.name}.md`), renderWindsurfWorkflow(entrypoint), report);
1288
+ }
1289
+ await mergeMcpJson(root, join2(root, ".mcp.json"), report);
1290
+ await mergeMcpJson(root, join2(root, ".cursor", "mcp.json"), report);
1291
+ await mergeMcpJson(root, join2(root, ".gemini", "settings.json"), report, { cwd: "." });
1292
+ await mergeCodexToml(root, join2(root, ".codex", "config.toml"), report);
1293
+ return report;
1294
+ }
1295
+ var GLOBAL_AGENT_SKILL_ROOTS = [
1296
+ {
1297
+ id: "codex",
1298
+ agent: "Codex",
1299
+ segments: [".codex", "skills"],
1300
+ invocations: ENTRYPOINT_NAMES.map((name) => `$${name}`),
1301
+ reload: "Restart or reopen Codex so it rescans user skills; invoke them with $."
1302
+ },
1303
+ {
1304
+ id: "claude",
1305
+ agent: "Claude Code",
1306
+ segments: [".claude", "skills"],
1307
+ invocations: ENTRYPOINT_NAMES.map((name) => `/${name}`),
1308
+ reload: "Restart Claude Code if the skills were installed during the current session."
1309
+ },
1310
+ {
1311
+ id: "cursor",
1312
+ agent: "Cursor",
1313
+ segments: [".cursor", "skills"],
1314
+ invocations: [...ENTRYPOINT_NAMES],
1315
+ reload: "Reload Cursor, open Agent chat, and ask normally; Cursor discovers the installed Agent Skills."
1316
+ },
1317
+ {
1318
+ id: "gemini",
1319
+ agent: "Gemini CLI",
1320
+ segments: [".gemini", "skills"],
1321
+ invocations: [...ENTRYPOINT_NAMES],
1322
+ reload: "Run /skills reload and /skills list; Gemini activates matching skills from your request."
1323
+ },
1324
+ {
1325
+ id: "windsurf",
1326
+ agent: "Windsurf",
1327
+ segments: [".codeium", "windsurf", "skills"],
1328
+ invocations: ENTRYPOINT_NAMES.map((name) => `@${name}`),
1329
+ reload: "Start a new Cascade session or reload Windsurf; invoke a skill with @."
1330
+ }
1331
+ ];
1332
+ async function installGlobalAgentSkills(homeRoot = homedir()) {
1333
+ const home = validateHomeRoot(homeRoot);
1334
+ const report = {
1335
+ scope: "global",
1336
+ created: [],
1337
+ updated: [],
1338
+ preserved: [],
1339
+ entrypoints: GLOBAL_AGENT_SKILL_ROOTS.map((agent) => ({
1340
+ id: agent.id,
1341
+ agent: agent.agent,
1342
+ kind: "skill",
1343
+ artifactRoot: join2(home, ...agent.segments),
1344
+ invocations: [...agent.invocations],
1345
+ reload: agent.reload
1346
+ }))
1347
+ };
1348
+ for (const agent of GLOBAL_AGENT_SKILL_ROOTS) {
1349
+ const skillRoot = join2(home, ...agent.segments);
1350
+ await writeManagedFile(home, join2(skillRoot, "empirical", "SKILL.md"), SKILL, report);
1351
+ for (const entrypoint of DEDICATED_ENTRYPOINTS) {
1352
+ await writeManagedFile(home, join2(skillRoot, entrypoint.name, "SKILL.md"), renderAgentSkill(entrypoint), report);
1353
+ }
1354
+ }
1355
+ return report;
1356
+ }
1357
+ function validateHomeRoot(homeRoot) {
1358
+ if (!homeRoot.trim()) {
1359
+ throw new EmpiricalError("INVALID_ARGUMENT", "Global integration requires a user home directory");
1360
+ }
1361
+ const home = resolve2(homeRoot);
1362
+ if (dirname2(home) === home) {
1363
+ throw new EmpiricalError("INVALID_ARGUMENT", "Global integration refuses a filesystem root as the user home");
1364
+ }
1365
+ return home;
1366
+ }
1367
+ async function writeManagedFile(root, path, managed, report) {
1368
+ if (await preserveUnsafeTarget(root, path, report))
1369
+ return;
1370
+ const desired = managed.endsWith(`
1371
+ `) ? managed : `${managed}
1372
+ `;
1373
+ if (!await isFile(path)) {
1374
+ await writeTextAtomic(path, desired);
1375
+ report.created.push(relativeLabel(root, path));
1376
+ return;
1377
+ }
1378
+ const current = await readFile2(path, "utf8");
1379
+ if (!current.includes(MANAGED_FILE_MARKER)) {
1380
+ report.preserved.push(`${relativeLabel(root, path)} (existing unmanaged file)`);
1381
+ return;
1382
+ }
1383
+ if (current !== desired) {
1384
+ await writeTextAtomic(path, desired);
1385
+ report.updated.push(relativeLabel(root, path));
1386
+ }
1387
+ }
1388
+ async function mergeMarkdown(root, path, managed, report) {
1389
+ if (await preserveUnsafeTarget(root, path, report))
1390
+ return;
1391
+ if (!await isFile(path)) {
1392
+ await writeTextAtomic(path, `${managed}
1393
+ `);
1394
+ report.created.push(relativeLabel(root, path));
1395
+ return;
1396
+ }
1397
+ const current = await readFile2(path, "utf8");
1398
+ const starts = markerIndexes(current, START);
1399
+ const ends = markerIndexes(current, END);
1400
+ if (starts.length === 1 && ends.length === 1 && ends[0] >= starts[0]) {
1401
+ const start = starts[0];
1402
+ const end = ends[0];
1403
+ const next = `${current.slice(0, start)}${managed}${current.slice(end + END.length)}`;
1404
+ if (next !== current) {
1405
+ await writeTextAtomic(path, next);
1406
+ report.updated.push(relativeLabel(root, path));
1407
+ }
1408
+ return;
1409
+ }
1410
+ if (starts.length > 0 || ends.length > 0) {
1411
+ report.preserved.push(`${relativeLabel(root, path)} (unmatched Empirical marker)`);
1412
+ return;
1413
+ }
1414
+ const separator = current.endsWith(`
1415
+ `) ? `
1416
+ ` : `
1417
+
1418
+ `;
1419
+ await writeTextAtomic(path, `${current}${separator}${managed}
1420
+ `);
1421
+ report.updated.push(relativeLabel(root, path));
1422
+ }
1423
+ async function mergeMcpJson(root, path, report, extra = {}) {
1424
+ if (await preserveUnsafeTarget(root, path, report))
1425
+ return;
1426
+ let document = {};
1427
+ const existed = await isFile(path);
1428
+ if (existed) {
1429
+ try {
1430
+ document = await readJson(path);
1431
+ } catch {
1432
+ report.preserved.push(`${relativeLabel(root, path)} (invalid JSON)`);
1433
+ return;
1434
+ }
1435
+ }
1436
+ if (document.mcpServers !== undefined && !isRecord2(document.mcpServers)) {
1437
+ report.preserved.push(`${relativeLabel(root, path)} (invalid mcpServers value)`);
1438
+ return;
1439
+ }
1440
+ const servers = isRecord2(document.mcpServers) ? document.mcpServers : {};
1441
+ const existing = servers.empirical;
1442
+ const desired = { ...MCP_SERVER, ...extra };
1443
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(desired)) {
1444
+ report.preserved.push(`${relativeLabel(root, path)} (existing empirical MCP entry)`);
1445
+ return;
1446
+ }
1447
+ if (existing !== undefined)
1448
+ return;
1449
+ document.mcpServers = { ...servers, empirical: desired };
1450
+ await writeJsonAtomic(path, document);
1451
+ (existed ? report.updated : report.created).push(relativeLabel(root, path));
1452
+ }
1453
+ async function mergeCodexToml(root, path, report) {
1454
+ if (await preserveUnsafeTarget(root, path, report))
1455
+ return;
1456
+ const start = "# empirical-sdd:mcp:start";
1457
+ const end = "# empirical-sdd:mcp:end";
1458
+ const block = `${start}
1459
+ [mcp_servers.empirical]
1460
+ command = "empirical"
1461
+ args = ["mcp"]
1462
+ ${end}`;
1463
+ if (!await isFile(path)) {
1464
+ await writeTextAtomic(path, `${block}
1465
+ `);
1466
+ report.created.push(relativeLabel(root, path));
1467
+ return;
1468
+ }
1469
+ const current = await readFile2(path, "utf8");
1470
+ const starts = markerIndexes(current, start);
1471
+ const ends = markerIndexes(current, end);
1472
+ if (starts.length === 1 && ends.length === 1 && ends[0] >= starts[0]) {
1473
+ const blockStart = starts[0];
1474
+ const blockEnd = ends[0];
1475
+ const next = `${current.slice(0, blockStart)}${block}${current.slice(blockEnd + end.length)}`;
1476
+ if (next !== current) {
1477
+ await writeTextAtomic(path, next);
1478
+ report.updated.push(relativeLabel(root, path));
1479
+ }
1480
+ return;
1481
+ }
1482
+ if (starts.length > 0 || ends.length > 0) {
1483
+ report.preserved.push(`${relativeLabel(root, path)} (unmatched Empirical marker)`);
1484
+ return;
1485
+ }
1486
+ if (/^\s*\[mcp_servers\.empirical\]\s*$/m.test(current)) {
1487
+ report.preserved.push(`${relativeLabel(root, path)} (existing empirical MCP table)`);
1488
+ return;
1489
+ }
1490
+ const separator = current.endsWith(`
1491
+ `) ? `
1492
+ ` : `
1493
+
1494
+ `;
1495
+ await writeTextAtomic(path, `${current}${separator}${block}
1496
+ `);
1497
+ report.updated.push(relativeLabel(root, path));
1498
+ }
1499
+ async function preserveUnsafeTarget(root, path, report) {
1500
+ const rootPath = resolve2(root);
1501
+ const targetPath = resolve2(path);
1502
+ const label = relativeLabel(rootPath, targetPath);
1503
+ if (!label || label === ".." || label.startsWith("../") || isAbsolute(label)) {
1504
+ throw new EmpiricalError("INVALID_ARGUMENT", `Integration target escapes its root: ${path}`);
1505
+ }
1506
+ const segments = label.split("/");
1507
+ let current = rootPath;
1508
+ for (let index = 0;index < segments.length; index += 1) {
1509
+ current = join2(current, segments[index]);
1510
+ let details;
1511
+ try {
1512
+ details = await lstat2(current);
1513
+ } catch (error) {
1514
+ if (isMissingPathError(error))
1515
+ return false;
1516
+ throw error;
1517
+ }
1518
+ if (details.isSymbolicLink()) {
1519
+ const suffix = index === segments.length - 1 ? "symbolic link" : `symbolic link ancestor ${relativeLabel(rootPath, current)}`;
1520
+ report.preserved.push(`${label} (${suffix})`);
1521
+ return true;
1522
+ }
1523
+ if (index < segments.length - 1 && !details.isDirectory()) {
1524
+ report.preserved.push(`${label} (non-directory ancestor ${relativeLabel(rootPath, current)})`);
1525
+ return true;
1526
+ }
1527
+ if (index === segments.length - 1 && !details.isFile()) {
1528
+ report.preserved.push(`${label} (existing non-file)`);
1529
+ return true;
1530
+ }
1531
+ }
1532
+ return false;
1533
+ }
1534
+ function isMissingPathError(error) {
1535
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1536
+ }
1537
+ function isRecord2(value) {
1538
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1539
+ }
1540
+ function markerIndexes(contents, marker) {
1541
+ const indexes = [];
1542
+ let offset = 0;
1543
+ while (offset < contents.length) {
1544
+ const index = contents.indexOf(marker, offset);
1545
+ if (index < 0)
1546
+ break;
1547
+ indexes.push(index);
1548
+ offset = index + marker.length;
1549
+ }
1550
+ return indexes;
1551
+ }
1552
+ function relativeLabel(root, path) {
1553
+ return relative(root, path).replaceAll("\\", "/");
1554
+ }
1555
+
1556
+ // src/decisions.ts
1557
+ import { readFile as readFile3 } from "node:fs/promises";
1558
+ import { join as join3 } from "node:path";
1559
+ var REQUIRED_SECTIONS = [
1560
+ "Evidence",
1561
+ "Options",
1562
+ "Chosen approach",
1563
+ "Trade-offs and risks",
1564
+ "Verification"
1565
+ ];
1566
+ function decisionPath(store, feature) {
1567
+ return join3(store.specDirectory(feature), "decisions.md");
1568
+ }
1569
+ async function createDecisionTemplate(store, feature) {
1570
+ const path = decisionPath(store, feature);
1571
+ await store.assertFeaturePathSafe(feature, [path]);
1572
+ if (await isFile(path))
1573
+ return;
1574
+ await writeTextAtomic(path, renderDecisionTemplate(feature));
1575
+ }
1576
+ async function validateDecisions(store, feature, requireAccepted = true) {
1577
+ const path = decisionPath(store, feature);
1578
+ await store.assertFeaturePathSafe(feature, [path]);
1579
+ if (!await isFile(path)) {
1580
+ return { valid: false, decisions: [], issues: [`Create ${path}`] };
1581
+ }
1582
+ return parseDecisions(await readFile3(path, "utf8"), requireAccepted);
1583
+ }
1584
+ function parseDecisions(markdown, requireAccepted = true) {
1585
+ const issues = [];
1586
+ if (/^#{1,6}\s+.*(?:chain[- ]of[- ]thought|private reasoning|prompt transcript|scratchpad|credentials?|secrets?)\b/im.test(markdown)) {
1587
+ issues.push("Decision records cannot contain hidden-reasoning, prompt, credential, or secret sections");
1588
+ }
1589
+ const heading = /^##\s+(D-\d{3}):\s*(.+?)\s*$/gim;
1590
+ const matches = [...markdown.matchAll(heading)];
1591
+ const parsed = [];
1592
+ const seen = new Set;
1593
+ for (let index = 0;index < matches.length; index += 1) {
1594
+ const match = matches[index];
1595
+ const id = match[1].toUpperCase();
1596
+ const title = match[2].trim();
1597
+ const start = (match.index ?? 0) + match[0].length;
1598
+ const end = matches[index + 1]?.index ?? markdown.length;
1599
+ const body = markdown.slice(start, end);
1600
+ if (seen.has(id))
1601
+ issues.push(`${id} is duplicated`);
1602
+ seen.add(id);
1603
+ const statusMatch = /^Status:\s*(Proposed|Accepted|Superseded)\s*$/im.exec(body);
1604
+ if (!statusMatch) {
1605
+ issues.push(`${id} needs Status: Proposed, Accepted, or Superseded`);
1606
+ continue;
1607
+ }
1608
+ const sections = new Map;
1609
+ for (const name of REQUIRED_SECTIONS)
1610
+ sections.set(name, section(body, name));
1611
+ for (const name of REQUIRED_SECTIONS) {
1612
+ const value = sections.get(name) ?? "";
1613
+ if (!meaningful(value))
1614
+ issues.push(`${id} has an empty ${name} section`);
1615
+ }
1616
+ const supersedes = /^Supersedes:\s*(.+)$/im.exec(body)?.[1]?.split(",").map((value) => value.trim().toUpperCase()).filter(Boolean) ?? [];
1617
+ const supersededBy = /^Superseded by:\s*(D-\d{3})\s*$/im.exec(body)?.[1]?.toUpperCase() ?? null;
1618
+ const status = statusMatch[1];
1619
+ if (status === "Superseded" && !supersededBy)
1620
+ issues.push(`${id} is Superseded but has no Superseded by link`);
1621
+ if (status !== "Superseded" && supersededBy)
1622
+ issues.push(`${id} has Superseded by but is not Superseded`);
1623
+ parsed.push({
1624
+ id,
1625
+ title,
1626
+ status,
1627
+ evidence: sections.get("Evidence") ?? "",
1628
+ options: sections.get("Options") ?? "",
1629
+ chosenApproach: sections.get("Chosen approach") ?? "",
1630
+ tradeoffs: sections.get("Trade-offs and risks") ?? "",
1631
+ verification: sections.get("Verification") ?? "",
1632
+ supersedes,
1633
+ supersededBy
1634
+ });
1635
+ }
1636
+ if (parsed.length === 0)
1637
+ issues.push("Add at least one material decision entry");
1638
+ if (requireAccepted && !parsed.some((decision) => decision.status === "Accepted")) {
1639
+ issues.push("Accept at least one material decision before completing Design");
1640
+ }
1641
+ const byId = new Map(parsed.map((decision) => [decision.id, decision]));
1642
+ for (const decision of parsed) {
1643
+ for (const previousId of decision.supersedes) {
1644
+ const previous = byId.get(previousId);
1645
+ if (!previous)
1646
+ issues.push(`${decision.id} supersedes missing ${previousId}`);
1647
+ else if (previous.status !== "Superseded" || previous.supersededBy !== decision.id) {
1648
+ issues.push(`${decision.id} and ${previousId} need reciprocal supersession links`);
1649
+ }
1650
+ }
1651
+ if (decision.supersededBy) {
1652
+ const replacement = byId.get(decision.supersededBy);
1653
+ if (!replacement || !replacement.supersedes.includes(decision.id)) {
1654
+ issues.push(`${decision.id} points to ${decision.supersededBy} without a reciprocal Supersedes link`);
1655
+ }
1656
+ }
1657
+ }
1658
+ const decisions = parsed.filter((decision) => decision.status !== "Proposed").map((decision) => ({
1659
+ id: decision.id,
1660
+ title: decision.title,
1661
+ status: decision.status,
1662
+ chosenApproach: summarize(decision.chosenApproach),
1663
+ supersedes: decision.supersedes,
1664
+ supersededBy: decision.supersededBy
1665
+ }));
1666
+ return { valid: issues.length === 0, decisions, issues };
1667
+ }
1668
+ async function requireValidDecisions(store, feature) {
1669
+ const report = await validateDecisions(store, feature, true);
1670
+ if (!report.valid) {
1671
+ throw new EmpiricalError("DECISIONS_REQUIRED", `Decision record is incomplete: ${report.issues.join("; ")}`);
1672
+ }
1673
+ return report.decisions;
1674
+ }
1675
+ function section(body, title) {
1676
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1677
+ const match = new RegExp(`^###\\s+${escaped}\\s*$`, "im").exec(body);
1678
+ if (!match)
1679
+ return "";
1680
+ const start = (match.index ?? 0) + match[0].length;
1681
+ const next = /^###\s+/m.exec(body.slice(start));
1682
+ return body.slice(start, next?.index === undefined ? body.length : start + next.index).trim();
1683
+ }
1684
+ function meaningful(value) {
1685
+ const clean = value.replace(/<!--[^]*?-->/g, "").replace(/[-*#>`_\s]/g, "").trim();
1686
+ return clean.length >= 3 && !/^(todo|tbd|none|n\/a|fillthisin)$/i.test(clean);
1687
+ }
1688
+ function summarize(value) {
1689
+ return value.replace(/^[-*]\s+/gm, "").replace(/\s+/g, " ").trim().slice(0, 500);
1690
+ }
1691
+ function renderDecisionTemplate(feature) {
1692
+ return `# Decisions: ${title(feature)}
1693
+
1694
+ Record concise, externally reviewable evidence and choices here. Do not store
1695
+ private chain-of-thought, prompts, credentials, secrets, or scratchpad text.
1696
+
1697
+ ## D-001: Select the implementation approach
1698
+
1699
+ Status: Proposed
1700
+
1701
+ ### Evidence
1702
+
1703
+ <!-- Repository facts, user constraints, or measured behavior. -->
1704
+
1705
+ ### Options
1706
+
1707
+ <!-- Two or more viable approaches. -->
1708
+
1709
+ ### Chosen approach
1710
+
1711
+ <!-- Change Status to Accepted and state the chosen approach. -->
1712
+
1713
+ ### Trade-offs and risks
1714
+
1715
+ <!-- Costs, limitations, failure modes, and mitigations. -->
1716
+
1717
+ ### Verification
1718
+
1719
+ <!-- Checks that will prove the decision was implemented correctly. -->
1720
+ `;
1721
+ }
1722
+ function title(feature) {
1723
+ return feature.split("-").filter(Boolean).map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`).join(" ");
1724
+ }
1725
+
1726
+ // src/worktrees.ts
1727
+ import { spawnSync } from "node:child_process";
1728
+ import { createHash } from "node:crypto";
1729
+ import { lstat as lstat3 } from "node:fs/promises";
1730
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3, sep } from "node:path";
1731
+ function inferChangeType(request) {
1732
+ if (/\b(fix|bug|broken|regression|repair|crash|error|incorrect)\b/i.test(request))
1733
+ return "fix";
1734
+ if (/\b(chore|docs?|test|release|upgrade|update|maintenance|refactor|cleanup|migrate)\b/i.test(request))
1735
+ return "chore";
1736
+ return "feature";
1737
+ }
1738
+ function featureSlug(request) {
1739
+ const slug = request.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").split("-").slice(0, 10).join("-").slice(0, 72).replace(/-$/, "");
1740
+ return slug || "feature";
1741
+ }
1742
+ function proposeWorktree(root, request, workflow, activeFeature, config, overrides = {}) {
1743
+ const cleanRequest = request.trim();
1744
+ if (!cleanRequest)
1745
+ throw new EmpiricalError("REQUEST_REQUIRED", "A non-empty feature request is required");
1746
+ const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
1747
+ const feature = overrides.feature ?? featureSlug(cleanRequest);
1748
+ assertFeature(feature);
1749
+ const changeType = overrides.changeType ?? inferChangeType(cleanRequest);
1750
+ const base = overrides.base ?? (config.baseBranch === "auto" ? detectBase(repoRoot) : config.baseBranch);
1751
+ const baseCommit = resolveBase(repoRoot, base);
1752
+ const repo = basename(repoRoot);
1753
+ const branch = overrides.branch ?? renderTemplate(config.branchPattern, { repo, feature, type: changeType });
1754
+ assertBranch(repoRoot, branch);
1755
+ const pathValue = overrides.path ?? renderTemplate(config.worktreePath, { repo, feature, type: changeType });
1756
+ const path = resolve3(isAbsolute2(pathValue) ? pathValue : resolve3(repoRoot, pathValue));
1757
+ assertSafeTarget(repoRoot, path);
1758
+ const approvedFields = {
1759
+ root: repoRoot,
1760
+ request: cleanRequest,
1761
+ workflow,
1762
+ changeType,
1763
+ feature,
1764
+ branch,
1765
+ path,
1766
+ base,
1767
+ baseCommit,
1768
+ activeFeature
1769
+ };
1770
+ return {
1771
+ kind: "worktree_proposal",
1772
+ protocol: "empirical-sdd",
1773
+ schemaVersion: SCHEMA_VERSION,
1774
+ ...approvedFields,
1775
+ approvalToken: createHash("sha256").update(JSON.stringify(approvedFields)).digest("hex"),
1776
+ command: ["git", "worktree", "add", "-b", branch, path, baseCommit],
1777
+ requiresApproval: true
1778
+ };
1779
+ }
1780
+ async function createGitWorktree(proposal) {
1781
+ const repoRoot = git(proposal.root, ["rev-parse", "--show-toplevel"]);
1782
+ if (resolve3(repoRoot) !== resolve3(proposal.root)) {
1783
+ throw new EmpiricalError("STALE_WORKTREE_PROPOSAL", "The proposal repository root changed");
1784
+ }
1785
+ const status = git(repoRoot, ["status", "--porcelain=v1", "--untracked-files=all"], true);
1786
+ if (status.trim()) {
1787
+ throw new EmpiricalError("DIRTY_CHECKOUT", "Commit, stash, or remove current changes before Empirical creates a worktree");
1788
+ }
1789
+ assertFeature(proposal.feature);
1790
+ const currentBaseCommit = resolveBase(repoRoot, proposal.base);
1791
+ if (currentBaseCommit !== proposal.baseCommit) {
1792
+ throw new EmpiricalError("STALE_WORKTREE_PROPOSAL", `Base ${proposal.base} moved from ${proposal.baseCommit} to ${currentBaseCommit}; review a new proposal`);
1793
+ }
1794
+ assertBranch(repoRoot, proposal.branch);
1795
+ assertSafeTarget(repoRoot, proposal.path);
1796
+ if (await pathExists2(proposal.path)) {
1797
+ throw new EmpiricalError("WORKTREE_PATH_EXISTS", `Worktree path already exists: ${proposal.path}`);
1798
+ }
1799
+ const worktrees = parseWorktreePaths(git(repoRoot, ["worktree", "list", "--porcelain"]));
1800
+ if (worktrees.some((path) => resolve3(path) === resolve3(proposal.path))) {
1801
+ throw new EmpiricalError("WORKTREE_PATH_EXISTS", `Worktree path is already registered: ${proposal.path}`);
1802
+ }
1803
+ const existingBranch = runGit(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${proposal.branch}`]);
1804
+ if (existingBranch.status === 0) {
1805
+ throw new EmpiricalError("WORKTREE_BRANCH_EXISTS", `Branch already exists: ${proposal.branch}`);
1806
+ }
1807
+ const result = runGit(repoRoot, ["worktree", "add", "-b", proposal.branch, proposal.path, proposal.baseCommit]);
1808
+ if (result.status !== 0 || result.error) {
1809
+ throw new EmpiricalError("WORKTREE_CREATE_FAILED", result.error?.message || result.stderr.trim() || `git exited with ${String(result.status)}`);
1810
+ }
1811
+ }
1812
+ function detectBase(root) {
1813
+ const originHead = runGit(root, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
1814
+ if (originHead.status === 0 && originHead.stdout.trim())
1815
+ return originHead.stdout.trim();
1816
+ for (const candidate of ["main", "master"]) {
1817
+ if (runGit(root, ["rev-parse", "--verify", "--quiet", candidate]).status === 0)
1818
+ return candidate;
1819
+ const remote = `origin/${candidate}`;
1820
+ if (runGit(root, ["rev-parse", "--verify", "--quiet", remote]).status === 0)
1821
+ return remote;
1822
+ }
1823
+ throw new EmpiricalError("BASE_BRANCH_REQUIRED", "Empirical could not detect origin/HEAD, main, or master; provide --base <ref>");
1824
+ }
1825
+ function resolveBase(root, base) {
1826
+ if (!base.trim() || /[\0\r\n]/.test(base))
1827
+ throw new EmpiricalError("INVALID_BASE", "Git base ref is invalid");
1828
+ const result = runGit(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
1829
+ if (result.status !== 0 || !result.stdout.trim()) {
1830
+ throw new EmpiricalError("BASE_NOT_FOUND", `Git base does not resolve to a commit: ${base}`);
1831
+ }
1832
+ return result.stdout.trim();
1833
+ }
1834
+ function assertBranch(root, branch) {
1835
+ if (!branch.trim() || runGit(root, ["check-ref-format", "--branch", branch]).status !== 0) {
1836
+ throw new EmpiricalError("INVALID_BRANCH", `Invalid Git branch: ${branch}`);
1837
+ }
1838
+ if (!/^(feature|fix|chore)\//.test(branch)) {
1839
+ throw new EmpiricalError("INVALID_BRANCH", "Worktree branches must begin with feature/, fix/, or chore/");
1840
+ }
1841
+ }
1842
+ function assertFeature(feature) {
1843
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(feature) || feature.length > 80) {
1844
+ throw new EmpiricalError("INVALID_FEATURE", `Invalid feature id: ${feature}`);
1845
+ }
1846
+ }
1847
+ function assertSafeTarget(repoRoot, target) {
1848
+ const root = resolve3(repoRoot);
1849
+ const path = resolve3(target);
1850
+ if (path === root || root.startsWith(`${path}${sep}`)) {
1851
+ throw new EmpiricalError("INVALID_WORKTREE_PATH", "Worktree path cannot be the repository or its ancestor");
1852
+ }
1853
+ if (path.startsWith(`${root}${sep}`)) {
1854
+ throw new EmpiricalError("INVALID_WORKTREE_PATH", "Worktree path must be outside the current checkout");
1855
+ }
1856
+ if (!relative2(dirname3(root), path)) {
1857
+ throw new EmpiricalError("INVALID_WORKTREE_PATH", "Worktree path must identify a new checkout");
1858
+ }
1859
+ }
1860
+ function renderTemplate(template, values) {
1861
+ const rendered = template.replaceAll("{repo}", values.repo).replaceAll("{feature}", values.feature).replaceAll("{type}", values.type);
1862
+ if (/[{}\0\r\n]/.test(rendered)) {
1863
+ throw new EmpiricalError("INVALID_CONFIG", `Template did not resolve safely: ${template}`);
1864
+ }
1865
+ return rendered;
1866
+ }
1867
+ function git(root, args, allowEmpty = false) {
1868
+ const result = runGit(root, args);
1869
+ if (result.status !== 0 || result.error) {
1870
+ throw new EmpiricalError("GIT_REQUIRED", result.error?.message || result.stderr.trim() || `git ${args[0]} failed`);
1871
+ }
1872
+ const output = result.stdout.trim();
1873
+ if (!allowEmpty && !output)
1874
+ throw new EmpiricalError("GIT_REQUIRED", `git ${args.join(" ")} returned no value`);
1875
+ return output;
1876
+ }
1877
+ function runGit(root, args) {
1878
+ return spawnSync("git", args, { cwd: root, encoding: "utf8", shell: false });
1879
+ }
1880
+ function parseWorktreePaths(output) {
1881
+ return output.split(/\r?\n/).filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9));
1882
+ }
1883
+ async function pathExists2(path) {
1884
+ try {
1885
+ await lstat3(path);
1886
+ return true;
1887
+ } catch (error) {
1888
+ if (error.code === "ENOENT")
1889
+ return false;
1890
+ throw error;
1891
+ }
1892
+ }
1893
+
1894
+ // src/specifications.ts
1895
+ import { readFile as readFile4, readdir as readdir2 } from "node:fs/promises";
1896
+ import { createHash as createHash2 } from "node:crypto";
1897
+ import { join as join4, relative as relative3 } from "node:path";
1898
+ async function loadCapabilityDeltas(store, feature) {
1899
+ const directory = store.deltaDirectory(feature);
1900
+ if (await isSymbolicLink(directory)) {
1901
+ throw new EmpiricalError("INVALID_DELTA", `Capability delta storage cannot use symbolic links: ${directory}`);
1902
+ }
1903
+ let entries;
1904
+ try {
1905
+ entries = await readdir2(directory, { withFileTypes: true });
1906
+ } catch (error) {
1907
+ if (error.code === "ENOENT")
1908
+ return [];
1909
+ throw new EmpiricalError("INVALID_DELTA", `Could not read ${directory}`, error);
1910
+ }
1911
+ const deltas = [];
1912
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
1913
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
1914
+ continue;
1915
+ const capability = entry.name.slice(0, -3);
1916
+ assertCapabilityId(capability);
1917
+ const path = join4(directory, entry.name);
1918
+ deltas.push(parseCapabilityDelta(capability, await readFile4(path, "utf8"), portableRelative(store.root, path)));
1919
+ }
1920
+ return deltas;
1921
+ }
1922
+ function parseCapabilityDelta(capability, markdown, source = `${capability}.md`) {
1923
+ assertCapabilityId(capability);
1924
+ const purpose = sectionContents(markdown, "Purpose");
1925
+ const requirements = [];
1926
+ const sectionPattern = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/gim;
1927
+ const sections = [...markdown.matchAll(sectionPattern)];
1928
+ for (let index = 0;index < sections.length; index += 1) {
1929
+ const match = sections[index];
1930
+ const label = match[1].toLowerCase();
1931
+ if (label === "renamed") {
1932
+ throw new EmpiricalError("INVALID_DELTA", `${source}: RENAMED requirements are not supported; use add/remove with migration text`);
1933
+ }
1934
+ const operation = label;
1935
+ const start = (match.index ?? 0) + match[0].length;
1936
+ const end = sections[index + 1]?.index ?? markdown.length;
1937
+ const body = markdown.slice(start, end);
1938
+ const blocks = requirementBlocks(body);
1939
+ if (blocks.length === 0) {
1940
+ throw new EmpiricalError("INVALID_DELTA", `${source}: ${match[1]} Requirements has no requirement blocks`);
1941
+ }
1942
+ for (const block of blocks) {
1943
+ if (!/^####\s+Scenario:\s*\S+/im.test(block.contents)) {
1944
+ throw new EmpiricalError("INVALID_DELTA", `${source}: requirement '${block.name}' needs at least one #### Scenario`);
1945
+ }
1946
+ requirements.push({ operation, name: block.name, contents: block.contents.trim() });
1947
+ }
1948
+ }
1949
+ if (requirements.length === 0) {
1950
+ throw new EmpiricalError("INVALID_DELTA", `${source}: no ADDED, MODIFIED, or REMOVED requirements found`);
1951
+ }
1952
+ return { capability, purpose: purpose?.trim() || null, requirements, source };
1953
+ }
1954
+ async function validateFeatureDeltas(store, feature) {
1955
+ try {
1956
+ const deltas = await loadCapabilityDeltas(store, feature);
1957
+ if (deltas.length === 0) {
1958
+ return {
1959
+ valid: false,
1960
+ capabilities: [],
1961
+ operations: 0,
1962
+ issues: [`Create at least one ${portableRelative(store.root, store.deltaDirectory(feature))}/<capability>.md delta`],
1963
+ digest: null
1964
+ };
1965
+ }
1966
+ const planned = await buildProjections(store, deltas);
1967
+ return {
1968
+ valid: planned.issues.length === 0,
1969
+ capabilities: [...new Set(deltas.map((delta) => delta.capability))],
1970
+ operations: deltas.reduce((total, delta) => total + delta.requirements.length, 0),
1971
+ issues: planned.issues,
1972
+ digest: digestCapabilityDeltas(deltas)
1973
+ };
1974
+ } catch (error) {
1975
+ return {
1976
+ valid: false,
1977
+ capabilities: [],
1978
+ operations: 0,
1979
+ issues: [error instanceof Error ? error.message : String(error)],
1980
+ digest: null
1981
+ };
1982
+ }
1983
+ }
1984
+ async function capabilityDeltaDigest(store, feature) {
1985
+ const deltas = await loadCapabilityDeltas(store, feature);
1986
+ return deltas.length === 0 ? null : digestCapabilityDeltas(deltas);
1987
+ }
1988
+ async function planCapabilityArchive(store, feature) {
1989
+ const deltas = await loadCapabilityDeltas(store, feature);
1990
+ if (deltas.length === 0) {
1991
+ throw new EmpiricalError("DELTA_REQUIRED", `Complex change ${feature} has no capability deltas`);
1992
+ }
1993
+ const planned = await buildProjections(store, deltas);
1994
+ if (planned.issues.length > 0) {
1995
+ throw new EmpiricalError("INVALID_DELTA", `Capability archive is invalid: ${planned.issues.join("; ")}`);
1996
+ }
1997
+ const counts = { added: 0, modified: 0, removed: 0 };
1998
+ for (const delta of deltas) {
1999
+ for (const requirement of delta.requirements)
2000
+ counts[requirement.operation] += 1;
2001
+ }
2002
+ return {
2003
+ report: {
2004
+ capabilities: planned.projections.map((projection) => projection.capability),
2005
+ ...counts
2006
+ },
2007
+ commit: async () => {
2008
+ const applied = [];
2009
+ const rollback = async () => {
2010
+ for (const projection of [...applied].reverse()) {
2011
+ if (projection.original === null)
2012
+ await store.removeCapability(projection.capability);
2013
+ else
2014
+ await store.writeCapability(projection.capability, projection.original);
2015
+ }
2016
+ };
2017
+ try {
2018
+ for (const projection of planned.projections) {
2019
+ await store.writeCapability(projection.capability, projection.next);
2020
+ applied.push(projection);
2021
+ }
2022
+ } catch (error) {
2023
+ try {
2024
+ await rollback();
2025
+ } catch (rollbackError) {
2026
+ throw new EmpiricalError("ARCHIVE_ROLLBACK_FAILED", "Capability archive failed and could not fully restore its earlier writes", {
2027
+ error: error instanceof Error ? error.message : String(error),
2028
+ rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
2029
+ });
2030
+ }
2031
+ throw error;
2032
+ }
2033
+ return rollback;
2034
+ }
2035
+ };
2036
+ }
2037
+ async function listCapabilities(store) {
2038
+ const summaries = [];
2039
+ for (const name of await store.listCapabilityNames()) {
2040
+ const path = store.capabilitySpecPath(name);
2041
+ if (!await isFile(path))
2042
+ continue;
2043
+ const contents = await readFile4(path, "utf8");
2044
+ summaries.push({
2045
+ name,
2046
+ path: portableRelative(store.root, path),
2047
+ requirements: requirementBlocks(contents).length
2048
+ });
2049
+ }
2050
+ return summaries;
2051
+ }
2052
+ function portableRelative(from, to) {
2053
+ return relative3(from, to).replaceAll("\\", "/");
2054
+ }
2055
+ async function buildProjections(store, deltas) {
2056
+ const grouped = new Map;
2057
+ for (const delta of deltas) {
2058
+ const group = grouped.get(delta.capability) ?? [];
2059
+ group.push(delta);
2060
+ grouped.set(delta.capability, group);
2061
+ }
2062
+ const projections = [];
2063
+ const issues = [];
2064
+ for (const [capability, capabilityDeltas] of [...grouped].sort(([left], [right]) => left.localeCompare(right))) {
2065
+ const original = await store.readCapability(capability);
2066
+ const current = new Map(requirementBlocks(original ?? "").map((block) => [normalizedName(block.name), block]));
2067
+ const touched = new Set;
2068
+ let purpose = original ? sectionContents(original, "Purpose") : null;
2069
+ for (const delta of capabilityDeltas) {
2070
+ if (!purpose && delta.purpose)
2071
+ purpose = delta.purpose;
2072
+ for (const requirement of delta.requirements) {
2073
+ const key = normalizedName(requirement.name);
2074
+ if (touched.has(key)) {
2075
+ issues.push(`${delta.source}: requirement '${requirement.name}' is changed more than once`);
2076
+ continue;
2077
+ }
2078
+ touched.add(key);
2079
+ const existing = current.get(key);
2080
+ if (requirement.operation === "added") {
2081
+ if (existing)
2082
+ issues.push(`${delta.source}: cannot add existing requirement '${requirement.name}'`);
2083
+ else
2084
+ current.set(key, { name: requirement.name, contents: requirement.contents });
2085
+ } else if (requirement.operation === "modified") {
2086
+ if (!existing)
2087
+ issues.push(`${delta.source}: cannot modify missing requirement '${requirement.name}'`);
2088
+ else
2089
+ current.set(key, { name: requirement.name, contents: requirement.contents });
2090
+ } else if (!existing) {
2091
+ issues.push(`${delta.source}: cannot remove missing requirement '${requirement.name}'`);
2092
+ } else {
2093
+ current.delete(key);
2094
+ }
2095
+ }
2096
+ }
2097
+ if (!original && (!purpose || purpose.trim().length < 20)) {
2098
+ issues.push(`${capability}: new capability needs a meaningful ## Purpose`);
2099
+ }
2100
+ projections.push({
2101
+ capability,
2102
+ original,
2103
+ next: renderCapability(capability, purpose, [...current.values()].map((block) => block.contents))
2104
+ });
2105
+ }
2106
+ return { projections, issues };
2107
+ }
2108
+ function requirementBlocks(markdown) {
2109
+ const pattern = /^###\s+Requirement:\s*(.+?)\s*$/gim;
2110
+ const matches = [...markdown.matchAll(pattern)];
2111
+ return matches.map((match, index) => {
2112
+ const start = match.index ?? 0;
2113
+ const end = matches[index + 1]?.index ?? nextSecondLevelHeading(markdown, start + match[0].length);
2114
+ return { name: match[1].trim(), contents: markdown.slice(start, end).trim() };
2115
+ });
2116
+ }
2117
+ function nextSecondLevelHeading(markdown, start) {
2118
+ const match = /^##\s+/m.exec(markdown.slice(start));
2119
+ return match?.index === undefined ? markdown.length : start + match.index;
2120
+ }
2121
+ function sectionContents(markdown, title2) {
2122
+ const pattern = new RegExp(`^##\\s+${escapeRegExp(title2)}\\s*$`, "im");
2123
+ const match = pattern.exec(markdown);
2124
+ if (!match)
2125
+ return null;
2126
+ const start = (match.index ?? 0) + match[0].length;
2127
+ const end = nextSecondLevelHeading(markdown, start);
2128
+ return markdown.slice(start, end).trim();
2129
+ }
2130
+ function renderCapability(capability, purpose, requirements) {
2131
+ const title2 = capability.split("-").map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join(" ");
2132
+ const body = requirements.length > 0 ? `${requirements.join(`
2133
+
2134
+ `)}
2135
+ ` : `_No current requirements._
2136
+ `;
2137
+ return `# ${title2} Specification
2138
+
2139
+ ## Purpose
2140
+
2141
+ ${purpose?.trim() ?? `Current behavior for ${title2}.`}
2142
+
2143
+ ## Requirements
2144
+
2145
+ ${body}`;
2146
+ }
2147
+ function normalizedName(name) {
2148
+ return name.trim().replace(/\s+/g, " ").toLowerCase();
2149
+ }
2150
+ function digestCapabilityDeltas(deltas) {
2151
+ return createHash2("sha256").update(JSON.stringify(deltas)).digest("hex");
2152
+ }
2153
+ function escapeRegExp(value) {
2154
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2155
+ }
2156
+
2157
+ // src/core.ts
2158
+ var FAST_PHASES = ["implement", "done"];
2159
+ var QUICK_PHASES = ["shape", "implement", "verify", "review", "done"];
2160
+ var COMPLEX_PHASES = [
2161
+ "specify",
2162
+ "design",
2163
+ "plan",
2164
+ "implement",
2165
+ "verify",
2166
+ "review",
2167
+ "archive",
2168
+ "done"
2169
+ ];
2170
+
2171
+ class EmpiricalProject {
2172
+ store;
2173
+ readOnly;
2174
+ constructor(store, readOnly = false) {
2175
+ this.store = store;
2176
+ this.readOnly = readOnly;
2177
+ }
2178
+ static async open(start = process.cwd(), options = {}) {
2179
+ const base = await discoverProject(start);
2180
+ const migrate = options.migrate !== false;
2181
+ if (migrate)
2182
+ await base.migrateSchema();
2183
+ const active = await base.activeFeature(migrate);
2184
+ return new EmpiricalProject(active ? base.forFeature(active) : base);
2185
+ }
2186
+ static async openReadOnly(start = process.cwd()) {
2187
+ const base = await discoverProject(start);
2188
+ await base.assertCurrentSchemaReadOnly();
2189
+ const active = await base.activeFeature(false);
2190
+ return new EmpiricalProject(active ? base.forFeature(active) : base, true);
2191
+ }
2192
+ static async initialize(root = process.cwd(), options = {}) {
2193
+ const absoluteRoot = resolve4(root);
2194
+ await mkdir2(absoluteRoot, { recursive: true });
2195
+ const store = new ProjectStore(absoluteRoot);
2196
+ if (await store.exists()) {
2197
+ const integrations2 = options.integrations === false ? emptyIntegrationReport() : await installProjectIntegrations(absoluteRoot);
2198
+ await store.migrateSchema();
2199
+ const active = await store.activeFeature();
2200
+ const project = new EmpiricalProject(active ? store.forFeature(active) : store);
2201
+ return { project, state: await project.store.loadState(), integrations: integrations2 };
2202
+ }
2203
+ if (await isFile(join5(absoluteRoot, "ai", "STATE.md"))) {
2204
+ throw new EmpiricalError("LEGACY_PROJECT", "An Empirical v1 ai/ workspace already exists; run empirical adopt");
2205
+ }
2206
+ const profile = options.profile ?? "complex";
2207
+ assertWorkflow(profile);
2208
+ const config = defaultConfig(profile, null, options);
2209
+ const state = initialState(profile);
2210
+ await store.writeInitial(config);
2211
+ const integrations = options.integrations === false ? emptyIntegrationReport() : await installProjectIntegrations(absoluteRoot);
2212
+ return { project: new EmpiricalProject(store), state, integrations };
2213
+ }
2214
+ static async adopt(root = process.cwd(), options = {}) {
2215
+ const absoluteRoot = resolve4(root);
2216
+ const store = new ProjectStore(absoluteRoot);
2217
+ if (await store.exists()) {
2218
+ const integrations2 = options.integrations === false ? emptyIntegrationReport() : await installProjectIntegrations(absoluteRoot);
2219
+ await store.migrateSchema();
2220
+ const active = await store.activeFeature();
2221
+ const project2 = new EmpiricalProject(active ? store.forFeature(active) : store);
2222
+ return { project: project2, state: await project2.store.loadState(), integrations: integrations2 };
2223
+ }
2224
+ const legacyStatePath = join5(absoluteRoot, "ai", "STATE.md");
2225
+ if (!await isFile(legacyStatePath)) {
2226
+ throw new EmpiricalError("LEGACY_NOT_FOUND", "No ai/STATE.md was found; use empirical init for a new repository");
2227
+ }
2228
+ const legacy = await readFile5(legacyStatePath, "utf8");
2229
+ const feature = legacyField(legacy, "current_spec") ?? legacyField(legacy, "currentSpec");
2230
+ const legacyPhase = legacyField(legacy, "current_phase") ?? legacyField(legacy, "currentPhase") ?? legacyField(legacy, "phase");
2231
+ const profile = options.profile ?? "complex";
2232
+ assertWorkflow(profile);
2233
+ const phase = feature ? mapLegacyPhase(legacyPhase, profile) : "idle";
2234
+ const now = new Date().toISOString();
2235
+ const state = {
2236
+ ...initialState(profile),
2237
+ activeFeature: feature,
2238
+ phase,
2239
+ status: phase === "idle" ? "idle" : phase === "done" ? "done" : "waiting",
2240
+ updatedAt: now,
2241
+ message: "Adopted non-destructively from ai/"
2242
+ };
2243
+ await store.writeInitial(defaultConfig(profile, "ai", options));
2244
+ if (feature) {
2245
+ const legacySpec = join5(absoluteRoot, "ai", "specs", feature, "spec.md");
2246
+ if (await isFile(legacySpec)) {
2247
+ await store.writeSpec(feature, await readFile5(legacySpec, "utf8"));
2248
+ } else {
2249
+ const request = `Adopted v1 feature ${feature}`;
2250
+ await store.writeSpec(feature, profile === "fast" ? renderFastSpec(feature, request) : renderSpec(feature, request));
2251
+ }
2252
+ await store.forFeature(feature).writeInitialFeature(state, "empirical-adopt", "Adopted Empirical v1 state");
2253
+ }
2254
+ const integrations = options.integrations === false ? emptyIntegrationReport() : await installProjectIntegrations(absoluteRoot);
2255
+ const project = new EmpiricalProject(feature ? store.forFeature(feature) : store);
2256
+ return { project, state, integrations };
2257
+ }
2258
+ async status() {
2259
+ return this.store.loadState(!this.readOnly);
2260
+ }
2261
+ async config() {
2262
+ return this.store.loadConfig();
2263
+ }
2264
+ async configure(input) {
2265
+ const current = await this.store.loadConfig();
2266
+ return this.store.configure({
2267
+ ...current,
2268
+ isolation: { ...current.isolation, ...input.isolation },
2269
+ decisions: { ...current.decisions, ...input.decisions },
2270
+ setupComplete: input.setupComplete ?? true
2271
+ });
2272
+ }
2273
+ async policy() {
2274
+ return this.store.loadPolicy();
2275
+ }
2276
+ async explore(problem) {
2277
+ const cleanProblem = problem.trim();
2278
+ if (!cleanProblem)
2279
+ throw new EmpiricalError("REQUEST_REQUIRED", "A non-empty problem is required");
2280
+ const policy = await this.store.loadPolicy();
2281
+ const capabilities = await listCapabilities(this.store);
2282
+ return {
2283
+ protocol: "empirical-sdd",
2284
+ schemaVersion: SCHEMA_VERSION,
2285
+ root: this.store.root,
2286
+ problem: cleanProblem,
2287
+ instructions: [
2288
+ "Use the current host agent only. Inspect the relevant code and living capability specifications; do not write implementation code yet.",
2289
+ "Identify the observed problem, affected users, current behavior, smallest useful outcome, constraints, risks, and two or three viable approaches.",
2290
+ "Ask only questions whose answers materially change scope or architecture, then restate the refined request in observable terms.",
2291
+ "Choose Fast only when the refined change is explicit, tiny, localized, reversible, low-risk, and non-UI; choose Complex otherwise."
2292
+ ],
2293
+ questions: [
2294
+ "Who experiences the problem and what observable behavior should change?",
2295
+ "What is the smallest useful outcome, and what is explicitly out of scope?",
2296
+ "Which assumption, dependency, or risk could change the implementation approach?"
2297
+ ],
2298
+ projectContext: policy.context,
2299
+ capabilityContext: capabilities.map((capability) => capability.path),
2300
+ next: {
2301
+ fast: `empirical fast ${JSON.stringify(cleanProblem)}`,
2302
+ complex: `empirical complex ${JSON.stringify(cleanProblem)}`
2303
+ }
2304
+ };
2305
+ }
2306
+ async capabilities() {
2307
+ return listCapabilities(this.store);
2308
+ }
2309
+ async capability(name) {
2310
+ return this.store.readCapability(name);
2311
+ }
2312
+ async start(request, options = {}) {
2313
+ const cleanRequest = request.trim();
2314
+ if (!cleanRequest) {
2315
+ throw new EmpiricalError("REQUEST_REQUIRED", "A non-empty feature request is required");
2316
+ }
2317
+ const configuredProfile = (await this.store.loadConfig()).profile;
2318
+ const profile = options.profile ?? (configuredProfile === "quick" ? "complex" : configuredProfile);
2319
+ assertWorkflow(profile);
2320
+ const base = new ProjectStore(this.store.root);
2321
+ const active = await base.activeFeature();
2322
+ if (active) {
2323
+ const current = await base.forFeature(active).loadState();
2324
+ if (current.request?.trim() === cleanRequest) {
2325
+ this.store = base.forFeature(active);
2326
+ return assertStartAction(await this.next(), cleanRequest, profile, options);
2327
+ }
2328
+ return this.proposeWorktree(cleanRequest, profile, { ...options.id ? { feature: options.id } : {} });
2329
+ }
2330
+ const started = await base.withResourceLock("specs", async () => {
2331
+ const raced = await base.activeFeature();
2332
+ if (raced) {
2333
+ const current = await base.forFeature(raced).loadState();
2334
+ if (current.request?.trim() === cleanRequest) {
2335
+ return { existing: true, store: base.forFeature(raced), state: current, spec: await base.readSpec(raced) };
2336
+ }
2337
+ return { proposal: await this.proposeWorktree(cleanRequest, profile, { ...options.id ? { feature: options.id } : {} }) };
2338
+ }
2339
+ const feature = options.id ?? featureSlug(cleanRequest);
2340
+ if ((await base.listFeatureIds()).includes(feature)) {
2341
+ throw new EmpiricalError("FEATURE_EXISTS", `Feature ${feature} already exists; choose a distinct --id`);
2342
+ }
2343
+ const spec = profile === "fast" ? renderFastSpec(titleFromFeature(feature), cleanRequest) : renderSpec(titleFromFeature(feature), cleanRequest);
2344
+ await base.writeSpec(feature, spec);
2345
+ if (profile === "complex")
2346
+ await createDecisionTemplate(base, feature);
2347
+ const state = {
2348
+ ...initialState(profile),
2349
+ revision: 1,
2350
+ activeFeature: feature,
2351
+ request: cleanRequest,
2352
+ phase: firstPhase(profile),
2353
+ status: "waiting",
2354
+ specDigest: digest(spec),
2355
+ capabilityArchiveRequired: profile === "complex",
2356
+ updatedAt: new Date().toISOString()
2357
+ };
2358
+ const scoped = base.forFeature(feature);
2359
+ await scoped.writeInitialFeature(state);
2360
+ return { existing: false, store: scoped, state, spec };
2361
+ });
2362
+ if ("proposal" in started)
2363
+ return started.proposal;
2364
+ this.store = started.store;
2365
+ return this.packet(started.state, parseCriteria(started.spec));
2366
+ }
2367
+ async fast(request, options = {}) {
2368
+ return this.begin(request, "fast", options);
2369
+ }
2370
+ async complex(request, options = {}) {
2371
+ return this.begin(request, "complex", options);
2372
+ }
2373
+ async loop() {
2374
+ if (arguments.length > 0) {
2375
+ throw new EmpiricalError("INVALID_ARGUMENT", "Loop only resumes current work; start new work with empirical fast or empirical complex");
2376
+ }
2377
+ return this.next();
2378
+ }
2379
+ async begin(request, profile, options) {
2380
+ const base = new ProjectStore(this.store.root);
2381
+ const activeFeature = await base.activeFeature();
2382
+ const current = activeFeature ? await base.forFeature(activeFeature).loadState() : await base.loadState();
2383
+ const cleanRequest = request.trim();
2384
+ if (!cleanRequest) {
2385
+ throw new EmpiricalError("REQUEST_REQUIRED", "A non-empty feature request is required");
2386
+ }
2387
+ const currentRequest = current.request?.trim();
2388
+ const active = current.activeFeature !== null && current.phase !== "done";
2389
+ if (active) {
2390
+ if (currentRequest !== cleanRequest) {
2391
+ return this.proposeWorktree(cleanRequest, profile, { ...options.id ? { feature: options.id } : {} });
2392
+ }
2393
+ if (profile !== current.profile) {
2394
+ throw new EmpiricalError("PROFILE_CONFLICT", `The active feature uses profile ${current.profile}, not ${profile}`);
2395
+ }
2396
+ if (options.id && options.id !== current.activeFeature) {
2397
+ throw new EmpiricalError("FEATURE_ACTIVE", `The active feature is ${current.activeFeature}, not ${options.id}`);
2398
+ }
2399
+ this.store = base.forFeature(current.activeFeature);
2400
+ return assertStartAction(await this.next(), cleanRequest, profile, options);
2401
+ }
2402
+ try {
2403
+ return await this.start(cleanRequest, { profile, ...options });
2404
+ } catch (error) {
2405
+ if (error instanceof EmpiricalError && (error.code === "FEATURE_ACTIVE" || error.code === "PROJECT_BUSY")) {
2406
+ const latest = await EmpiricalProject.open(this.store.root);
2407
+ const action = await latest.next();
2408
+ if (action.request === cleanRequest) {
2409
+ this.store = latest.store;
2410
+ return assertStartAction(action, cleanRequest, profile, options);
2411
+ }
2412
+ }
2413
+ throw error;
2414
+ }
2415
+ }
2416
+ async proposeWorktree(request, workflow, overrides = {}) {
2417
+ const base = new ProjectStore(this.store.root);
2418
+ const activeFeature = await base.activeFeature(!this.readOnly);
2419
+ if (!activeFeature) {
2420
+ throw new EmpiricalError("WORKTREE_NOT_NEEDED", "This checkout has no active feature; start the request here");
2421
+ }
2422
+ const config = await base.loadConfig();
2423
+ if (config.isolation.mode === "off") {
2424
+ throw new EmpiricalError("FEATURE_ACTIVE", `Feature ${activeFeature} is active and automatic worktree proposals are disabled`);
2425
+ }
2426
+ return proposeWorktree(base.root, request, workflow, activeFeature, config.isolation, overrides);
2427
+ }
2428
+ async createWorktree(input) {
2429
+ if (input.approved !== true) {
2430
+ throw new EmpiricalError("WORKTREE_APPROVAL_REQUIRED", "Worktree creation requires approved: true");
2431
+ }
2432
+ const proposal = await this.proposeWorktree(input.request, input.workflow, {
2433
+ ...input.changeType ? { changeType: input.changeType } : {},
2434
+ ...input.feature ? { feature: input.feature } : {},
2435
+ ...input.branch ? { branch: input.branch } : {},
2436
+ ...input.path ? { path: input.path } : {},
2437
+ ...input.base ? { base: input.base } : {}
2438
+ });
2439
+ if (proposal.activeFeature !== input.activeFeature) {
2440
+ throw new EmpiricalError("STALE_WORKTREE_PROPOSAL", `The active feature changed from ${input.activeFeature} to ${proposal.activeFeature}; review a new proposal`);
2441
+ }
2442
+ if (proposal.baseCommit !== input.baseCommit) {
2443
+ throw new EmpiricalError("STALE_WORKTREE_PROPOSAL", `Base ${proposal.base} moved after approval; review a new proposal`);
2444
+ }
2445
+ if (proposal.approvalToken !== input.approvalToken) {
2446
+ throw new EmpiricalError("STALE_WORKTREE_PROPOSAL", "The approved worktree fields changed; review and approve a new proposal");
2447
+ }
2448
+ await createGitWorktree(proposal);
2449
+ try {
2450
+ let project;
2451
+ try {
2452
+ project = await EmpiricalProject.open(proposal.path);
2453
+ } catch (error) {
2454
+ if (!(error instanceof EmpiricalError) || error.code !== "PROJECT_NOT_INITIALIZED")
2455
+ throw error;
2456
+ project = (await EmpiricalProject.initialize(proposal.path, { integrations: false })).project;
2457
+ }
2458
+ const result = proposal.workflow === "fast" ? await project.fast(proposal.request, { id: proposal.feature }) : await project.complex(proposal.request, { id: proposal.feature });
2459
+ if (result.kind !== "action") {
2460
+ throw new EmpiricalError("WORKTREE_HANDOFF_FAILED", `The new checkout already contains active feature ${result.activeFeature}`);
2461
+ }
2462
+ return {
2463
+ kind: "worktree_handoff",
2464
+ protocol: "empirical-sdd",
2465
+ schemaVersion: SCHEMA_VERSION,
2466
+ root: proposal.root,
2467
+ path: proposal.path,
2468
+ branch: proposal.branch,
2469
+ base: proposal.base,
2470
+ baseCommit: proposal.baseCommit,
2471
+ feature: result.feature,
2472
+ revision: result.revision,
2473
+ workflow: proposal.workflow,
2474
+ resume: `cd ${JSON.stringify(proposal.path)} && empirical loop`,
2475
+ action: result
2476
+ };
2477
+ } catch (error) {
2478
+ throw new EmpiricalError("WORKTREE_HANDOFF_FAILED", `Git created ${proposal.path}, but Empirical handoff failed: ${error instanceof Error ? error.message : String(error)}`, { path: proposal.path, branch: proposal.branch, base: proposal.base, baseCommit: proposal.baseCommit });
2479
+ }
2480
+ }
2481
+ async explain() {
2482
+ const state = await this.store.loadState(!this.readOnly);
2483
+ const criteria = state.activeFeature ? parseCriteria(await this.store.readSpec(state.activeFeature)) : [];
2484
+ const packet = await this.packet(state, criteria);
2485
+ const decisions = state.activeFeature && state.profile === "complex" ? (await validateDecisions(this.store, state.activeFeature, false)).decisions.filter((decision) => decision.status === "Accepted") : [];
2486
+ return {
2487
+ protocol: "empirical-sdd",
2488
+ schemaVersion: SCHEMA_VERSION,
2489
+ root: this.store.root,
2490
+ feature: state.activeFeature,
2491
+ phase: state.phase,
2492
+ status: state.status,
2493
+ revision: state.revision,
2494
+ rationale: packet.rationale,
2495
+ decisions
2496
+ };
2497
+ }
2498
+ async next() {
2499
+ const state = await this.store.loadState(!this.readOnly);
2500
+ const criteria = state.activeFeature ? parseCriteria(await this.store.readSpec(state.activeFeature)) : [];
2501
+ return this.packet(state, criteria);
2502
+ }
2503
+ async complete(input) {
2504
+ assertCompletionInput(input);
2505
+ const summary = input.summary.trim();
2506
+ if (!summary)
2507
+ throw new EmpiricalError("SUMMARY_REQUIRED", "Completion summary cannot be blank");
2508
+ const actor = input.actor?.trim() || "agent";
2509
+ const completed = await this.store.transaction(async (current) => {
2510
+ if (input.revision !== current.revision) {
2511
+ throw new EmpiricalError("STALE_REVISION", `Expected revision ${input.revision}, but the project is at ${current.revision}`);
2512
+ }
2513
+ if (!current.activeFeature || current.phase === "idle" || current.phase === "done") {
2514
+ throw new EmpiricalError("NO_ACTIVE_PHASE", "There is no active phase to complete");
2515
+ }
2516
+ if (current.phase === "archive") {
2517
+ throw new EmpiricalError("ARCHIVE_REQUIRED", "Use empirical archive for the reviewed revision");
2518
+ }
2519
+ if (current.status === "blocked") {
2520
+ throw new EmpiricalError("WORKFLOW_BLOCKED", "Resolve the blocker and run empirical retry");
2521
+ }
2522
+ if (current.status === "awaiting_human") {
2523
+ throw new EmpiricalError("AWAITING_HUMAN", "Run empirical retry after the decision is provided");
2524
+ }
2525
+ const specBefore = await this.store.readSpec(current.activeFeature);
2526
+ const specBeforeDigest = digest(specBefore);
2527
+ if (current.specDigest && current.specDigest !== specBeforeDigest && current.phase !== "shape" && current.phase !== "specify") {
2528
+ throw new EmpiricalError("SPEC_CHANGED", "The specification changed after it was approved; restore it or start a new feature");
2529
+ }
2530
+ await this.assertCapabilityDeltasUnchanged(current);
2531
+ const criteria = parseCriteria(specBefore);
2532
+ const config = await this.store.loadConfig();
2533
+ let approvedDeltaDigest = null;
2534
+ if (input.outcome === "passed") {
2535
+ approvedDeltaDigest = await this.validatePhasePass(current, input, criteria, config);
2536
+ }
2537
+ const state = structuredClone(current);
2538
+ state.specDigest = specBeforeDigest;
2539
+ if (input.outcome === "awaiting_human") {
2540
+ state.status = "awaiting_human";
2541
+ state.message = summary;
2542
+ } else if (input.outcome === "blocked") {
2543
+ state.status = "blocked";
2544
+ state.message = summary;
2545
+ } else if (input.outcome === "failed") {
2546
+ routeFailure(state, summary, config.maxRepairAttempts);
2547
+ } else {
2548
+ if (current.phase === "specify" && current.capabilityArchiveRequired) {
2549
+ state.capabilityDeltaDigest = approvedDeltaDigest;
2550
+ }
2551
+ if (state.phase === "implement")
2552
+ state.implementationActor = actor;
2553
+ if (input.evidence?.length)
2554
+ state.evidence.push(...input.evidence);
2555
+ state.phase = followingPhase(state.profile, state.phase);
2556
+ state.status = state.phase === "done" ? "done" : "waiting";
2557
+ state.message = summary;
2558
+ if (state.phase === "done")
2559
+ state.repairAttempts = 0;
2560
+ }
2561
+ return {
2562
+ actor,
2563
+ summary,
2564
+ state,
2565
+ value: specBefore,
2566
+ validate: async () => {
2567
+ if (await this.store.readSpec(current.activeFeature) !== specBefore) {
2568
+ throw new EmpiricalError("SPEC_CHANGED", "The specification changed during completion; read the latest action and retry");
2569
+ }
2570
+ if (approvedDeltaDigest && await capabilityDeltaDigest(this.store, current.activeFeature) !== approvedDeltaDigest) {
2571
+ throw new EmpiricalError("DELTA_CHANGED", "Capability deltas changed during completion; read the latest action and retry");
2572
+ }
2573
+ }
2574
+ };
2575
+ });
2576
+ return this.packet(completed.state, parseCriteria(completed.value));
2577
+ }
2578
+ async archive(expectedRevision, actor = "agent") {
2579
+ const current = await this.store.loadState();
2580
+ if (!current.activeFeature)
2581
+ throw new EmpiricalError("NO_ACTIVE_PHASE", "There is no feature to archive");
2582
+ if (current.phase === "done" && current.status === "done" && current.profile === "complex" && current.revision === expectedRevision + 1 && current.message?.startsWith("Archived")) {
2583
+ return {
2584
+ action: await this.next(),
2585
+ report: {
2586
+ feature: current.activeFeature,
2587
+ capabilities: [],
2588
+ added: 0,
2589
+ modified: 0,
2590
+ removed: 0,
2591
+ converged: true
2592
+ }
2593
+ };
2594
+ }
2595
+ if (current.phase === "done" && current.status === "done") {
2596
+ throw new EmpiricalError("STALE_REVISION", `Archive revision ${expectedRevision} does not identify the latest completed archive`);
2597
+ }
2598
+ if (current.phase !== "archive" || current.status !== "waiting") {
2599
+ throw new EmpiricalError("ARCHIVE_NOT_READY", "Complex work must pass review before archive");
2600
+ }
2601
+ if (current.revision !== expectedRevision) {
2602
+ throw new EmpiricalError("STALE_REVISION", `Expected revision ${expectedRevision}, but the project is at ${current.revision}`);
2603
+ }
2604
+ return this.store.withResourceLock("capabilities", async () => {
2605
+ await this.assertCapabilityDeltasUnchanged(current);
2606
+ const deltas = await loadCapabilityDeltas(this.store, current.activeFeature);
2607
+ const plan = deltas.length > 0 ? await planCapabilityArchive(this.store, current.activeFeature) : null;
2608
+ if (!plan && current.capabilityArchiveRequired) {
2609
+ throw new EmpiricalError("DELTA_REQUIRED", `Complex change ${current.activeFeature} has no capability deltas`);
2610
+ }
2611
+ const archived = await this.store.transaction(async (latest) => {
2612
+ if (latest.revision !== expectedRevision) {
2613
+ throw new EmpiricalError("STALE_REVISION", `Expected revision ${expectedRevision}, but the project is at ${latest.revision}`);
2614
+ }
2615
+ if (latest.phase !== "archive" || latest.status !== "waiting") {
2616
+ throw new EmpiricalError("ARCHIVE_NOT_READY", "Complex work must pass review before archive");
2617
+ }
2618
+ const state = structuredClone(latest);
2619
+ state.phase = "done";
2620
+ state.status = "done";
2621
+ state.message = plan ? `Archived capability changes: ${plan.report.capabilities.join(", ")}` : "Archived legacy change without capability deltas";
2622
+ state.repairAttempts = 0;
2623
+ return {
2624
+ actor: actor.trim() || "agent",
2625
+ summary: state.message,
2626
+ state,
2627
+ value: latest.activeFeature,
2628
+ ...plan ? { effect: plan.commit } : {}
2629
+ };
2630
+ });
2631
+ return {
2632
+ action: await this.packet(archived.state, parseCriteria(await this.store.readSpec(archived.value))),
2633
+ report: {
2634
+ feature: archived.value,
2635
+ capabilities: plan?.report.capabilities ?? [],
2636
+ added: plan?.report.added ?? 0,
2637
+ modified: plan?.report.modified ?? 0,
2638
+ removed: plan?.report.removed ?? 0,
2639
+ converged: false
2640
+ }
2641
+ };
2642
+ });
2643
+ }
2644
+ async retry(expectedRevision, actor = "human") {
2645
+ const current = await this.store.loadState();
2646
+ if (!["blocked", "awaiting_human"].includes(current.status)) {
2647
+ throw new EmpiricalError("NOT_PAUSED", "The workflow is not blocked or awaiting human input");
2648
+ }
2649
+ const state = await this.store.transition(expectedRevision, actor, "Resumed workflow", (state2) => ({
2650
+ ...state2,
2651
+ status: "waiting",
2652
+ message: null
2653
+ }));
2654
+ const criteria = state.activeFeature ? parseCriteria(await this.store.readSpec(state.activeFeature)) : [];
2655
+ return this.packet(state, criteria);
2656
+ }
2657
+ async verify() {
2658
+ const state = await this.store.loadState(!this.readOnly);
2659
+ if (!state.activeFeature) {
2660
+ return { valid: false, phase: state.phase, criteria: 0, missing: ["No active feature"] };
2661
+ }
2662
+ const spec = await this.store.readSpec(state.activeFeature);
2663
+ const criteria = parseCriteria(spec);
2664
+ const config = await this.store.loadConfig();
2665
+ const missing = validateEvidence(criteria, state.evidence, config, state.phase === "review" || state.phase === "archive" || state.phase === "done");
2666
+ if (state.specDigest && state.specDigest !== digest(spec)) {
2667
+ missing.push("Specification changed after the last completed revision");
2668
+ }
2669
+ if (state.capabilityArchiveRequired && state.capabilityDeltaDigest) {
2670
+ try {
2671
+ if (await capabilityDeltaDigest(this.store, state.activeFeature) !== state.capabilityDeltaDigest) {
2672
+ missing.push("Capability deltas changed after Specify approval");
2673
+ }
2674
+ } catch {
2675
+ missing.push("Capability deltas are malformed or unreadable after Specify approval");
2676
+ }
2677
+ }
2678
+ for (const record of state.evidence) {
2679
+ if (record.kind === "screenshot" && record.passed && record.artifact && !await isFile(join5(this.store.root, record.artifact))) {
2680
+ missing.push(`Screenshot artifact does not exist: ${record.artifact}`);
2681
+ }
2682
+ }
2683
+ return { valid: missing.length === 0, phase: state.phase, criteria: criteria.length, missing };
2684
+ }
2685
+ async integrations() {
2686
+ return installProjectIntegrations(this.store.root);
2687
+ }
2688
+ async migrate() {
2689
+ const migration = await new ProjectStore(this.store.root).migrateSchema();
2690
+ return {
2691
+ ...migration,
2692
+ version: PRODUCT_VERSION,
2693
+ schemaVersion: SCHEMA_VERSION
2694
+ };
2695
+ }
2696
+ async doctor() {
2697
+ const state = await this.store.loadState(!this.readOnly);
2698
+ const config = await this.store.loadConfig();
2699
+ return {
2700
+ ok: true,
2701
+ version: PRODUCT_VERSION,
2702
+ schemaVersion: SCHEMA_VERSION,
2703
+ root: this.store.root,
2704
+ state,
2705
+ config,
2706
+ activeFeature: await new ProjectStore(this.store.root).activeFeature(!this.readOnly),
2707
+ policy: await this.store.loadPolicy(),
2708
+ capabilities: await this.capabilities(),
2709
+ runtime: "node",
2710
+ bunUsedForDevelopment: true,
2711
+ mcpCommand: "empirical mcp",
2712
+ canonicalStore: ".empirical"
2713
+ };
2714
+ }
2715
+ async validatePhasePass(state, input, criteria, config) {
2716
+ let approvedDeltaDigest = null;
2717
+ if ((state.phase === "shape" || state.phase === "specify") && criteria.length === 0) {
2718
+ throw new EmpiricalError("CRITERIA_REQUIRED", `Add at least one '- [ ] [AC-1] observable behavior' to ${relativeSpec(state.activeFeature)}`);
2719
+ }
2720
+ if (state.phase === "specify" && state.profile === "complex" && state.capabilityArchiveRequired) {
2721
+ const report = await validateFeatureDeltas(this.store, state.activeFeature);
2722
+ if (!report.valid) {
2723
+ throw new EmpiricalError("DELTA_REQUIRED", `Capability deltas are incomplete: ${report.issues.join("; ")}`);
2724
+ }
2725
+ approvedDeltaDigest = report.digest;
2726
+ }
2727
+ if (state.phase === "design") {
2728
+ await requireArtifact(this.store.specDirectory(state.activeFeature), "design.md");
2729
+ if (config.decisions.complexRecords === "required" && state.profile === "complex") {
2730
+ await requireValidDecisions(this.store, state.activeFeature);
2731
+ }
2732
+ }
2733
+ if (state.phase === "plan") {
2734
+ await requireArtifact(this.store.specDirectory(state.activeFeature), "plan.md");
2735
+ }
2736
+ if (state.phase === "verify") {
2737
+ const evidence = input.evidence ?? [];
2738
+ const missing = validateEvidence(criteria, evidence, config, false);
2739
+ if (missing.length > 0) {
2740
+ throw new EmpiricalError("EVIDENCE_REQUIRED", `Verification is incomplete: ${missing.join("; ")}`);
2741
+ }
2742
+ await validateEvidenceArtifacts(this.store.root, evidence);
2743
+ }
2744
+ if (state.phase === "review" && config.evidence.codeReview) {
2745
+ if (config.decisions.complexRecords === "required" && state.profile === "complex") {
2746
+ await requireValidDecisions(this.store, state.activeFeature);
2747
+ }
2748
+ const review = input.evidence?.some((record) => record.kind === "review" && record.passed);
2749
+ if (!review) {
2750
+ throw new EmpiricalError("REVIEW_REQUIRED", "Review completion needs passing review evidence");
2751
+ }
2752
+ }
2753
+ if (state.profile === "fast" && state.phase === "implement") {
2754
+ if (criteria.length === 0) {
2755
+ throw new EmpiricalError("CRITERIA_REQUIRED", `Add at least one '- [ ] [AC-1] observable behavior' to ${relativeSpec(state.activeFeature)}`);
2756
+ }
2757
+ const evidence = input.evidence ?? [];
2758
+ const missing = validateEvidence(criteria, evidence, config, true);
2759
+ if (missing.length > 0) {
2760
+ throw new EmpiricalError("EVIDENCE_REQUIRED", `Fast completion is incomplete: ${missing.join("; ")}`);
2761
+ }
2762
+ await validateEvidenceArtifacts(this.store.root, evidence);
2763
+ }
2764
+ return approvedDeltaDigest;
2765
+ }
2766
+ async assertCapabilityDeltasUnchanged(state) {
2767
+ if (!state.capabilityArchiveRequired || !state.capabilityDeltaDigest || !state.activeFeature)
2768
+ return;
2769
+ try {
2770
+ if (await capabilityDeltaDigest(this.store, state.activeFeature) === state.capabilityDeltaDigest)
2771
+ return;
2772
+ } catch {}
2773
+ throw new EmpiricalError("DELTA_CHANGED", "Capability deltas changed after Specify approval; restore the approved deltas before continuing");
2774
+ }
2775
+ async packet(state, criteria) {
2776
+ const policy = await this.store.loadPolicy();
2777
+ const config = await this.store.loadConfig();
2778
+ const capabilities = await listCapabilities(this.store);
2779
+ const artifacts = expectedArtifacts(state, config.decisions.complexRecords === "required");
2780
+ const missingArtifacts = [];
2781
+ for (const artifact of artifacts) {
2782
+ if (artifact.includes("deltas/<capability>.md") && state.activeFeature) {
2783
+ if (!(await validateFeatureDeltas(this.store, state.activeFeature)).valid)
2784
+ missingArtifacts.push(artifact);
2785
+ } else if (artifact.endsWith("/decisions.md") && state.activeFeature) {
2786
+ if (!(await validateDecisions(this.store, state.activeFeature, state.phase === "design" || state.phase === "review")).valid) {
2787
+ missingArtifacts.push(artifact);
2788
+ }
2789
+ } else if (artifact.includes("<capability>")) {
2790
+ missingArtifacts.push(artifact);
2791
+ } else if (!await isFile(join5(this.store.root, artifact))) {
2792
+ missingArtifacts.push(artifact);
2793
+ }
2794
+ }
2795
+ return actionPacket(this.store.root, state, criteria, policy, capabilities.map((capability) => capability.path), artifacts, missingArtifacts);
2796
+ }
2797
+ }
2798
+ function parseCriteria(markdown) {
2799
+ const criteria = [];
2800
+ let inComment = false;
2801
+ let activeCriterion = null;
2802
+ for (const line of markdown.split(/\r?\n/)) {
2803
+ if (line.includes("<!--")) {
2804
+ inComment = true;
2805
+ activeCriterion = null;
2806
+ }
2807
+ if (inComment) {
2808
+ if (line.includes("-->"))
2809
+ inComment = false;
2810
+ continue;
2811
+ }
2812
+ const match = /^\s*-\s*\[([ xX])\]\s*\[([^\]]+)\]\s*(.+?)\s*$/.exec(line);
2813
+ if (match?.[2] && match[3]) {
2814
+ const id = match[2].trim();
2815
+ const text = match[3].trim();
2816
+ activeCriterion = {
2817
+ id,
2818
+ text,
2819
+ ui: /\[UI\]/i.test(text),
2820
+ checked: match[1]?.toLowerCase() === "x"
2821
+ };
2822
+ criteria.push(activeCriterion);
2823
+ continue;
2824
+ }
2825
+ if (activeCriterion && /^\s{2,}\S/.test(line)) {
2826
+ activeCriterion.text = `${activeCriterion.text} ${line.trim()}`;
2827
+ activeCriterion.ui = /\[UI\]/i.test(activeCriterion.text);
2828
+ continue;
2829
+ }
2830
+ activeCriterion = null;
2831
+ }
2832
+ return criteria;
2833
+ }
2834
+ function defaultConfig(profile, legacySource, options = {}) {
2835
+ return {
2836
+ schemaVersion: SCHEMA_VERSION,
2837
+ profile,
2838
+ maxRepairAttempts: 2,
2839
+ evidence: {
2840
+ required: true,
2841
+ browserForUi: true,
2842
+ screenshotForUi: true,
2843
+ codeReview: true
2844
+ },
2845
+ isolation: {
2846
+ mode: options.isolation?.mode ?? "ask",
2847
+ baseBranch: options.isolation?.baseBranch ?? "auto",
2848
+ worktreePath: options.isolation?.worktreePath ?? "../{repo}-{feature}",
2849
+ branchPattern: options.isolation?.branchPattern ?? "{type}/{feature}"
2850
+ },
2851
+ decisions: {
2852
+ complexRecords: options.decisions?.complexRecords ?? "required"
2853
+ },
2854
+ setupComplete: options.setupComplete ?? true,
2855
+ legacySource
2856
+ };
2857
+ }
2858
+ function initialState(profile) {
2859
+ return {
2860
+ schemaVersion: SCHEMA_VERSION,
2861
+ revision: 0,
2862
+ activeFeature: null,
2863
+ request: null,
2864
+ profile,
2865
+ phase: "idle",
2866
+ status: "idle",
2867
+ repairAttempts: 0,
2868
+ message: null,
2869
+ implementationActor: null,
2870
+ specDigest: null,
2871
+ capabilityArchiveRequired: false,
2872
+ capabilityDeltaDigest: null,
2873
+ evidence: [],
2874
+ updatedAt: new Date().toISOString()
2875
+ };
2876
+ }
2877
+ function renderSpec(title2, request) {
2878
+ return `# ${title2}
2879
+
2880
+ ## Request
2881
+
2882
+ ${renderRequest(request)}
2883
+
2884
+ ## Goal
2885
+
2886
+ Describe the observable result.
2887
+
2888
+ ## Acceptance Criteria
2889
+
2890
+ <!-- Replace this comment with observable criteria such as:
2891
+ - [ ] [AC-1] The user can complete the intended action.
2892
+ - [ ] [AC-UI-1] [UI] The result is visible in the browser.
2893
+ -->
2894
+
2895
+ ## Scope
2896
+
2897
+ ## Non-goals
2898
+
2899
+ ## Verification
2900
+
2901
+ ## Capability Deltas
2902
+
2903
+ Create one or more files under deltas/<capability>.md using ADDED, MODIFIED, or
2904
+ REMOVED Requirements sections, named Requirement blocks, and concrete Scenario
2905
+ examples. These merge into living specifications
2906
+ after verification and review.
2907
+ `;
2908
+ }
2909
+ function renderFastSpec(title2, request) {
2910
+ const criterion = request.replace(/<!--/g, "&lt;!--").replace(/-->/g, "--&gt;").replace(/\s+/g, " ").trim();
2911
+ return `# ${title2}
2912
+
2913
+ ## Request
2914
+
2915
+ ${renderRequest(request)}
2916
+
2917
+ ## Goal
2918
+
2919
+ ${criterion}
2920
+
2921
+ ## Acceptance Criteria
2922
+
2923
+ - [ ] [AC-1] ${criterion}
2924
+
2925
+ ## Scope
2926
+
2927
+ Small, localized, and reversible changes required by the request.
2928
+
2929
+ ## Non-goals
2930
+
2931
+ Unrequested behavior or broader architectural changes.
2932
+
2933
+ ## Verification
2934
+
2935
+ Run the smallest real check that proves AC-1 and inspect the resulting diff.
2936
+ `;
2937
+ }
2938
+ function renderRequest(request) {
2939
+ return request.replace(/<!--/g, "&lt;!--").replace(/-->/g, "--&gt;").split(/\r?\n/).map((line) => `> ${line}`).join(`
2940
+ `);
2941
+ }
2942
+ function actionPacket(root, state, criteria, policy, capabilityContext, artifacts, missingArtifacts) {
2943
+ const evidence = requiredEvidence(state, criteria);
2944
+ const completionAvailable = state.status === "waiting" && state.phase !== "idle" && state.phase !== "done";
2945
+ const fastCliEvidence = state.profile === "fast" && evidence.length === 2 && evidence.includes("test") && evidence.includes("review") ? ' --test "<test result>" --review "<diff review>"' : null;
2946
+ const archive = state.phase === "archive";
2947
+ return {
2948
+ kind: "action",
2949
+ protocol: "empirical-sdd",
2950
+ schemaVersion: SCHEMA_VERSION,
2951
+ root,
2952
+ feature: state.activeFeature,
2953
+ request: state.request,
2954
+ profile: state.profile,
2955
+ phase: state.phase,
2956
+ status: state.status,
2957
+ revision: state.revision,
2958
+ instructions: instructionsFor(state, policy),
2959
+ rationale: rationaleFor(state, artifacts, missingArtifacts, evidence),
2960
+ acceptanceCriteria: criteria,
2961
+ requiredEvidence: evidence,
2962
+ artifacts,
2963
+ projectContext: policy.context,
2964
+ capabilityContext,
2965
+ completion: {
2966
+ available: completionAvailable,
2967
+ mcpTool: archive ? "empirical_archive" : "empirical_complete",
2968
+ cli: completionAvailable ? archive ? `empirical archive --revision ${state.revision}` : `empirical complete --revision ${state.revision} --outcome passed --summary "<what you did>"${fastCliEvidence ?? (evidence.length > 0 ? " --evidence <evidence.json>" : "")}` : "",
2969
+ requiredFields: completionAvailable ? archive ? ["revision"] : ["revision", "outcome", "summary", ...evidence.length > 0 ? ["evidence"] : []] : []
2970
+ }
2971
+ };
2972
+ }
2973
+ function rationaleFor(state, artifacts, missingArtifacts, evidence) {
2974
+ const currentState = `${state.phase}/${state.status} at revision ${state.revision}`;
2975
+ const nextAction = state.status === "blocked" || state.status === "awaiting_human" ? "Resolve the stated gate, then retry the exact revision" : state.phase === "idle" ? "Start an approved Fast or Complex feature" : state.phase === "done" ? "Report completion" : state.phase === "archive" ? "Archive the reviewed capability deltas" : `Complete ${state.phase} at revision ${state.revision}`;
2976
+ const reason = state.phase === "idle" ? "No non-terminal feature state exists in this checkout." : state.phase === "done" ? "All workflow gates have completed." : state.status === "blocked" || state.status === "awaiting_human" ? state.message ?? "The workflow state machine has an unresolved stop condition." : `The ${state.profile} state machine advances from ${state.phase} only after its artifacts and evidence pass.`;
2977
+ return {
2978
+ currentState,
2979
+ nextAction,
2980
+ reason,
2981
+ requiredContext: [...artifacts, ...evidence.map((kind) => `${kind} evidence`)],
2982
+ missingContext: [...missingArtifacts, ...evidence.map((kind) => `${kind} evidence`)],
2983
+ gate: state.status === "blocked" || state.status === "awaiting_human" ? "stop" : "proceed"
2984
+ };
2985
+ }
2986
+ function assertStartAction(action, request, profile, options) {
2987
+ if (action.request?.trim() !== request) {
2988
+ throw new EmpiricalError("FEATURE_ACTIVE", action.feature ? `Feature ${action.feature} belongs to a different request` : "The requested feature is no longer active");
2989
+ }
2990
+ if (profile !== action.profile) {
2991
+ throw new EmpiricalError("PROFILE_CONFLICT", `The feature uses profile ${action.profile}, not ${profile}`);
2992
+ }
2993
+ if (options.id && options.id !== action.feature) {
2994
+ throw new EmpiricalError("FEATURE_ACTIVE", `The active feature is ${action.feature ?? "none"}, not ${options.id}`);
2995
+ }
2996
+ return action;
2997
+ }
2998
+ function instructionsFor(state, policy) {
2999
+ if (state.status === "blocked")
3000
+ return appendPolicy(`Stop. Resolve this blocker before retrying: ${state.message ?? "unknown"}`, state, policy);
3001
+ if (state.status === "awaiting_human")
3002
+ return appendPolicy(`Stop and ask the user: ${state.message ?? "a decision is required"}`, state, policy);
3003
+ if (state.phase === "idle")
3004
+ return appendPolicy("No feature is active. Explore genuinely vague work first; otherwise start it with empirical_fast or empirical_complex. Use empirical loop only to resume current work.", state, policy);
3005
+ if (state.phase === "done")
3006
+ return appendPolicy("The feature passed verification, review, and required capability archive. Report completion; delivery is manual.", state, policy);
3007
+ const feature = state.activeFeature ?? "current feature";
3008
+ const instructions = {
3009
+ shape: `Read the request, edit ${relativeSpec(feature)}, and define concise observable acceptance criteria. Do not implement yet.`,
3010
+ specify: `Refine ${relativeSpec(feature)} into a complete contract with observable acceptance criteria, scope, non-goals, risks, and verification. Declare current-behavior changes in .empirical/specs/${feature}/deltas/<capability>.md using ADDED, MODIFIED, or REMOVED requirement blocks with scenarios.`,
3011
+ design: `Design the solution in .empirical/specs/${feature}/design.md and maintain .empirical/specs/${feature}/decisions.md with accepted evidence, options, the chosen approach, trade-offs/risks, and verification. Record concise reviewable decisions, never private chain-of-thought.`,
3012
+ plan: `Break the approved design into an executable plan in .empirical/specs/${feature}/plan.md.`,
3013
+ implement: state.profile === "fast" ? "Fast lane: the packet already contains the complete generated criterion. Inspect only the relevant project files, implement in one focused pass, combine the smallest real test and diff review when practical, then run the returned completion command. Do not reread Empirical state or add redundant checks. If the work is no longer small and low-risk, report failure so Empirical can escalate it." : "Implement the current acceptance criteria. Preserve unrelated work and run focused checks while editing.",
3014
+ verify: "Run real tests for every criterion. For [UI] criteria, use a real browser and capture a screenshot. Return structured evidence.",
3015
+ review: `Review the implementation against every criterion, the diff, and accepted decisions in .empirical/specs/${feature}/decisions.md. Contradictions require an explicit accepted superseding entry. Return passing review evidence or route failures back to implementation.`,
3016
+ archive: "Apply the reviewed capability deltas to the living specifications with the returned empirical_archive operation. Do not edit capability specs manually during archive."
3017
+ };
3018
+ return appendPolicy(instructions[state.phase], state, policy);
3019
+ }
3020
+ function appendPolicy(base, state, policy) {
3021
+ const sections = [base];
3022
+ if (policy.context.length > 0)
3023
+ sections.push(`Project context:
3024
+ - ${policy.context.join(`
3025
+ - `)}`);
3026
+ const phaseGuidance = policy.phases[state.phase] ?? [];
3027
+ if (phaseGuidance.length > 0) {
3028
+ sections.push(`Additional project guidance (mandatory Empirical gates still apply):
3029
+ - ${phaseGuidance.join(`
3030
+ - `)}`);
3031
+ }
3032
+ return sections.join(`
3033
+
3034
+ `);
3035
+ }
3036
+ function expectedArtifacts(state, decisionsRequired) {
3037
+ if (!state.activeFeature)
3038
+ return [];
3039
+ const base = `.empirical/specs/${state.activeFeature}`;
3040
+ if (state.phase === "shape")
3041
+ return [`${base}/spec.md`];
3042
+ if (state.phase === "specify")
3043
+ return [`${base}/spec.md`, `${base}/deltas/<capability>.md`];
3044
+ if (state.phase === "design")
3045
+ return [`${base}/design.md`, ...decisionsRequired ? [`${base}/decisions.md`] : []];
3046
+ if (state.phase === "plan")
3047
+ return [`${base}/plan.md`];
3048
+ if (state.phase === "review")
3049
+ return decisionsRequired ? [`${base}/decisions.md`] : [];
3050
+ if (state.phase === "archive")
3051
+ return [".empirical/capabilities/<capability>/spec.md"];
3052
+ return [];
3053
+ }
3054
+ function requiredEvidence(state, criteria) {
3055
+ const fast = state.profile === "fast" && state.phase === "implement";
3056
+ if (state.phase !== "verify" && state.phase !== "review" && !fast)
3057
+ return [];
3058
+ const kinds = new Set;
3059
+ if (state.phase === "verify" || fast)
3060
+ kinds.add("test");
3061
+ if ((state.phase === "verify" || fast) && criteria.some((criterion) => criterion.ui)) {
3062
+ kinds.add("browser");
3063
+ kinds.add("screenshot");
3064
+ }
3065
+ if (state.phase === "review" || fast)
3066
+ kinds.add("review");
3067
+ return [...kinds];
3068
+ }
3069
+ function validateEvidence(criteria, evidence, config, includeReview) {
3070
+ if (!config.evidence.required)
3071
+ return [];
3072
+ const missing = [];
3073
+ if (criteria.length === 0)
3074
+ missing.push("No acceptance criteria are defined");
3075
+ for (const criterion of criteria) {
3076
+ const records = evidence.filter((record) => record.criterionId === criterion.id && record.passed);
3077
+ if (!records.some((record) => record.kind === "test")) {
3078
+ missing.push(`${criterion.id} has no passing test evidence`);
3079
+ }
3080
+ if (criterion.ui && config.evidence.browserForUi && !records.some((record) => record.kind === "browser")) {
3081
+ missing.push(`${criterion.id} has no browser evidence`);
3082
+ }
3083
+ if (criterion.ui && config.evidence.screenshotForUi && !records.some((record) => record.kind === "screenshot" && record.artifact)) {
3084
+ missing.push(`${criterion.id} has no screenshot artifact`);
3085
+ }
3086
+ }
3087
+ if (includeReview && config.evidence.codeReview && !evidence.some((record) => record.kind === "review" && record.passed)) {
3088
+ missing.push("No passing code review evidence");
3089
+ }
3090
+ return missing;
3091
+ }
3092
+ function routeFailure(state, summary, maxRepairAttempts) {
3093
+ state.message = summary;
3094
+ if (state.profile === "fast" && state.phase === "implement") {
3095
+ state.profile = "complex";
3096
+ state.phase = "specify";
3097
+ state.capabilityArchiveRequired = true;
3098
+ state.capabilityDeltaDigest = null;
3099
+ state.repairAttempts = 0;
3100
+ state.evidence = [];
3101
+ state.status = "waiting";
3102
+ return state;
3103
+ }
3104
+ if (state.phase === "verify" || state.phase === "review") {
3105
+ state.repairAttempts += 1;
3106
+ state.evidence = [];
3107
+ if (state.repairAttempts > maxRepairAttempts) {
3108
+ state.status = "blocked";
3109
+ return state;
3110
+ }
3111
+ state.phase = "implement";
3112
+ state.status = "waiting";
3113
+ return state;
3114
+ }
3115
+ state.status = "waiting";
3116
+ return state;
3117
+ }
3118
+ function firstPhase(profile) {
3119
+ if (profile === "fast")
3120
+ return "implement";
3121
+ return profile === "quick" ? "shape" : "specify";
3122
+ }
3123
+ function followingPhase(profile, phase) {
3124
+ const sequence = profile === "fast" ? FAST_PHASES : profile === "quick" ? QUICK_PHASES : COMPLEX_PHASES;
3125
+ const index = sequence.indexOf(phase);
3126
+ if (index < 0)
3127
+ throw new EmpiricalError("INVALID_PHASE", `Phase ${phase} is not valid for ${profile}`);
3128
+ return sequence[index + 1] ?? "done";
3129
+ }
3130
+ function mapLegacyPhase(value, profile) {
3131
+ const phase = value?.toLowerCase() ?? "";
3132
+ if (/done|complete|ready/.test(phase))
3133
+ return "done";
3134
+ if (profile === "fast")
3135
+ return "implement";
3136
+ if (/review/.test(phase))
3137
+ return "review";
3138
+ if (/test|verify|qa/.test(phase))
3139
+ return "verify";
3140
+ if (/develop|implement|dev/.test(phase))
3141
+ return "implement";
3142
+ if (profile === "quick")
3143
+ return "shape";
3144
+ if (/plan/.test(phase))
3145
+ return "plan";
3146
+ if (/architect|design/.test(phase))
3147
+ return "design";
3148
+ return "specify";
3149
+ }
3150
+ function legacyField(contents, field) {
3151
+ const match = new RegExp(`^\\s*${field}\\s*:\\s*([^#\\r\\n]+)`, "im").exec(contents);
3152
+ const value = match?.[1]?.trim();
3153
+ if (!value || /<none|none|null/i.test(value))
3154
+ return null;
3155
+ return value.replace(/^['"]|['"]$/g, "");
3156
+ }
3157
+ function titleFromFeature(feature) {
3158
+ const withoutNumber = feature.replace(/^\d+-/, "");
3159
+ return withoutNumber.split(/[-_]/).filter(Boolean).map((word) => `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`).join(" ") || basename2(feature);
3160
+ }
3161
+ function relativeSpec(feature) {
3162
+ return `.empirical/specs/${feature ?? "<feature>"}/spec.md`;
3163
+ }
3164
+ async function requireArtifact(directory, name) {
3165
+ const path = join5(directory, name);
3166
+ if (!await isFile(path) || (await readFile5(path, "utf8")).trim().length === 0) {
3167
+ throw new EmpiricalError("ARTIFACT_REQUIRED", `Create the non-empty artifact ${path}`);
3168
+ }
3169
+ }
3170
+ async function validateEvidenceArtifacts(root, evidence) {
3171
+ for (const record of evidence) {
3172
+ if (record.kind === "screenshot" && record.passed && record.artifact && !await isFile(join5(root, record.artifact))) {
3173
+ throw new EmpiricalError("EVIDENCE_REQUIRED", `Screenshot artifact does not exist: ${record.artifact}`);
3174
+ }
3175
+ }
3176
+ }
3177
+ function digest(contents) {
3178
+ return createHash3("sha256").update(contents).digest("hex");
3179
+ }
3180
+ function emptyIntegrationReport() {
3181
+ return { scope: "project", created: [], updated: [], preserved: [], entrypoints: [] };
3182
+ }
3183
+ function assertWorkflow(profile) {
3184
+ if (profile !== "fast" && profile !== "complex") {
3185
+ throw new EmpiricalError("INVALID_PROFILE", `Workflow must be fast or complex, not '${profile}'`);
3186
+ }
3187
+ }
3188
+ function assertCompletionInput(input) {
3189
+ if (!Number.isSafeInteger(input.revision) || input.revision < 0) {
3190
+ throw new EmpiricalError("INVALID_REVISION", "Completion revision must be a non-negative integer");
3191
+ }
3192
+ if (!"passed failed awaiting_human blocked".split(" ").includes(input.outcome)) {
3193
+ throw new EmpiricalError("INVALID_OUTCOME", `Unsupported completion outcome '${String(input.outcome)}'`);
3194
+ }
3195
+ for (const record of input.evidence ?? []) {
3196
+ if (!record.criterionId?.trim() || !record.summary?.trim() || typeof record.passed !== "boolean") {
3197
+ throw new EmpiricalError("INVALID_EVIDENCE", "Evidence needs criterionId, summary, kind, and passed");
3198
+ }
3199
+ if (!"test browser screenshot review human".split(" ").includes(record.kind)) {
3200
+ throw new EmpiricalError("INVALID_EVIDENCE", `Unsupported evidence kind '${String(record.kind)}'`);
3201
+ }
3202
+ if (record.artifact && (record.artifact.startsWith("/") || /^[A-Za-z]:[\\/]/.test(record.artifact) || record.artifact.split(/[\\/]/).includes(".."))) {
3203
+ throw new EmpiricalError("INVALID_EVIDENCE", "Evidence artifact paths must stay inside the repository");
3204
+ }
3205
+ }
3206
+ }
3207
+ export {
3208
+ validateFeatureDeltas,
3209
+ planCapabilityArchive,
3210
+ parseCriteria,
3211
+ parseCapabilityDelta,
3212
+ loadCapabilityDeltas,
3213
+ listCapabilities,
3214
+ installProjectIntegrations,
3215
+ installGlobalAgentSkills,
3216
+ discoverProject,
3217
+ capabilityDeltaDigest,
3218
+ SCHEMA_VERSION,
3219
+ ProjectStore,
3220
+ PRODUCT_VERSION,
3221
+ POLICY_SCHEMA_VERSION,
3222
+ EmpiricalProject,
3223
+ EmpiricalError
3224
+ };