backend-skeleton 1.0.0-beta.8 → 1.0.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.
Files changed (48) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +738 -12
  3. package/contracts/completeness.mjs +10 -0
  4. package/contracts/emit.mjs +5 -1
  5. package/contracts/export.mjs +26 -3
  6. package/contracts/openapi.mjs +29 -3
  7. package/handles/_engine.mjs +79 -29
  8. package/handles/providers/java-spring/plan.mjs +22 -9
  9. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  10. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  11. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  12. package/handles/providers/typescript-express/emit.mjs +23 -23
  13. package/handles/providers/typescript-express/observe.mjs +101 -0
  14. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  15. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  16. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  17. package/lib/attest.mjs +40 -0
  18. package/lib/cli.mjs +169 -2
  19. package/lib/cross-feature-collisions.mjs +286 -0
  20. package/lib/diff.mjs +35 -0
  21. package/lib/field-dependencies.mjs +355 -0
  22. package/lib/fsutil.mjs +7 -2
  23. package/lib/gate-definitions.mjs +117 -1
  24. package/lib/gates.mjs +5 -1
  25. package/lib/http-server.mjs +358 -0
  26. package/lib/lock.mjs +68 -15
  27. package/lib/patch-kinds.mjs +52 -0
  28. package/lib/patch-transactions.mjs +206 -0
  29. package/lib/serve-ui.html +328 -0
  30. package/lib/workflow.mjs +39 -3
  31. package/package.json +5 -2
  32. package/scanners/adapters/java-spring.mjs +6 -0
  33. package/scanners/adapters/python-fastapi.mjs +9 -1
  34. package/scanners/adapters/typescript-express.mjs +6 -0
  35. package/scanners/db/ddl-apply.mjs +253 -0
  36. package/scanners/db/introspect.mjs +61 -32
  37. package/scanners/db/migrations.mjs +73 -18
  38. package/schemas/cross-feature-report.schema.json +66 -0
  39. package/schemas/cross-feature-resolution.schema.json +28 -0
  40. package/schemas/field-dependency.schema.json +49 -0
  41. package/schemas/gate-attestation.schema.json +22 -0
  42. package/schemas/gate-export.schema.json +58 -0
  43. package/schemas/patch-transaction.schema.json +182 -0
  44. package/schemas/scan-report.schema.json +6 -4
  45. package/schemas/stack-choice.schema.json +12 -1
  46. package/stack/apply.mjs +4 -1
  47. package/stack/catalog/ngrok.yml +8 -2
  48. package/stack/config-apply.mjs +168 -0
package/bin/bskel.mjs CHANGED
@@ -30,7 +30,19 @@ import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contrac
30
30
  import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
31
31
  import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
32
32
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
33
+ import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
34
+ import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
35
+ import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
33
36
  import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
37
+ import { createHttpServer } from '../lib/http-server.mjs';
38
+ import {
39
+ resolveClassFile, listDownstreamDependents, DependencyOperationError,
40
+ declareDependency, removeDependency, buildDependencyListReport,
41
+ } from '../lib/field-dependencies.mjs';
42
+ import {
43
+ findCollisions, evaluateCrossFeatureFindings, waiverKey,
44
+ crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
45
+ } from '../lib/cross-feature-collisions.mjs';
34
46
  import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
