@thebackstoryis/engineering-with-ai 0.2.9 → 0.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Docs/README.md +2 -0
- package/Docs/autonomous-intent-delivery.md +37 -0
- package/Docs/guide-catalogue.md +2 -0
- package/Docs/quality/persona-driven-test-scenarios.md +6 -0
- package/README.md +17 -0
- package/config/autonomy-worker.schema.json +70 -0
- package/config/autonomy.schema.json +25 -0
- package/package.json +3 -3
- package/public/app.js +7 -0
- package/public/autonomy.js +470 -0
- package/public/index.html +26 -0
- package/public/styles.css +64 -0
- package/skills-src/ewai-autonomy/SKILL.md +18 -0
- package/skills-src/ewai-autonomy/agents/openai.yaml +4 -0
- package/skills-src/ewai-deliver/SKILL.md +2 -0
- package/skills-src/ewai-test-scenarios/references/scenario-contract.md +6 -0
- package/src/autonomy-phase-contracts.mjs +88 -0
- package/src/autonomy-worker.mjs +15 -0
- package/src/autonomy.mjs +199 -0
- package/src/checkin.mjs +30 -6
- package/src/cli.mjs +67 -0
- package/src/delivery-gates.mjs +8 -1
- package/src/delivery.mjs +74 -3
- package/src/design-system-application.mjs +28 -0
- package/src/runtime/afk-conductor.mjs +191 -71
- package/src/runtime/autonomy-operations.mjs +250 -0
- package/src/runtime/autonomy-supervisor.mjs +426 -0
- package/src/runtime/autonomy-workers.mjs +333 -0
- package/src/runtime/autonomy-workspace.mjs +310 -0
- package/src/runtime/dashboard-actions.mjs +69 -0
- package/src/runtime/dashboard-server.mjs +25 -0
- package/src/runtime/database.mjs +25 -6
- package/src/runtime/intent-ownership.mjs +272 -0
- package/src/runtime/mcp-server.mjs +38 -0
- package/src/runtime/provider-adapters.mjs +365 -7
- package/src/runtime/work.mjs +21 -5
- package/src/test-scenarios.mjs +173 -20
package/src/test-scenarios.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
|
+
linkSync,
|
|
5
|
+
lstatSync,
|
|
4
6
|
mkdirSync,
|
|
5
7
|
readdirSync,
|
|
6
8
|
readFileSync,
|
|
9
|
+
realpathSync,
|
|
7
10
|
renameSync,
|
|
8
11
|
rmSync,
|
|
9
12
|
writeFileSync
|
|
@@ -17,6 +20,9 @@ import { selectContextualPersonas } from './runtime/persona-engagement.mjs';
|
|
|
17
20
|
const BRIEF_SCHEMA = 'ewai.persona-test-brief/v1';
|
|
18
21
|
const SCENARIO_SCHEMA = 'ewai.persona-test-scenarios/v1';
|
|
19
22
|
const WORKSPACE_SCHEMA = 'ewai.persona-test-workspace/v1';
|
|
23
|
+
const SOURCE_DIGEST_VERSION = 'intent-content-v2';
|
|
24
|
+
const BASELINE_SCHEMA = 'ewai.scenario-source-baseline/v1';
|
|
25
|
+
const lifecycleMetadata = new Set(['status', 'delivery_status', 'current_phase', 'delivery_state_path', 'updated_at']);
|
|
20
26
|
const scenarioIdPattern = /^PTS-[0-9]{3}$/;
|
|
21
27
|
const sourceIdPattern = /^[a-z0-9][a-z0-9:._-]{2,159}$/i;
|
|
22
28
|
const allowedTypes = new Set([
|
|
@@ -54,6 +60,22 @@ function canonicalJson(value) {
|
|
|
54
60
|
return JSON.stringify(canonical(value));
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
function assertFingerprintMetadata(value, ancestors = new Set()) {
|
|
64
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
65
|
+
if (typeof value === 'number') {
|
|
66
|
+
if (!Number.isFinite(value)) throw new Error('Intent metadata contains a non-finite number');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (typeof value !== 'object' || (!Array.isArray(value)
|
|
70
|
+
&& Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
|
|
71
|
+
throw new Error('Intent metadata contains an unsupported value; use plain mappings, arrays and JSON scalar values');
|
|
72
|
+
}
|
|
73
|
+
if (ancestors.has(value)) throw new Error('Intent metadata contains a cyclic value');
|
|
74
|
+
ancestors.add(value);
|
|
75
|
+
for (const child of Object.values(value)) assertFingerprintMetadata(child, ancestors);
|
|
76
|
+
ancestors.delete(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
function relativePath(root, path) {
|
|
58
80
|
return relative(resolve(root), resolve(path)).replaceAll('\\', '/');
|
|
59
81
|
}
|
|
@@ -260,13 +282,33 @@ function briefDigest(sources, contextualEvidence) {
|
|
|
260
282
|
}));
|
|
261
283
|
}
|
|
262
284
|
|
|
263
|
-
|
|
285
|
+
function prepareBrief(projectRoot, slug, options = {}, sourceDigestVersion = SOURCE_DIGEST_VERSION) {
|
|
264
286
|
const root = resolve(projectRoot);
|
|
265
287
|
const focus = cleanText(options.focus, 'Focus', MAX_FOCUS, false);
|
|
266
288
|
const intent = findIntent(root, cleanText(slug, 'Intent slug', 100));
|
|
267
289
|
const paths = deliveryPaths(root, slug);
|
|
290
|
+
const intentEvidence = intentSources(root, intent);
|
|
291
|
+
// A planning source alone cannot carry the complete intent fingerprint.
|
|
292
|
+
if (!intentEvidence.length) {
|
|
293
|
+
throw new Error('A recognised intent source is required: capture Problem, Desired outcome, Constraints, or a listed Journey or Acceptance criterion');
|
|
294
|
+
}
|
|
295
|
+
const rawIntentDigest = sha256(intent.content);
|
|
296
|
+
// Both policies describe the same captured intent bytes, even if a later read changes.
|
|
297
|
+
for (const source of intentEvidence) source.digest = rawIntentDigest;
|
|
298
|
+
if (sourceDigestVersion === SOURCE_DIGEST_VERSION) {
|
|
299
|
+
if (!intent.metadata || typeof intent.metadata !== 'object' || Array.isArray(intent.metadata)) {
|
|
300
|
+
throw new Error('Intent metadata must be a mapping');
|
|
301
|
+
}
|
|
302
|
+
const metadata = Object.fromEntries(Object.entries(intent.metadata).filter(([key]) => !lifecycleMetadata.has(key)));
|
|
303
|
+
// Reject values canonical() cannot represent faithfully before it can erase their type/content.
|
|
304
|
+
assertFingerprintMetadata(metadata);
|
|
305
|
+
// Cover the complete body and every other metadata value, never the bounded excerpts.
|
|
306
|
+
const content = canonicalJson({ metadata, body: intent.body });
|
|
307
|
+
const digest = sha256(content);
|
|
308
|
+
for (const source of intentEvidence) source.digest = digest;
|
|
309
|
+
}
|
|
268
310
|
const authoritativeSources = [
|
|
269
|
-
...
|
|
311
|
+
...intentEvidence,
|
|
270
312
|
...planSources(root, paths.deliveryRoot),
|
|
271
313
|
...standardSources(root, intent.specsRoot)
|
|
272
314
|
];
|
|
@@ -283,11 +325,15 @@ export function preparePersonaTestScenarioBrief(projectRoot, slug, options = {})
|
|
|
283
325
|
limit: 4
|
|
284
326
|
});
|
|
285
327
|
const sourceDigest = briefDigest(authoritativeSources, contextualEvidence);
|
|
328
|
+
const legacySourceDigest = briefDigest(authoritativeSources.map((source) => source.kind.startsWith('intent-')
|
|
329
|
+
? { ...source, digest: rawIntentDigest } : source), contextualEvidence);
|
|
286
330
|
return {
|
|
287
331
|
schema: BRIEF_SCHEMA,
|
|
288
332
|
slug,
|
|
289
333
|
focus,
|
|
290
334
|
sourceDigest,
|
|
335
|
+
legacySourceDigest,
|
|
336
|
+
...(sourceDigestVersion ? { sourceDigestVersion } : {}),
|
|
291
337
|
authoritativeSources,
|
|
292
338
|
contextualEvidence,
|
|
293
339
|
activePersonas,
|
|
@@ -307,6 +353,11 @@ export function preparePersonaTestScenarioBrief(projectRoot, slug, options = {})
|
|
|
307
353
|
};
|
|
308
354
|
}
|
|
309
355
|
|
|
356
|
+
export function preparePersonaTestScenarioBrief(projectRoot, slug, options = {}) {
|
|
357
|
+
const { legacySourceDigest: _legacySourceDigest, ...brief } = prepareBrief(projectRoot, slug, options);
|
|
358
|
+
return brief;
|
|
359
|
+
}
|
|
360
|
+
|
|
310
361
|
function textList(value, label, { required = true } = {}) {
|
|
311
362
|
if (!Array.isArray(value) || (required && !value.length) || value.length > MAX_LIST_ITEMS) {
|
|
312
363
|
throw new Error(`${label} must contain ${required ? 'one to ' : 'no more than '}${MAX_LIST_ITEMS} items`);
|
|
@@ -394,6 +445,7 @@ function scenarioPayload(slug, brief, input, reviewer) {
|
|
|
394
445
|
schema: SCENARIO_SCHEMA,
|
|
395
446
|
slug,
|
|
396
447
|
preparedSourceDigest: brief.sourceDigest,
|
|
448
|
+
...(brief.sourceDigestVersion ? { sourceDigestVersion: brief.sourceDigestVersion } : {}),
|
|
397
449
|
reviewer,
|
|
398
450
|
focus: brief.focus,
|
|
399
451
|
sources: brief.authoritativeSources.map(({ id, kind, label, path, digest, authority }) => ({ id, kind, label, path, digest, authority })),
|
|
@@ -510,26 +562,126 @@ function writePair(projectRoot, slug, jsonContent, markdownContent, options = {}
|
|
|
510
562
|
return { jsonPath, markdownPath };
|
|
511
563
|
}
|
|
512
564
|
|
|
565
|
+
function evidenceFile(root, path, label, maximum = Infinity) {
|
|
566
|
+
let stat;
|
|
567
|
+
try { stat = lstatSync(path); } catch (error) {
|
|
568
|
+
if (error.code === 'ENOENT') return false;
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !isWithin(realpathSync(root), realpathSync(path)) || stat.size > maximum) {
|
|
572
|
+
throw new Error(`${label} must be a bounded regular file inside the project`);
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function readRecordedPair(root, paths, slug) {
|
|
578
|
+
const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
|
|
579
|
+
const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
|
|
580
|
+
const hasJson = evidenceFile(root, jsonPath, 'Scenario JSON');
|
|
581
|
+
const hasMarkdown = evidenceFile(root, markdownPath, 'Scenario Markdown');
|
|
582
|
+
if (!hasJson && !hasMarkdown) return null;
|
|
583
|
+
if (!hasJson || !hasMarkdown) throw new Error('Existing persona test-scenario evidence is incomplete and will not be overwritten');
|
|
584
|
+
const record = readJson(jsonPath, 'Persona test-scenario evidence');
|
|
585
|
+
if (record.schema !== SCENARIO_SCHEMA || record.slug !== slug) throw new Error('Persona test-scenario JSON schema or slug is invalid');
|
|
586
|
+
if (record.sourceDigestVersion !== undefined && record.sourceDigestVersion !== SOURCE_DIGEST_VERSION) {
|
|
587
|
+
throw new Error('Unsupported persona test-scenario source digest version');
|
|
588
|
+
}
|
|
589
|
+
if (sha256(canonicalJson(recordDigestPayload(record))) !== record.contentDigest) throw new Error('Persona test-scenario content digest does not match');
|
|
590
|
+
if (sha256(readFileSync(markdownPath, 'utf8')) !== record.markdownDigest) throw new Error('Persona test-scenario Markdown digest does not match');
|
|
591
|
+
return record;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function readSourceBaseline(root, paths, record) {
|
|
595
|
+
const path = resolve(paths.deliveryRoot, 'test-scenarios.source-baseline.json');
|
|
596
|
+
if (!evidenceFile(root, path, 'Scenario source baseline', 8192)) return null;
|
|
597
|
+
const baseline = readJson(path, 'Scenario source baseline');
|
|
598
|
+
const { digest, ...payload } = baseline;
|
|
599
|
+
const keys = ['schema', 'slug', 'recordDigest', 'legacySourceDigest', 'sourceDigestVersion', 'sourceDigest', 'reviewedBy', 'recordedAt', 'digest'];
|
|
600
|
+
if (Object.keys(baseline).sort().join() !== keys.sort().join()
|
|
601
|
+
|| baseline.schema !== BASELINE_SCHEMA || baseline.slug !== record.slug
|
|
602
|
+
|| record.sourceDigestVersion !== undefined
|
|
603
|
+
|| baseline.recordDigest !== record.contentDigest || baseline.legacySourceDigest !== record.preparedSourceDigest
|
|
604
|
+
|| baseline.sourceDigestVersion !== SOURCE_DIGEST_VERSION || !/^[a-f0-9]{64}$/.test(baseline.sourceDigest)
|
|
605
|
+
|| baseline.reviewedBy !== record.reviewer || typeof baseline.recordedAt !== 'string' || !Number.isFinite(Date.parse(baseline.recordedAt))
|
|
606
|
+
|| digest !== sha256(canonicalJson(payload))) {
|
|
607
|
+
throw new Error('Scenario source baseline is invalid or bound to different accepted evidence');
|
|
608
|
+
}
|
|
609
|
+
return baseline;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function writeSourceBaseline(root, paths, record, brief) {
|
|
613
|
+
const path = resolve(paths.deliveryRoot, 'test-scenarios.source-baseline.json');
|
|
614
|
+
const existing = readSourceBaseline(root, paths, record);
|
|
615
|
+
if (existing) {
|
|
616
|
+
if (existing.sourceDigest !== brief.sourceDigest) throw new Error('Scenario source baseline is stale');
|
|
617
|
+
return existing;
|
|
618
|
+
}
|
|
619
|
+
if (!isWithin(realpathSync(root), realpathSync(paths.deliveryRoot))) throw new Error('Scenario source baseline is outside the project');
|
|
620
|
+
const payload = {
|
|
621
|
+
schema: BASELINE_SCHEMA, slug: record.slug, recordDigest: record.contentDigest,
|
|
622
|
+
legacySourceDigest: record.preparedSourceDigest, sourceDigestVersion: SOURCE_DIGEST_VERSION,
|
|
623
|
+
sourceDigest: brief.sourceDigest, reviewedBy: record.reviewer, recordedAt: new Date().toISOString()
|
|
624
|
+
};
|
|
625
|
+
const baseline = { ...payload, digest: sha256(canonicalJson(payload)) };
|
|
626
|
+
const stage = `${path}.${process.pid}-${randomUUID()}.tmp`;
|
|
627
|
+
try {
|
|
628
|
+
writeFileSync(stage, `${JSON.stringify(baseline, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
629
|
+
// A hard-link publish is atomic and cannot overwrite a competing or historical receipt.
|
|
630
|
+
try { linkSync(stage, path); } catch (error) {
|
|
631
|
+
if (error.code !== 'EEXIST') throw error;
|
|
632
|
+
const concurrent = readSourceBaseline(root, paths, record);
|
|
633
|
+
if (!concurrent || concurrent.sourceDigest !== brief.sourceDigest) throw new Error('Conflicting scenario source baseline');
|
|
634
|
+
return concurrent;
|
|
635
|
+
}
|
|
636
|
+
} finally { rmSync(stage, { force: true }); }
|
|
637
|
+
return baseline;
|
|
638
|
+
}
|
|
639
|
+
|
|
513
640
|
export function recordPersonaTestScenarios(projectRoot, slug, input = {}, options = {}) {
|
|
514
641
|
const root = resolve(projectRoot);
|
|
642
|
+
findIntent(root, slug);
|
|
515
643
|
cleanTransaction(root, slug);
|
|
516
644
|
const reviewer = cleanText(options.reviewedBy ?? input.reviewedBy, 'Named reviewer', 160);
|
|
645
|
+
if (input.sourceDigestVersion !== undefined && input.sourceDigestVersion !== SOURCE_DIGEST_VERSION) {
|
|
646
|
+
throw new Error('Unsupported persona test-scenario source digest version');
|
|
647
|
+
}
|
|
517
648
|
const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
brief.projectRoot = root;
|
|
521
|
-
const payload = scenarioPayload(slug, brief, input, reviewer);
|
|
522
|
-
delete brief.projectRoot;
|
|
523
|
-
const contentDigest = sha256(canonicalJson(payload));
|
|
649
|
+
const prepareOptions = { focus: input.focus ?? options.focus ?? '', personas: catalogue };
|
|
650
|
+
const { legacySourceDigest, ...brief } = prepareBrief(root, slug, prepareOptions);
|
|
524
651
|
const paths = deliveryPaths(root, slug);
|
|
525
652
|
const jsonPath = resolve(paths.deliveryRoot, 'test-scenarios.json');
|
|
526
653
|
const markdownPath = resolve(paths.deliveryRoot, 'test-scenarios.md');
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
654
|
+
const existing = readRecordedPair(root, paths, slug);
|
|
655
|
+
if (!existing && evidenceFile(root, resolve(paths.deliveryRoot, 'test-scenarios.source-baseline.json'), 'Scenario source baseline', 8192)) {
|
|
656
|
+
throw new Error('Scenario source baseline has no accepted evidence pair');
|
|
657
|
+
}
|
|
658
|
+
const baseline = existing ? readSourceBaseline(root, paths, existing) : null;
|
|
659
|
+
let validationBrief = brief;
|
|
660
|
+
let validationInput = input;
|
|
661
|
+
if (existing && existing.sourceDigestVersion === undefined) {
|
|
662
|
+
const currentDigest = baseline ? brief.sourceDigest : legacySourceDigest;
|
|
663
|
+
if (currentDigest !== (baseline?.sourceDigest ?? existing.preparedSourceDigest)) {
|
|
664
|
+
throw new Error('Existing accepted scenario sources are stale; legacy evidence cannot be silently rebound');
|
|
665
|
+
}
|
|
666
|
+
if (![existing.preparedSourceDigest, brief.sourceDigest].includes(input.preparedSourceDigest)) {
|
|
667
|
+
throw new Error('Prepared source digest is stale or does not match this intent');
|
|
668
|
+
}
|
|
669
|
+
// Compare the reviewed payload exactly, retaining its historical source and reviewer metadata.
|
|
670
|
+
validationBrief = { ...brief, sourceDigest: existing.preparedSourceDigest, authoritativeSources: existing.sources, contextualEvidence: existing.contextualEvidence };
|
|
671
|
+
delete validationBrief.sourceDigestVersion;
|
|
672
|
+
validationInput = { ...input, preparedSourceDigest: existing.preparedSourceDigest };
|
|
673
|
+
}
|
|
674
|
+
const payload = scenarioPayload(slug, { ...validationBrief, projectRoot: root }, validationInput, reviewer);
|
|
675
|
+
const contentDigest = sha256(canonicalJson(payload));
|
|
676
|
+
if (existing) {
|
|
677
|
+
if (existing.contentDigest === contentDigest) {
|
|
678
|
+
const legacy = existing.sourceDigestVersion === undefined;
|
|
679
|
+
if (legacy) writeSourceBaseline(root, paths, existing, brief);
|
|
680
|
+
return {
|
|
681
|
+
schema: 'ewai.persona-test-recording/v1', status: 'recorded', digest: contentDigest,
|
|
682
|
+
jsonPath: relativePath(root, jsonPath), markdownPath: relativePath(root, markdownPath), idempotent: true,
|
|
683
|
+
...(legacy ? { sourceBaselinePath: relativePath(root, resolve(paths.deliveryRoot, 'test-scenarios.source-baseline.json')) } : {})
|
|
684
|
+
};
|
|
533
685
|
}
|
|
534
686
|
throw new Error('Conflicting accepted persona test-scenario evidence already exists and will not be overwritten');
|
|
535
687
|
}
|
|
@@ -601,21 +753,22 @@ export function readPersonaTestScenarioWorkspace(projectRoot, slug, options = {}
|
|
|
601
753
|
}
|
|
602
754
|
let record;
|
|
603
755
|
try {
|
|
604
|
-
record =
|
|
605
|
-
if (record.schema !== SCENARIO_SCHEMA || record.slug !== safeSlug) throw new Error('Persona test-scenario JSON schema or slug is invalid');
|
|
606
|
-
if (sha256(canonicalJson(recordDigestPayload(record))) !== record.contentDigest) throw new Error('Persona test-scenario content digest does not match');
|
|
607
|
-
if (sha256(readFileSync(markdownPath, 'utf8')) !== record.markdownDigest) throw new Error('Persona test-scenario Markdown digest does not match');
|
|
756
|
+
record = readRecordedPair(root, paths, safeSlug);
|
|
608
757
|
} catch (error) {
|
|
609
758
|
return { schema: WORKSPACE_SCHEMA, slug: safeSlug, status: 'invalid', reason: error.message, activePersonas: [], sources: [], scenarios: [], gaps: [], evidenceRoutes: [], guidance: { readOnly: true } };
|
|
610
759
|
}
|
|
611
760
|
const catalogue = Array.isArray(options.personas) ? options.personas : Array.isArray(options.personaCatalogue) ? options.personaCatalogue : [];
|
|
612
761
|
let current;
|
|
762
|
+
let expectedDigest = record.preparedSourceDigest;
|
|
613
763
|
try {
|
|
614
|
-
|
|
764
|
+
const baseline = readSourceBaseline(root, paths, record);
|
|
765
|
+
current = prepareBrief(root, safeSlug, { focus: record.focus, personas: catalogue },
|
|
766
|
+
record.sourceDigestVersion ?? (baseline ? SOURCE_DIGEST_VERSION : null));
|
|
767
|
+
expectedDigest = baseline?.sourceDigest ?? expectedDigest;
|
|
615
768
|
} catch (error) {
|
|
616
769
|
return safeWorkspaceRecord(record, 'invalid', `Current authoritative sources cannot be read: ${error.message}`);
|
|
617
770
|
}
|
|
618
|
-
if (current.sourceDigest !==
|
|
771
|
+
if (current.sourceDigest !== expectedDigest) {
|
|
619
772
|
return safeWorkspaceRecord(record, 'stale', 'Authoritative source evidence changed after this pack was reviewed. Prepare and review it again.');
|
|
620
773
|
}
|
|
621
774
|
return safeWorkspaceRecord(record, 'recorded');
|