35
47
  import {
36
48
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
@@ -47,6 +59,8 @@ import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
47
59
  import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
48
60
  import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
49
61
  import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
62
+ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-express/plan.mjs';
63
+ import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
50
64
  import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
51
65
  import { computeWorkflowState } from '../lib/workflow.mjs';
52
66
  import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
@@ -65,6 +79,8 @@ function usage() {
65
79
  bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
66
80
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
67
81
  bskel scan explain <module> --feature <id> [--json]
82
+ bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
83
+ bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
68
84
  bskel feature init --slug <name>
69
85
  bskel feature list [--all] [--json]
70
86
  bskel feature show <id> [--json]
@@ -77,14 +93,22 @@ function usage() {
77
93
  bskel contract validate --feature <id> --file <envelope.json>
78
94
  bskel contract tool-schema --feature <id> --operation <operationId>
79
95
  bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
96
+ bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
97
+ bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
98
+ bskel dependency list --feature <id> [--json]
80
99
  bskel stack apply --choice <id> [--apply] [--port N] [--json]
81
100
  bskel catalog lint [<choice>] [--json]
82
101
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
83
102
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
84
103
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
85
104
  bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
105
+ bskel patch propose --feature <id> [--kind config-apply|ddl-apply] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> [--json]
106
+ bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
107
+ bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
108
+ bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
109
+ bskel patch list --feature <id> [--json]
86
110
  bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
87
- bskel observe import --feature <id> --receipts <path> [--json]
111
+ bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
88
112
  bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
89
113
  bskel status [--feature <id>] [--json]
90
114
  bskel next [--feature <id>] [--json]
@@ -93,8 +117,11 @@ function usage() {
93
117
  bskel gate revoke <name> --reason "..." [--feature <id>]
94
118
  bskel gate history <name> [--feature <id>] [--json]
95
119
  bskel gate show [<name>] [--feature <id>]
96
- bskel gate export --feature <id> [--out <path>] [--json]
120
+ bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
121
+ bskel attest keygen --out <dir> [--force] [--json]
122
+ bskel attest verify --file <path> --pubkey <path> [--json]
97
123
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
124
+ bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
98
125
  `);
99
126
  }
100
127
 
@@ -384,6 +411,12 @@ function cmdGateExport(args) {
384
411
  setContext('gate export', flags);
385
412
  const root = requireRepoRoot();
386
413
  requireValidFeatureId(flags.feature);
414
+ if (flags.sign && !flags.key) {
415
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel gate export --sign requires --key <privateKeyPath>');
416
+ }
417
+ if (flags.key && !flags.sign) {
418
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
419
+ }
387
420
 
388
421
  const gates = {};
389
422
  for (const name of GATE_NAMES) {
@@ -398,14 +431,45 @@ function cmdGateExport(args) {
398
431
  git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
399
432
  gates,
400
433
  };
401
- const rendered = `${JSON.stringify(report, null, 2)}\n`;
434
+ // D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
435
+ // be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
436
+ {
437
+ const { ok, errors } = validateAgainstSchema('gate-export.schema.json', report);
438
+ if (!ok) {
439
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate-export report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
440
+ }
441
+ }
442
+
443
+ let payload = report;
444
+ if (flags.sign) {
445
+ let privateKeyPem;
446
+ try {
447
+ privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
448
+ } catch (err) {
449
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
450
+ }
451
+ let signatureValue;
452
+ try {
453
+ signatureValue = signPayload(report, privateKeyPem);
454
+ } catch (err) {
455
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
456
+ }
457
+ const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
458
+ const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
459
+ if (!ok) {
460
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
461
+ }
462
+ payload = attestation;
463
+ }
464
+ const rendered = `${JSON.stringify(payload, null, 2)}\n`;
402
465
 
403
466
  if (flags.out) {
404
467
  const outPath = path.resolve(process.cwd(), flags.out);
405
468
  writeFileAtomic(outPath, rendered);
406
469
  if (!flags.quiet) {
407
470
  const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
408
- console.log(`wrote ${flags.out} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
471
+ const signedNote = flags.sign ? ' (signed)' : '';
472
+ console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
409
473
  }
410
474
  } else {
411
475
  console.log(rendered);
@@ -413,6 +477,78 @@ function cmdGateExport(args) {
413
477
  process.exit(0);
414
478
  }
415
479
 
480
+ // D-gate-attestation-signing: --out is always required, never a default/home-directory location --
481
+ // this codebase has zero existing home-directory persistence convention anywhere, and inventing
482
+ // one is explicitly out of scope for this slice (a real fork the user weighed and decided, see
483
+ // DECISIONS.md). Repo-independent -- does not require a git repo at all, matching `attest verify`'s
484
+ // own posture below (both operate purely on files the caller names).
485
+ function cmdAttestKeygen(args) {
486
+ const flags = parseCommand('attest keygen', args);
487
+ if (flags.help) { console.log(renderCommandHelp('attest keygen')); process.exit(0); }
488
+ setContext('attest keygen', flags);
489
+ const outDir = path.resolve(process.cwd(), flags.out);
490
+ const privatePath = path.join(outDir, 'attest-private.pem');
491
+ const publicPath = path.join(outDir, 'attest-public.pem');
492
+ if (!flags.force) {
493
+ const existing = [privatePath, publicPath].filter((p) => fs.existsSync(p));
494
+ if (existing.length > 0) {
495
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to overwrite existing key file(s) without --force: ${existing.join(', ')} -- regenerating would orphan any attestation already signed with the old key`);
496
+ }
497
+ }
498
+ const { publicKeyPem, privateKeyPem } = generateKeypair();
499
+ // Restrictive mode (owner read/write only) from the very first write -- see the
500
+ // `D-gate-attestation-signing` entry's own note in `lib/fsutil.mjs` on why this is a
501
+ // `writeFileAtomic` parameter, not a chmod() called after the fact.
502
+ writeFileAtomic(privatePath, privateKeyPem, 0o600);
503
+ writeFileAtomic(publicPath, publicKeyPem);
504
+
505
+ if (flags.json) {
506
+ console.log(JSON.stringify({ private_key: privatePath, public_key: publicPath }, null, 2));
507
+ } else if (!flags.quiet) {
508
+ console.log(`wrote ${privatePath} (0600) and ${publicPath}`);
509
+ }
510
+ process.exit(0);
511
+ }
512
+
513
+ // Deliberately repo-independent (no requireRepoRoot()) -- verifying a previously-exported
514
+ // attestation has nothing to do with the current directory's own git state; the whole point is to
515
+ // check a document someone else produced, possibly on a different machine, offline. Exit code is
516
+ // driven ONLY by signature validity -- whether the gates INSIDE the report passed is a separate,
517
+ // printed question (see DECISIONS.md for why conflating the two would be actively misleading).
518
+ function cmdAttestVerify(args) {
519
+ const flags = parseCommand('attest verify', args);
520
+ if (flags.help) { console.log(renderCommandHelp('attest verify')); process.exit(0); }
521
+ setContext('attest verify', flags);
522
+
523
+ let attestation;
524
+ try {
525
+ attestation = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), flags.file), 'utf8'));
526
+ } catch (err) {
527
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --file "${flags.file}": ${err.message}`);
528
+ }
529
+ const { ok: schemaOk, errors: schemaErrors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
530
+ if (!schemaOk) {
531
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.file}" is not a valid gate attestation: ${formatSchemaErrors(schemaErrors).join('; ')}`);
532
+ }
533
+ let publicKeyPem;
534
+ try {
535
+ publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
536
+ } catch (err) {
537
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
538
+ }
539
+
540
+ const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
541
+ const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
542
+
543
+ if (flags.json) {
544
+ console.log(JSON.stringify({ valid, report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` } }, null, 2));
545
+ } else if (!flags.quiet) {
546
+ console.log(valid ? 'VALID: this attestation was genuinely signed by the holder of the matching private key' : 'INVALID: signature does not match this report + public key');
547
+ console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
548
+ }
549
+ process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
550
+ }
551
+
416
552
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
417
553
  // SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
418
554
  // Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
@@ -625,6 +761,146 @@ function cmdScanExplain(args) {
625
761
  process.exit(0);
626
762
  }
627
763
 
764
+ // D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
765
+ // blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
766
+ // identity collisions against every OTHER feature, not this feature's own contract completeness).
767
+ //
768
+ // D-cross-feature-fk-inference: async now -- reuses the EXACT same resolveDbSchemaOrExit() helper
769
+ // cmdScan() already calls for `--db [--database-url-env <NAME>] [--schema public]`, so this command
770
+ // gains those same flags with zero new live-DB code path. If `--db` was never given (the existing,
771
+ // unmodified call shape every current caller -- 5 CI smoke scripts, README's Quickstart -- already
772
+ // uses), findCollisions() falls back to a persisted snapshot or reports fk_check:'unavailable';
773
+ // the first 3 signals' findings and blocking behavior are byte-identical to before this item.
774
+ async function cmdScanCrossFeatureCheck(args) {
775
+ const flags = parseCommand('scan cross-feature-check', args);
776
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-check')); process.exit(0); }
777
+ setContext('scan cross-feature-check', flags);
778
+ const root = requireRepoRoot();
779
+ requireValidFeatureId(flags.feature);
780
+
781
+ const liveDbSchema = await resolveDbSchemaOrExit(root, flags);
782
+ const { findings, fk_check, unknowns } = findCollisions(root, flags.feature, { liveDbSchema });
783
+ const report = {
784
+ schema: 'sbf.cross-feature-report/1',
785
+ feature_id: flags.feature,
786
+ generated_at: new Date().toISOString(),
787
+ findings,
788
+ fk_check,
789
+ unknowns,
790
+ };
791
+ const { ok, errors } = validateAgainstSchema('cross-feature-report.schema.json', report);
792
+ if (!ok) {
793
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed cross-feature report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
794
+ }
795
+ writeFileAtomic(crossFeatureReportPath(root, flags.feature), `${JSON.stringify(report, null, 2)}\n`);
796
+
797
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
798
+ const evaluation = evaluateCrossFeatureFindings(findings, resolution);
799
+ const evidence = {
800
+ finding_count: findings.length,
801
+ high_confidence_count: findings.filter((f) => f.confidence === 'high').length,
802
+ waived_count: evaluation.waived.length,
803
+ stale_waivers: evaluation.staleWaivers.length,
804
+ };
805
+ const gateState = evaluation.blocking
806
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
807
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
808
+
809
+ if (flags.json) {
810
+ console.log(JSON.stringify({ report, gate: gateState.gates.cross_feature }, null, 2));
811
+ } else {
812
+ if (!flags.quiet) {
813
+ const otherCount = new Set(findings.map((f) => f.other_feature)).size;
814
+ console.log(`${findings.length} finding(s) (${evidence.high_confidence_count} high-confidence) across ${otherCount} other feature(s)`);
815
+ for (const f of findings) console.log(` [${f.confidence}] ${f.signal}: "${f.identifier}" also declared by ${f.other_feature}`);
816
+ // D-cross-feature-fk-inference (staleness/freshness token): the actual point of the field
817
+ // -- a human SEEING how stale a persisted/migrations-mode correlation is, not just the
818
+ // JSON carrying it silently.
819
+ const fkCheckDetails = [];
820
+ if (fk_check.source_feature) fkCheckDetails.push(`from ${fk_check.source_feature}'s own persisted snapshot`);
821
+ if (fk_check.generated_at) fkCheckDetails.push(`captured ${fk_check.generated_at}`);
822
+ console.log(`fk_check: ${fk_check.mode}${fkCheckDetails.length ? ` (${fkCheckDetails.join(', ')})` : ''}`);
823
+ for (const u of unknowns) console.log(` note: ${u}`);
824
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
825
+ }
826
+ if (evaluation.staleWaivers.length > 0) {
827
+ console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current finding (kept as-is, not auto-removed):`);
828
+ for (const w of evaluation.staleWaivers) console.error(` ${w.signal} "${w.identifier}" (${w.other_feature})`);
829
+ }
830
+ if (evaluation.blocking) {
831
+ console.error(`\nblocked: ${evaluation.unwaived.length} unresolved high-confidence collision(s):`);
832
+ for (const f of evaluation.unwaived) {
833
+ console.error(` bskel scan cross-feature-waive --feature ${flags.feature} --signal ${f.signal} --identifier "${f.identifier}" --other-feature ${f.other_feature} --reason "..."`);
834
+ }
835
+ }
836
+ }
837
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
838
+ }
839
+
840
+ const CROSS_FEATURE_SIGNALS = ['resource_type', 'table', 'operation_id', 'db_foreign_key'];
841
+
842
+ // Validates against the PERSISTED report from the last `cross-feature-check` run, never a live
843
+ // re-computation -- same precedent `contract waive` already establishes against `loadContract`
844
+ // (contracts/completeness.mjs). If reality moved since that check, the gate's own staleness token
845
+ // (which covers every OTHER feature named in the report) is what surfaces that, not a silent
846
+ // re-check inside this command.
847
+ function cmdScanCrossFeatureWaive(args) {
848
+ const flags = parseCommand('scan cross-feature-waive', args);
849
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-waive')); process.exit(0); }
850
+ setContext('scan cross-feature-waive', flags);
851
+ const root = requireRepoRoot();
852
+ requireValidFeatureId(flags.feature);
853
+ requireValidFeatureId(flags['other-feature']);
854
+ if (!flags.reason || !flags.reason.trim()) {
855
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-waive requires --reason "..." -- every waiver must be auditable');
856
+ }
857
+ if (!CROSS_FEATURE_SIGNALS.includes(flags.signal)) {
858
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--signal must be one of: ${CROSS_FEATURE_SIGNALS.join(', ')}`);
859
+ }
860
+
861
+ const report = loadCrossFeatureReport(root, flags.feature);
862
+ if (!report) {
863
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}" -- run \`bskel scan cross-feature-check --feature ${flags.feature}\` first`);
864
+ }
865
+ const match = report.findings.find((f) => f.signal === flags.signal && f.identifier === flags.identifier && f.other_feature === flags['other-feature']);
866
+ if (!match) {
867
+ const known = report.findings.map((f) => `${f.signal} "${f.identifier}" (${f.other_feature})`);
868
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no current finding matches --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- current findings: ${known.join('; ') || '(none)'}`);
869
+ }
870
+
871
+ const updated = withLockSync(root, 'state', () => {
872
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
873
+ const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
874
+ const entry = { signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'], reason: flags.reason, at: new Date().toISOString() };
875
+ const next = {
876
+ schema: 'sbf.cross-feature-resolution/1',
877
+ feature_id: flags.feature,
878
+ waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
879
+ };
880
+ saveCrossFeatureResolution(root, flags.feature, next);
881
+ return next;
882
+ });
883
+
884
+ const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
885
+ const evidence = {
886
+ finding_count: report.findings.length,
887
+ high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
888
+ waived_count: evaluation.waived.length,
889
+ stale_waivers: evaluation.staleWaivers.length,
890
+ };
891
+ const gateState = evaluation.blocking
892
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
893
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
894
+
895
+ if (flags.json) {
896
+ console.log(JSON.stringify({ waived: true, gate: gateState.gates.cross_feature }, null, 2));
897
+ } else if (!flags.quiet) {
898
+ console.log(`waived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
899
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
900
+ }
901
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
902
+ }
903
+
628
904
  // D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
629
905
  // load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
630
906
  // during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
@@ -1055,6 +1331,7 @@ function cmdContractEmit(args) {
1055
1331
  console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
1056
1332
  for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
1057
1333
  }
1334
+ for (const n of describeDownstreamImpact(root, flags.feature)) console.error(`\nnote: ${n}`);
1058
1335
  if (evaluation.blocking) {
1059
1336
  if (evaluation.status === 'blocked') {
1060
1337
  console.error(`\nblocked: this contract has zero operations and cannot be waived -- fix --module/--terms, or run \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if this module genuinely has no HTTP surface (yet).`);
@@ -1363,6 +1640,123 @@ function cmdContractWaive(args) {
1363
1640
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1364
1641
  }
1365
1642
 
1643
+ // D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
1644
+ // feature, at the moment its own generated artifacts are refreshed, that other features declared a
1645
+ // dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
1646
+ // is actually stale AND that staleness is attributable to THIS featureId specifically (a
1647
+ // `source_field_file:<featureId>:` key in its changed_inputs) -- a dependent that's stale for some
1648
+ // OTHER, unrelated reason must not be misattributed to this feature's own change. When
1649
+ // changed_inputs can't explain the staleness (NO_RECORDED_INPUTS/RECORDED_INPUTS_MISMATCH), the note
1650
+ // is skipped rather than guessed -- this is a best-effort nudge, never the source of truth for
1651
+ // whether something is actually stale (bskel verify/status on the dependent feature itself remains
1652
+ // that source of truth).
1653
+ function describeDownstreamImpact(root, featureId) {
1654
+ const byDependent = new Map();
1655
+ for (const { dependentFeature, dep } of listDownstreamDependents(root, featureId)) {
1656
+ if (!byDependent.has(dependentFeature)) byDependent.set(dependentFeature, []);
1657
+ byDependent.get(dependentFeature).push(dep);
1658
+ }
1659
+ const notes = [];
1660
+ const prefix = `source_field_file:${featureId}:`;
1661
+ for (const [dependentFeature, deps] of byDependent) {
1662
+ const gate = requireNamedGate(root, 'dependencies', dependentFeature);
1663
+ if (gate.status !== 'stale') continue;
1664
+ if (!(gate.changed_inputs ?? []).some((k) => k.startsWith(prefix))) continue;
1665
+ const list = deps.map((d) => `${d.target.resourceType}.${d.target.fieldName} <- ${d.source.resourceType}.${d.source.fieldName}`).join('; ');
1666
+ notes.push(
1667
+ `downstream impact: feature "${dependentFeature}" depends on this feature's field(s) (${list}), and that dependency just went stale -- ` +
1668
+ `review with \`bskel dependency list --feature ${dependentFeature} --json\`, then re-run \`bskel dependency declare ...\` once the change is accounted for.`,
1669
+ );
1670
+ }
1671
+ return notes;
1672
+ }
1673
+
1674
+ // D-http-serving-layer: cmdDependencyDeclare/Remove/List are now thin CLI wrappers over
1675
+ // lib/field-dependencies.mjs's declareDependency/removeDependency/buildDependencyListReport --
1676
+ // lib/http-server.mjs's POST/DELETE/GET handlers call the SAME functions, so the CLI and HTTP
1677
+ // surfaces can never diverge on what these operations actually do. A thrown DependencyOperationError
1678
+ // carries the exact (exitCode, reasonCode) this CLI path always used -- fail() is called with those
1679
+ // verbatim, so this refactor is behavior-preserving (verified: test/dependency-cli.test.mjs, written
1680
+ // before this refactor existed, passes unchanged).
1681
+ function cmdDependencyDeclare(args) {
1682
+ const flags = parseCommand('dependency declare', args);
1683
+ if (flags.help) { console.log(renderCommandHelp('dependency declare')); process.exit(0); }
1684
+ setContext('dependency declare', flags);
1685
+ const root = requireRepoRoot();
1686
+ let result;
1687
+ try {
1688
+ result = declareDependency(root, {
1689
+ feature: flags.feature, resource: flags.resource, field: flags.field,
1690
+ sourceFeature: flags['source-feature'], sourceResource: flags['source-resource'], sourceField: flags['source-field'],
1691
+ reason: flags.reason, memo: flags.memo,
1692
+ });
1693
+ } catch (err) {
1694
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1695
+ throw err;
1696
+ }
1697
+
1698
+ if (flags.json) {
1699
+ console.log(JSON.stringify(result, null, 2));
1700
+ } else if (!flags.quiet) {
1701
+ console.log(`declared: ${flags.resource}.${flags.field} <- ${flags['source-feature']}/${flags['source-resource']}.${flags['source-field']}`);
1702
+ console.log(`gate: dependencies -> ${result.gate.status}`);
1703
+ }
1704
+ process.exit(EXIT.PASS);
1705
+ }
1706
+
1707
+ function cmdDependencyRemove(args) {
1708
+ const flags = parseCommand('dependency remove', args);
1709
+ if (flags.help) { console.log(renderCommandHelp('dependency remove')); process.exit(0); }
1710
+ setContext('dependency remove', flags);
1711
+ const root = requireRepoRoot();
1712
+ let result;
1713
+ try {
1714
+ result = removeDependency(root, {
1715
+ feature: flags.feature, resource: flags.resource, field: flags.field,
1716
+ sourceFeature: flags['source-feature'], sourceResource: flags['source-resource'], sourceField: flags['source-field'],
1717
+ reason: flags.reason,
1718
+ });
1719
+ } catch (err) {
1720
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1721
+ throw err;
1722
+ }
1723
+
1724
+ if (flags.json) {
1725
+ console.log(JSON.stringify(result, null, 2));
1726
+ } else if (!flags.quiet) {
1727
+ console.log(`removed: ${flags.resource}.${flags.field} <- ${flags['source-feature']}/${flags['source-resource']}.${flags['source-field']}`);
1728
+ console.log(`gate: dependencies -> ${result.gate.status}`);
1729
+ }
1730
+ process.exit(EXIT.PASS);
1731
+ }
1732
+
1733
+ function cmdDependencyList(args) {
1734
+ const flags = parseCommand('dependency list', args);
1735
+ if (flags.help) { console.log(renderCommandHelp('dependency list')); process.exit(0); }
1736
+ setContext('dependency list', flags);
1737
+ const root = requireRepoRoot();
1738
+ let report;
1739
+ try {
1740
+ report = buildDependencyListReport(root, flags.feature);
1741
+ } catch (err) {
1742
+ if (err instanceof DependencyOperationError) fail(err.exitCode, err.reasonCode, err.message);
1743
+ throw err;
1744
+ }
1745
+
1746
+ if (flags.json) {
1747
+ console.log(JSON.stringify(report, null, 2));
1748
+ } else {
1749
+ console.log(`dependencies -- feature ${flags.feature} (gate: ${report.gate.status})`);
1750
+ for (const r of report.dependencies) {
1751
+ const tNote = r.target_resolved ? 'ok' : `UNRESOLVED:${r.target_unresolved_reason}`;
1752
+ const sNote = r.source_resolved ? 'ok' : `UNRESOLVED:${r.source_unresolved_reason}`;
1753
+ console.log(` ${r.target.resourceType}.${r.target.fieldName} [${tNote}] <- ${r.source.feature}/${r.source.resourceType}.${r.source.fieldName} [${sNote}]`);
1754
+ }
1755
+ if (report.dependencies.length === 0) console.log(' (none declared)');
1756
+ }
1757
+ process.exit(0);
1758
+ }
1759
+
1366
1760
  // D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
1367
1761
  // bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
1368
1762
  // a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
@@ -1909,6 +2303,25 @@ function cmdHandlesEmit(args) {
1909
2303
  });
1910
2304
  }
1911
2305
 
2306
+ // D-cross-feature-collision: making "mandatory disposition" actually mandatory, not just
2307
+ // available -- `handles emit` is specifically the step that generates the runtime resolver
2308
+ // code every codegen provider's own resourceType-keyed dispatch (Java/Python/TS, all three)
2309
+ // already implicitly assumes is repo-unique. `cross_feature`'s own gate `verifyPolicy` stays
2310
+ // REQUIRED_WHEN_PRESENT (so `bskel verify` doesn't retroactively fail a contract-only feature
2311
+ // that never touches handles at all) -- this hard-requires it ONLY here, the one command where
2312
+ // the real risk actually lives, mirroring the `contract` gate check immediately above exactly.
2313
+ // Deliberately NOT added to `cmdHandlesPlan` -- that command never writes (dryRun always),
2314
+ // same reasoning it already gives for skipping the `contract` gate entirely.
2315
+ const crossFeatureResult = requireNamedGate(root, 'cross_feature', flags.feature);
2316
+ if (crossFeatureResult.code !== EXIT.PASS) {
2317
+ const cfHint = crossFeatureResult.status === 'awaiting_disposition'
2318
+ ? `resolve it first -- \`bskel scan cross-feature-waive --feature ${flags.feature} --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."\`, or \`bskel gate force cross_feature --feature ${flags.feature} --reason "..."\` if intentional.`
2319
+ : `run \`bskel scan cross-feature-check --feature ${flags.feature}\` first.`;
2320
+ fail(crossFeatureResult.code, gateReasonForCode(crossFeatureResult.code), `blocked: \`cross_feature\` gate for ${flags.feature} is ${crossFeatureResult.status} -- ${cfHint}`, {
2321
+ next_actions: [{ command: `bskel scan cross-feature-check --feature ${flags.feature}`, reason: 'the cross_feature gate has not passed yet', mutating: true }],
2322
+ });
2323
+ }
2324
+
1912
2325
  const scanReport = loadScanReportOrExit(root, flags.feature);
1913
2326
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
1914
2327
  requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
@@ -1990,6 +2403,11 @@ function cmdHandlesEmit(args) {
1990
2403
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
1991
2404
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
1992
2405
 
2406
+ // D-dependency-propagation-notice: appended here (not inside any provider's own emit.mjs) so it
2407
+ // applies uniformly regardless of which provider ran -- inherits the same --json/text-mode
2408
+ // visibility every provider-authored postEmitNote already has, no special-casing needed.
2409
+ postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
2410
+
1993
2411
  if (flags.json) {
1994
2412
  console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
1995
2413
  } else if (!flags.quiet) {
@@ -2079,6 +2497,189 @@ function cmdHandlesPatchApprove(args) {
2079
2497
  process.exit(0);
2080
2498
  }
2081
2499
 
2500
+ // D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
2501
+ // specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
2502
+ // file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
2503
+ // distinction cmdHandlesEmit/cmdHandlesPlan already draw for --check vs a real emit.
2504
+ // D-ddl-apply: `--kind` picks which patch-transaction kind to propose (default 'config-apply',
2505
+ // fully backward compatible -- every existing call site/script that never passed --kind gets the
2506
+ // exact prior behavior). Kind-specific params are validated by hand here (not via COMMANDS'
2507
+ // declarative `required: true`, which is unconditional per-flag) -- matches this file's own
2508
+ // existing convention for kind-conditional requirements (e.g. --reason on approve/rollback).
2509
+ async function cmdPatchPropose(args) {
2510
+ const flags = parseCommand('patch propose', args);
2511
+ if (flags.help) { console.log(renderCommandHelp('patch propose')); process.exit(0); }
2512
+ setContext('patch propose', flags);
2513
+ const root = requireRepoRoot();
2514
+ requireValidFeatureId(flags.feature);
2515
+ const kind = flags.kind || 'config-apply';
2516
+ if (!PATCH_KIND_NAMES.includes(kind)) {
2517
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--kind must be one of: ${PATCH_KIND_NAMES.join(', ')}`);
2518
+ }
2519
+
2520
+ let params;
2521
+ let source;
2522
+ if (kind === 'config-apply') {
2523
+ if (!flags.choice || !flags.target) {
2524
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind config-apply requires --choice <stackChoiceId> --target <config_check target path>');
2525
+ }
2526
+ params = { choice: flags.choice, target: flags.target };
2527
+ source = { choice: flags.choice };
2528
+ } else {
2529
+ if (!flags['database-url-env'] || !flags['sql-file']) {
2530
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
2531
+ }
2532
+ let sqlText;
2533
+ try {
2534
+ sqlText = fs.readFileSync(flags['sql-file'], 'utf8');
2535
+ } catch (err) {
2536
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --sql-file "${flags['sql-file']}": ${err.message}`);
2537
+ }
2538
+ params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
2539
+ source = { database_url_env: flags['database-url-env'], schema: flags.schema };
2540
+ }
2541
+
2542
+ let plan;
2543
+ try {
2544
+ plan = await getPatchKind(kind).planFresh(root, params);
2545
+ } catch (err) {
2546
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2547
+ }
2548
+ const txn = proposeTransaction(root, flags.feature, kind, plan, source);
2549
+
2550
+ if (flags.json) {
2551
+ console.log(JSON.stringify(txn, null, 2));
2552
+ } else if (!flags.quiet) {
2553
+ console.log(`proposed: ${txn.transaction_id}`);
2554
+ console.log(` ${describePatchTransaction(txn)}`);
2555
+ console.log(`next: bskel patch approve --feature ${flags.feature} --transaction ${txn.transaction_id} --reason "..."`);
2556
+ }
2557
+ process.exit(0);
2558
+ }
2559
+
2560
+ // Kind-aware one-line summary, reused by propose/list's human-readable output -- config-apply's
2561
+ // target is a file+key_path, ddl-apply's is raw SQL text (truncated for a terminal line).
2562
+ function describePatchTransaction(txn) {
2563
+ if (txn.kind === 'config-apply') {
2564
+ return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
2565
+ }
2566
+ const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
2567
+ return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
2568
+ }
2569
+
2570
+ function requireTransactionOrExit(root, featureId, transactionId) {
2571
+ const txn = loadTransaction(root, featureId, transactionId);
2572
+ if (!txn) {
2573
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no patch transaction "${transactionId}" for feature "${featureId}"`);
2574
+ }
2575
+ return txn;
2576
+ }
2577
+
2578
+ async function cmdPatchApprove(args) {
2579
+ const flags = parseCommand('patch approve', args);
2580
+ if (flags.help) { console.log(renderCommandHelp('patch approve')); process.exit(0); }
2581
+ setContext('patch approve', flags);
2582
+ const root = requireRepoRoot();
2583
+ requireValidFeatureId(flags.feature);
2584
+ if (!flags.reason || !flags.reason.trim()) {
2585
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch approve requires --reason "..." -- every approval must be auditable');
2586
+ }
2587
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2588
+
2589
+ let freshPlan;
2590
+ try {
2591
+ freshPlan = await replanTransaction(root, txn);
2592
+ } catch (err) {
2593
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2594
+ }
2595
+ const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
2596
+
2597
+ console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
2598
+ process.exit(0);
2599
+ }
2600
+
2601
+ // D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
2602
+ // than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
2603
+ // is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
2604
+ // preimage-hash TOCTOU check (this check does not replace it). Optional/ignored for config-apply,
2605
+ // zero behavior change for that kind.
2606
+ async function cmdPatchApply(args) {
2607
+ const flags = parseCommand('patch apply', args);
2608
+ if (flags.help) { console.log(renderCommandHelp('patch apply')); process.exit(0); }
2609
+ setContext('patch apply', flags);
2610
+ const root = requireRepoRoot();
2611
+ requirePreflightPassed(root);
2612
+ requireValidFeatureId(flags.feature);
2613
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2614
+
2615
+ const requiredConfirm = getPatchKind(txn.kind).requiredConfirmValue(txn);
2616
+ if (requiredConfirm !== null && flags.confirm !== requiredConfirm) {
2617
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `bskel patch apply --kind ${txn.kind} requires --confirm ${JSON.stringify(requiredConfirm)} exactly -- pass --confirm ${requiredConfirm} once you've reviewed this transaction`);
2618
+ }
2619
+
2620
+ let freshPlan;
2621
+ try {
2622
+ freshPlan = await replanTransaction(root, txn);
2623
+ } catch (err) {
2624
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2625
+ }
2626
+ const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
2627
+
2628
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
2629
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2630
+
2631
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `applied: ${flags.transaction} [${updated.kind}]`);
2632
+ process.exit(0);
2633
+ }
2634
+
2635
+ async function cmdPatchRollback(args) {
2636
+ const flags = parseCommand('patch rollback', args);
2637
+ if (flags.help) { console.log(renderCommandHelp('patch rollback')); process.exit(0); }
2638
+ setContext('patch rollback', flags);
2639
+ const root = requireRepoRoot();
2640
+ requirePreflightPassed(root);
2641
+ requireValidFeatureId(flags.feature);
2642
+ if (!flags.reason || !flags.reason.trim()) {
2643
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch rollback requires --reason "..." -- every rollback must be auditable');
2644
+ }
2645
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2646
+ const updated = await rollbackTransaction(root, flags.feature, flags.transaction, flags.reason, { force: flags.force }, getPatchKind(txn.kind).rollback);
2647
+
2648
+ // Re-passing (not revoking) mirrors cmdScanCrossFeatureWaive's own precedent for this exact
2649
+ // situation -- recompute() only loops status:'applied' transactions, so a rolled-back one
2650
+ // naturally drops out of the gate's own input set on the next pass; a fresh pass here reflects
2651
+ // "still fine, just smaller" immediately rather than leaving the gate to be reactively
2652
+ // discovered stale by the next `bskel verify`.
2653
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, rolled_back_at: updated.rollback.at };
2654
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2655
+
2656
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `rolled back: ${flags.transaction} [${updated.kind}]`);
2657
+ process.exit(0);
2658
+ }
2659
+
2660
+ // A deliberately-omitted-until-now, easy read-only convenience (D-patch-transactions' own EXIT
2661
+ // list named it explicitly) -- mirrors `dependency list`/`contract history`'s own pure-reader
2662
+ // posture, gate-independent like both.
2663
+ function cmdPatchList(args) {
2664
+ const flags = parseCommand('patch list', args);
2665
+ if (flags.help) { console.log(renderCommandHelp('patch list')); process.exit(0); }
2666
+ setContext('patch list', flags);
2667
+ const root = requireRepoRoot();
2668
+ requireValidFeatureId(flags.feature);
2669
+ const transactions = listTransactions(root, flags.feature);
2670
+
2671
+ if (flags.json) {
2672
+ console.log(JSON.stringify({ feature: flags.feature, transactions }, null, 2));
2673
+ } else if (!flags.quiet) {
2674
+ console.log(`patch transactions -- feature ${flags.feature}`);
2675
+ for (const t of transactions) {
2676
+ console.log(` ${t.transaction_id} [${t.status}] ${describePatchTransaction(t)}`);
2677
+ }
2678
+ if (transactions.length === 0) console.log(' (none proposed)');
2679
+ }
2680
+ process.exit(0);
2681
+ }
2682
+
2082
2683
  // O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
2083
2684
  // D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
2084
2685
  // gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
@@ -2209,8 +2810,23 @@ function cmdObserveEmit(args) {
2209
2810
  } catch (err) {
2210
2811
  fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2211
2812
  }
2813
+ } else if (scanReport.adapter === 'typescript-express') {
2814
+ // typescript-express's own project-root detection needs a module to anchor itself too (same
2815
+ // reasoning as python-fastapi's own --module dependency) -- plan.mjs's detectProjectRoot()
2816
+ // walks up from the module's own scanned files.
2817
+ let tsPlan;
2818
+ try {
2819
+ tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
2820
+ } catch (err) {
2821
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2822
+ }
2823
+ try {
2824
+ result = emitObserveTypeScriptExpress({ repoRoot: root, featureId: flags.feature, contract, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2825
+ } catch (err) {
2826
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2827
+ }
2212
2828
  } else {
2213
- fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi)`);
2829
+ fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi, typescript-express)`);
2214
2830
  }
2215
2831
  const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
2216
2832
  const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
@@ -2361,11 +2977,20 @@ function cmdObserveImport(args) {
2361
2977
  const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
2362
2978
  writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
2363
2979
 
2364
- // Evidence-first, not verdict-first (same as `contract`'s own precedent: a `partial` contract
2365
- // is still passable via waiver) -- passes on a successful STRUCTURAL import, never on "zero
2366
- // violations found". Whether violation counts should block CI is a policy question for whoever
2367
- // reads the report, deliberately not decided here -- see DECISIONS.md's own deferred list.
2368
- const gateState = passNamedGate(root, 'conformance', flags.feature, { receipt_count: receipts.length, matched, violations: violationCount });
2980
+ // Evidence-first, not verdict-first by DEFAULT (same as `contract`'s own precedent: a `partial`
2981
+ // contract is still passable via waiver) -- passes on a successful STRUCTURAL import, never on
2982
+ // "zero violations found", unless --fail-on-violation opts into the stricter behavior. Whether
2983
+ // violation counts should block CI was a policy question deliberately left undecided in v1 --
2984
+ // this is that v1.1 layer, see D-runtime-conformance-receipts's own "Continued" entry in
2985
+ // DECISIONS.md. `awaitNamedGateDisposition` is the EXACT same disposition mechanism `contract`
2986
+ // already uses for "evidence exists but isn't good enough to pass silently" -- resolved by the
2987
+ // already-generic `bskel gate force conformance --feature <id> --reason "..."` (no new CLI verb
2988
+ // needed; `bskel next`'s own generic awaitingDispositionCommand() fallback already names it).
2989
+ const evidence = { receipt_count: receipts.length, matched, violations: violationCount };
2990
+ const blocking = flags['fail-on-violation'] && violationCount > 0;
2991
+ const gateState = blocking
2992
+ ? awaitNamedGateDisposition(root, 'conformance', flags.feature, evidence)
2993
+ : passNamedGate(root, 'conformance', flags.feature, evidence);
2369
2994
 
2370
2995
  if (flags.json) {
2371
2996
  console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
@@ -2374,8 +2999,11 @@ function cmdObserveImport(args) {
2374
2999
  console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
2375
3000
  console.log(`wrote ${path.relative(root, reportPath)}`);
2376
3001
  console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
3002
+ if (blocking) {
3003
+ console.error(`\nblocked: ${violationCount} violation(s) found (--fail-on-violation) -- review ${path.relative(root, reportPath)}, then \`bskel gate force conformance --feature ${flags.feature} --reason "..."\` once accounted for.`);
3004
+ }
2377
3005
  }
2378
- process.exit(0);
3006
+ process.exit(blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
2379
3007
  }
2380
3008
 
2381
3009
  // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
@@ -2400,10 +3028,17 @@ function renderVerifyReport({ featureId, gates, artifacts, conflicts, build, all
2400
3028
  const completenessNote = g.gate === 'contract' && evidence?.completeness
2401
3029
  ? ` (${evidence.completeness}${evidence.waived_count ? `: ${evidence.waived_count} waived` : ''})`
2402
3030
  : '';
3031
+ // D-runtime-conformance-receipts (Continued, --fail-on-violation): same "surface the evidence
3032
+ // right in the verify report" precedent as `completenessNote` above -- fires for both `pass`
3033
+ // and `awaiting_disposition` states, since a human should see the count even when it didn't
3034
+ // end up blocking (i.e. --fail-on-violation wasn't used at import time).
3035
+ const conformanceNote = g.gate === 'conformance' && evidence?.violations
3036
+ ? ` (${evidence.violations} violation(s), ${evidence.matched}/${evidence.receipt_count} matched)`
3037
+ : '';
2403
3038
  // S4 (D-gate-history): a revoked gate's reason is exactly the kind of "why is this
2404
3039
  // blocking" detail describeStale() already surfaces for stale gates -- same treatment here.
2405
3040
  const revokedNote = g.status === 'revoked' && g.record?.reason ? ` (revoked: ${g.record.reason})` : '';
2406
- lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${describeStale(g)}${revokedNote}`);
3041
+ lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${conformanceNote}${describeStale(g)}${revokedNote}`);
2407
3042
  }
2408
3043
  lines.push('', '## Artifacts');
2409
3044
  for (const a of artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
@@ -2603,6 +3238,66 @@ function cmdDoctor(args) {
2603
3238
  process.exit(allOk ? 0 : 1);
2604
3239
  }
2605
3240
 
3241
+ // D-http-serving-layer: starts a real, long-running HTTP server (lib/http-server.mjs) -- unlike
3242
+ // every other command in this file, success here does NOT process.exit(); the server's own open
3243
+ // socket keeps the event loop alive until Ctrl+C (SIGINT) or SIGTERM. Every route handler calls
3244
+ // straight into the same lib/ functions the CLI commands use -- no separate business logic lives in
3245
+ // the HTTP layer itself.
3246
+ async function cmdServe(args) {
3247
+ const flags = parseCommand('serve', args);
3248
+ if (flags.help) { console.log(renderCommandHelp('serve')); process.exit(0); }
3249
+ setContext('serve', flags);
3250
+ const root = requireRepoRoot();
3251
+ const port = Number.parseInt(flags.port, 10);
3252
+
3253
+ // D-ddl-apply: eagerly checked here, at startup -- the DB-touching routes must not silently
3254
+ // 404 forever because of a typo'd env var name; missing/unset is BAD_ARGS at the CLI boundary,
3255
+ // same convention as resolveDbSchemaOrExit's own `bskel scan --db` handling. The live
3256
+ // connection itself still only ever opens lazily, per-request (no eager connect-at-startup, no
3257
+ // pool) -- a transient DB outage must not block the unrelated, DB-independent parts of the UI.
3258
+ let dbConfig = null;
3259
+ if (flags['database-url-env']) {
3260
+ if (!process.env[flags['database-url-env']]) {
3261
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--database-url-env ${flags['database-url-env']} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
3262
+ }
3263
+ dbConfig = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, signKeyPath: flags['sign-key'] };
3264
+ // D-ddl-apply: closes this feature's own named "mandatory signing... cheap, well-justified
3265
+ // near-term addition" EXIT item -- an opt-in-to-MORE-strictness knob, letting a cautious team
3266
+ // enforce mandatory signing for themselves without this tool forcing it on every user. A
3267
+ // no-op when --database-url-env wasn't given at all (this whole block never runs then) --
3268
+ // nothing to enforce on a DDL surface that isn't running.
3269
+ if (flags['require-sign-key'] && !dbConfig.signKeyPath) {
3270
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-sign-key was given but --sign-key was not -- refusing to start the DDL surface without an audit-signing key. Pass --sign-key <path>, or drop --require-sign-key to allow starting unsigned (with a warning).');
3271
+ }
3272
+ }
3273
+
3274
+ let started;
3275
+ try {
3276
+ started = await createHttpServer(root, { host: flags.host, port, dbConfig });
3277
+ } catch (err) {
3278
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not start server: ${err.message}`);
3279
+ }
3280
+
3281
+ if (flags.json) {
3282
+ console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root, ddlApplyEnabled: Boolean(dbConfig) }));
3283
+ } else if (!flags.quiet) {
3284
+ console.log(`bskel serve -- listening on ${started.url}`);
3285
+ console.log(` UI: ${started.url}/`);
3286
+ console.log(` API: ${started.url}/api/...`);
3287
+ if (dbConfig) {
3288
+ console.log(` DDL apply routes: ENABLED (schema "${dbConfig.schema}", connection from $${dbConfig.databaseUrlEnv})`);
3289
+ if (!dbConfig.signKeyPath) {
3290
+ console.log(' WARNING: no --sign-key given -- every propose/approve/apply is recorded in specs/ but NOT cryptographically attested. Pass --sign-key <path> to change this.');
3291
+ }
3292
+ }
3293
+ console.log('press Ctrl+C to stop');
3294
+ }
3295
+
3296
+ const shutdown = () => started.server.close(() => process.exit(0));
3297
+ process.on('SIGINT', shutdown);
3298
+ process.on('SIGTERM', shutdown);
3299
+ }
3300
+
2606
3301
  // P2 (D-greenfield-bootstrap): the one path into this tool that doesn't require an existing git
2607
3302
  // repo (contrast requireRepoRoot(), used by nearly everything else) -- `bskel new` is what CREATES
2608
3303
  // one. `--stack`'s two choices come from new/index.mjs's plain dispatch map, not a dynamic
@@ -2823,6 +3518,8 @@ async function dispatchCommand(cmd, rest) {
2823
3518
  case 'scan': {
2824
3519
  if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
2825
3520
  if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
3521
+ if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
3522
+ if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
2826
3523
  await cmdScan(rest);
2827
3524
  break;
2828
3525
  }
@@ -2850,12 +3547,32 @@ async function dispatchCommand(cmd, rest) {
2850
3547
  process.exit(14);
2851
3548
  break;
2852
3549
  }
3550
+ case 'dependency': {
3551
+ const sub = rest[0];
3552
+ const subArgs = rest.slice(1);
3553
+ if (sub === 'declare') return cmdDependencyDeclare(subArgs);
3554
+ if (sub === 'remove') return cmdDependencyRemove(subArgs);
3555
+ if (sub === 'list') return cmdDependencyList(subArgs);
3556
+ usage();
3557
+ process.exit(14);
3558
+ break;
3559
+ }
2853
3560
  case 'stack': {
2854
3561
  if (rest[0] === 'apply') return cmdStackApply(rest.slice(1));
2855
3562
  usage();
2856
3563
  process.exit(14);
2857
3564
  break;
2858
3565
  }
3566
+ case 'patch': {
3567
+ if (rest[0] === 'propose') return cmdPatchPropose(rest.slice(1));
3568
+ if (rest[0] === 'approve') return cmdPatchApprove(rest.slice(1));
3569
+ if (rest[0] === 'apply') return cmdPatchApply(rest.slice(1));
3570
+ if (rest[0] === 'rollback') return cmdPatchRollback(rest.slice(1));
3571
+ if (rest[0] === 'list') return cmdPatchList(rest.slice(1));
3572
+ usage();
3573
+ process.exit(14);
3574
+ break;
3575
+ }
2859
3576
  case 'catalog': {
2860
3577
  if (rest[0] === 'lint') return cmdCatalogLint(rest.slice(1));
2861
3578
  usage();
@@ -2900,9 +3617,18 @@ async function dispatchCommand(cmd, rest) {
2900
3617
  process.exit(14);
2901
3618
  break;
2902
3619
  }
3620
+ case 'attest': {
3621
+ if (rest[0] === 'keygen') return cmdAttestKeygen(rest.slice(1));
3622
+ if (rest[0] === 'verify') return cmdAttestVerify(rest.slice(1));
3623
+ usage();
3624
+ process.exit(14);
3625
+ break;
3626
+ }
2903
3627
  case 'doctor':
2904
3628
  cmdDoctor(rest);
2905
3629
  break;
3630
+ case 'serve':
3631
+ return cmdServe(rest);
2906
3632
  case 'new':
2907
3633
  return cmdNew(rest);
2908
3634
  default: