backend-skeleton 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
package/bin/bskel.mjs ADDED
@@ -0,0 +1,2384 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import { repoRoot, localDefaultBranch } from '../lib/repo.mjs';
9
+ import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, awaitNamedGateDisposition, EXIT } from '../lib/gates.mjs';
10
+ import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
11
+ import { getGate, loadState, historyPath } from '../lib/state.mjs';
12
+ import { writeFileAtomic } from '../lib/fsutil.mjs';
13
+ import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
14
+ import { withLockSync } from '../lib/lock.mjs';
15
+ import { specDir, specPath } from '../lib/paths.mjs';
16
+ import { requireValidFeatureId, requireValidSlug, requireValidFeatureOrRepoId, slugWords, nextFeatureNumber } from '../lib/featureid.mjs';
17
+ import {
18
+ loadFeatureFile, saveFeatureFile, loadFeatureIndex, saveFeatureIndex,
19
+ listFeatures, currentFeatureIdForUid, uidForFeatureId, featureIdInUse,
20
+ renameFeatureArtifacts, archiveFeature, linkFeature,
21
+ } from '../lib/featurelifecycle.mjs';
22
+ import { runScan } from '../scanners/index.mjs';
23
+ import { scanMigrations } from '../scanners/db/migrations.mjs';
24
+ import { introspectSchema, describeConnectionError } from '../scanners/db/introspect.mjs';
25
+ import { renderScanMarkdown, renderPlanConstraints, renderScanExplain } from '../scanners/render.mjs';
26
+ import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
27
+ import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
28
+ import { buildContract, selectModule } from '../contracts/emit.mjs';
29
+ import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
30
+ import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
31
+ import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
32
+ import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
33
+ import {
34
+ requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
35
+ requireValidPythonVersion, requireValidLicense, requireValidDatabase, requireSupportedJavaVersion,
36
+ requireValidPythonProjectName,
37
+ } from '../new/params.mjs';
38
+ import { DEFAULT_GROUP_ID, DEFAULT_JAVA_VERSION, resolveSpringDependencies } from '../new/spring.mjs';
39
+ import { buildReconciliation, snapshotFromReconciliation, describeSourceFile } from '../contracts/openapi.mjs';
40
+ import { buildOpenApiDocument, pathPrefixCandidates, unreflectedPathPrefixes, STATUS_CODE_MODES } from '../contracts/export.mjs';
41
+ import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
42
+ import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
43
+ import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
44
+ import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
45
+ import { computeWorkflowState } from '../lib/workflow.mjs';
46
+ import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
47
+ import { parseCommand, renderCommandHelp, diagnostic } from '../lib/cli.mjs';
48
+ import { EXIT_CODES } from '../lib/exit-codes.mjs';
49
+ import { RESIDUAL_TEMPLATE_VAR_RE } from '../lib/template.mjs';
50
+
51
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
+ const SKILL_ROOT = path.resolve(__dirname, '..');
53
+
54
+ function usage() {
55
+ console.error(`bskel -- backend-skeleton CLI
56
+
57
+ bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none]
58
+ bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
59
+ bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
60
+ bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
61
+ bskel scan explain <module> --feature <id> [--json]
62
+ bskel feature init --slug <name>
63
+ bskel feature list [--all] [--json]
64
+ bskel feature show <id> [--json]
65
+ bskel feature rename <id> --to <new-slug> --reason "..." [--json]
66
+ bskel feature link <keepId> <aliasId> --reason "..." [--json]
67
+ bskel feature archive <id> --reason "..." [--json]
68
+ bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]
69
+ bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
70
+ bskel contract validate --feature <id> --file <envelope.json>
71
+ bskel contract tool-schema --feature <id> --operation <operationId>
72
+ bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..."
73
+ bskel stack apply --choice <id> [--apply] [--port N] [--json]
74
+ bskel catalog lint [<choice>] [--json]
75
+ bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
76
+ bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff]
77
+ bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
78
+ bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
79
+ bskel status [--feature <id>] [--json]
80
+ bskel next [--feature <id>] [--json]
81
+ bskel gate require <name> [--feature <id>] (name: ${GATE_NAMES.join('|')})
82
+ bskel gate force <name> --reason "..." [--feature <id>] [--max-age-minutes N]
83
+ bskel gate revoke <name> --reason "..." [--feature <id>]
84
+ bskel gate history <name> [--feature <id>] [--json]
85
+ bskel gate show [<name>] [--feature <id>]
86
+ bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
87
+ `);
88
+ }
89
+
90
+ // D2 (D-cli-contract): usage()'s exact shape (a single template literal passed directly to
91
+ // console.error) is load-bearing -- test/doc-integrity.test.mjs's regex-based drift guard parses
92
+ // it as source text, so it is never restructured. This is the "same text, but to stdout" bridge
93
+ // for --help/`help`/bare `bskel` -- a local, temporary console.error redirect rather than a second
94
+ // copy of the banner text (which could silently drift from the real one).
95
+ function printUsageToStdout() {
96
+ const original = console.error;
97
+ console.error = console.log;
98
+ try {
99
+ usage();
100
+ } finally {
101
+ console.error = original;
102
+ }
103
+ }
104
+
105
+ // D2: module-level, set once per process right after a command's own parseCommand() succeeds --
106
+ // same lifetime class as `process.exitCode` itself. Threading {json,quiet,command} through every
107
+ // helper function's own parameter list would add noise to ~20 call sites for a value that is, by
108
+ // construction, fixed for the entire life of one `bskel` invocation.
109
+ const CTX = { command: null, json: false, quiet: false };
110
+
111
+ function setContext(command, flags) {
112
+ CTX.command = command;
113
+ CTX.json = Boolean(flags.json);
114
+ CTX.quiet = Boolean(flags.quiet);
115
+ }
116
+
117
+ // Prints the diagnostic envelope to stdout IF --json was requested -- never prints the human
118
+ // message itself (the caller already did, on stderr, possibly across several console.error calls
119
+ // for a multi-line explanation). See DECISIONS.md D-cli-contract: this only ever fires on a
120
+ // PAYLOAD-LESS early exit -- a command whose stdout would otherwise be empty on this path.
121
+ function exitWithDiagnostic(code, reason, message, { next_actions = [] } = {}) {
122
+ if (CTX.json) {
123
+ console.log(JSON.stringify(diagnostic({ command: CTX.command, code, reason, message, next_actions }), null, 2));
124
+ }
125
+ process.exit(code);
126
+ }
127
+
128
+ // The common case: exactly one stderr line, then the (optional) JSON envelope, then exit.
129
+ function fail(code, reason, message, opts = {}) {
130
+ console.error(message);
131
+ exitWithDiagnostic(code, reason, message, opts);
132
+ }
133
+
134
+ function requireRepoRoot() {
135
+ const root = repoRoot();
136
+ if (!root) fail(EXIT_CODES.NOT_A_REPO, 'NOT_A_REPO', 'bskel: not inside a git repository');
137
+ return root;
138
+ }
139
+
140
+ // D2: a gate blocked in a way where the underlying result.code varies (2/3/4 depending on
141
+ // current gate status) still needs the right `reason` for the envelope -- this is the one place
142
+ // that mapping lives, reused by every "some other gate must pass first" check below.
143
+ function gateReasonForCode(code) {
144
+ if (code === EXIT.AWAITING_DISPOSITION) return 'GATE_AWAITING_DISPOSITION';
145
+ if (code === EXIT.STALE) return 'GATE_STALE';
146
+ return 'GATE_NOT_PASSED';
147
+ }
148
+
149
+ function cmdPreflight(args) {
150
+ const flags = parseCommand('preflight', args);
151
+ if (flags.help) { console.log(renderCommandHelp('preflight')); process.exit(0); }
152
+ setContext('preflight', flags);
153
+ const root = requireRepoRoot();
154
+ const scriptPath = path.join(SKILL_ROOT, 'scripts', 'preflight-base-ref.sh');
155
+ const fetchTimeoutSeconds = Number(flags['fetch-timeout-seconds']);
156
+ const scriptArgs = ['--max-behind', flags['max-behind'], '--fetch-timeout-seconds', flags['fetch-timeout-seconds']];
157
+ // D-preflight-freshness (S3): --no-fetch is kept as an exact alias for --offline (both flow
158
+ // through to the script's own --offline, which also accepts --no-fetch) -- see lib/cli.mjs's
159
+ // COMMANDS.preflight for why both flags are declared.
160
+ if (flags.offline || flags['no-fetch']) scriptArgs.push('--offline');
161
+ if (flags['allow-dirty']) scriptArgs.push('--allow-dirty');
162
+ scriptArgs.push('--json');
163
+
164
+ let stdout;
165
+ let exitCode = 0;
166
+ try {
167
+ // D-preflight-freshness (S3): a backstop for transports `http.lowSpeedLimit`/
168
+ // `http.lowSpeedTime` (set inside the script) don't cover -- a local-path or ssh remote
169
+ // that simply hangs. +10s over the script's own fetch timeout so the script's own
170
+ // REFRESH_FAILED message (which explains WHY) has a chance to win the race.
171
+ stdout = execFileSync(scriptPath, scriptArgs, { cwd: root, encoding: 'utf8', timeout: (fetchTimeoutSeconds + 10) * 1000 });
172
+ } catch (err) {
173
+ stdout = err.stdout ?? '';
174
+ exitCode = err.status ?? 1;
175
+ }
176
+ let result;
177
+ try {
178
+ result = JSON.parse(stdout);
179
+ } catch {
180
+ // Only reachable if the Node-side timeout above fired before the script produced any
181
+ // output at all (or the script crashed outside its own fail()/JSON paths) -- the script
182
+ // itself always emits a JSON verdict on every path `bskel` cares about.
183
+ fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `preflight check timed out or produced no output after ${fetchTimeoutSeconds}s -- fix connectivity, or re-run with --offline to accept a local-only verdict`);
184
+ }
185
+
186
+ if (result.verdict === 'PASS') {
187
+ const evidence = { ...result.evidence, freshness: { max_age_minutes: Number(flags['max-age-minutes']) } };
188
+ passNamedGate(root, 'preflight', null, evidence);
189
+ // detection only, never a silent fix -- see D-openapi-reconciliation's path_prefix_signals
190
+ // precedent for the same "point it out, don't touch the user's repo metadata" stance.
191
+ if (evidence.default_branch && !localDefaultBranch(root)) {
192
+ console.error(`note: origin/HEAD is not set locally, so the preflight gate cannot detect remote-tracking movement -- run \`git remote set-head origin ${evidence.default_branch}\` (local-only) to enable it.`);
193
+ }
194
+ }
195
+
196
+ if (flags.json) {
197
+ console.log(stdout.trim());
198
+ } else if (result.verdict === 'PASS') {
199
+ if (!flags.quiet) console.log(`PASS: HEAD is up to date with origin/${result.evidence.default_branch}`);
200
+ } else {
201
+ console.error(`FAIL (${result.reason}): ${result.message}`);
202
+ }
203
+ process.exit(exitCode);
204
+ }
205
+
206
+ // S1: validates a gate name against the shared definitions, and the --feature/--repo scope
207
+ // shape (D-security-3), in one place shared by require/force/show. Before this, an unknown
208
+ // gate name silently reported `not_run` (exit 2) from `getGate`/`requireGate` -- indistinguishable
209
+ // from "a real gate that just hasn't run yet" -- so a typo read as "not done" instead of
210
+ // "this gate doesn't exist".
211
+ function resolveGateArg(gateName, featureFlag) {
212
+ let def;
213
+ try {
214
+ def = requireGateDefinition(gateName);
215
+ } catch (err) {
216
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
217
+ }
218
+ try {
219
+ requireValidFeatureOrRepoId(featureFlag, REPO_GATE_ID);
220
+ } catch (err) {
221
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
222
+ }
223
+ return { def, scopeId: gateScopeId(gateName, featureFlag) };
224
+ }
225
+
226
+ function cmdGateRequire(args) {
227
+ const flags = parseCommand('gate require', args);
228
+ if (flags.help) { console.log(renderCommandHelp('gate require')); process.exit(0); }
229
+ setContext('gate require', flags);
230
+ const root = requireRepoRoot();
231
+ const gateName = flags._[0];
232
+ if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate require <name> [--feature <id>]');
233
+ resolveGateArg(gateName, flags.feature);
234
+ // `require` never re-runs the underlying check (e.g. it doesn't re-fetch or re-scan) -- it
235
+ // freshly recomputes only the cheap, local inputs the gate's token was built from (see
236
+ // lib/gate-definitions.mjs) and compares against what was stored when the gate last passed.
237
+ const result = requireNamedGate(root, gateName, flags.feature);
238
+ console.log(JSON.stringify({ gate: gateName, feature: flags.feature, ...result }));
239
+ process.exit(result.code);
240
+ }
241
+
242
+ // S4 (D-gate-history): `--max-age-minutes`, unlike preflight's own same-named flag, has no
243
+ // default -- opt-in only, so an un-timed force never silently starts expiring (see
244
+ // checkFreshness()'s own comment in lib/gates.mjs for why a forced record never inherits the
245
+ // underlying gate's TTL policy).
246
+ function parseForceMaxAge(raw) {
247
+ if (raw == null) return null;
248
+ const n = Number(raw);
249
+ if (!Number.isFinite(n) || n < 0) {
250
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--max-age-minutes must be a non-negative number, got "${raw}"`);
251
+ }
252
+ return n;
253
+ }
254
+
255
+ function cmdGateForce(args) {
256
+ const flags = parseCommand('gate force', args);
257
+ if (flags.help) { console.log(renderCommandHelp('gate force')); process.exit(0); }
258
+ setContext('gate force', flags);
259
+ const root = requireRepoRoot();
260
+ const gateName = flags._[0];
261
+ if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate force <name> --reason "..." [--feature <id>] [--max-age-minutes N]');
262
+ resolveGateArg(gateName, flags.feature);
263
+ const maxAgeMinutes = parseForceMaxAge(flags['max-age-minutes']);
264
+ let state;
265
+ try {
266
+ state = forceNamedGate(root, gateName, flags.feature, flags.reason, { maxAgeMinutes });
267
+ } catch (err) {
268
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
269
+ }
270
+ console.log(JSON.stringify(state.gates[gateName]));
271
+ process.exit(EXIT.PASS);
272
+ }
273
+
274
+ // S4 (D-gate-history): un-passes a gate. Distinct from `force` (which asserts a pass a check
275
+ // couldn't earn) -- revoke retracts one that's already there, e.g. a human decides a prior force
276
+ // or a stale-but-still-token-matching pass shouldn't be trusted after all.
277
+ function cmdGateRevoke(args) {
278
+ const flags = parseCommand('gate revoke', args);
279
+ if (flags.help) { console.log(renderCommandHelp('gate revoke')); process.exit(0); }
280
+ setContext('gate revoke', flags);
281
+ const root = requireRepoRoot();
282
+ const gateName = flags._[0];
283
+ if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate revoke <name> --reason "..." [--feature <id>]');
284
+ resolveGateArg(gateName, flags.feature);
285
+ let state;
286
+ try {
287
+ state = revokeNamedGate(root, gateName, flags.feature, flags.reason);
288
+ } catch (err) {
289
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
290
+ }
291
+ console.log(JSON.stringify(state.gates[gateName]));
292
+ process.exit(EXIT_CODES.NOT_PASSED);
293
+ }
294
+
295
+ // S4 (D-gate-history): reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid
296
+ // line is skipped with a warning, not a hard failure, matching JSONL's own resilience rationale
297
+ // (see lib/state.mjs's appendGateEvent).
298
+ function readGateHistory(root, featureId, gateName) {
299
+ const file = historyPath(root, featureId);
300
+ if (!fs.existsSync(file)) return [];
301
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
302
+ const events = [];
303
+ for (const [i, line] of lines.entries()) {
304
+ let parsed;
305
+ try {
306
+ parsed = JSON.parse(line);
307
+ } catch {
308
+ console.error(`warning: ${file}:${i + 1}: not valid JSON, skipped`);
309
+ continue;
310
+ }
311
+ const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
312
+ if (!ok) {
313
+ console.error(`warning: ${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped:\n${formatSchemaErrors(errors).join('\n')}`);
314
+ continue;
315
+ }
316
+ if (parsed.gate === gateName) events.push(parsed);
317
+ }
318
+ return events;
319
+ }
320
+
321
+ function cmdGateHistory(args) {
322
+ const flags = parseCommand('gate history', args);
323
+ if (flags.help) { console.log(renderCommandHelp('gate history')); process.exit(0); }
324
+ setContext('gate history', flags);
325
+ const root = requireRepoRoot();
326
+ const gateName = flags._[0];
327
+ if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate history <name> [--feature <id>] [--json]');
328
+ resolveGateArg(gateName, flags.feature);
329
+ const events = readGateHistory(root, flags.feature, gateName);
330
+ if (flags.json) {
331
+ console.log(JSON.stringify(events, null, 2));
332
+ } else if (events.length === 0) {
333
+ console.log(`no history recorded for gate "${gateName}" (feature ${flags.feature})`);
334
+ } else {
335
+ for (const e of events) {
336
+ const detail = e.event === 'force' || e.event === 'revoke' ? ` -- ${e.reason}` : '';
337
+ console.log(`${e.at} ${e.event.padEnd(20)} status=${e.status}${detail}`);
338
+ }
339
+ }
340
+ process.exit(0);
341
+ }
342
+
343
+ function cmdGateShow(args) {
344
+ const flags = parseCommand('gate show', args);
345
+ if (flags.help) { console.log(renderCommandHelp('gate show')); process.exit(0); }
346
+ setContext('gate show', flags);
347
+ const root = requireRepoRoot();
348
+ const gateName = flags._[0] ?? null;
349
+ if (gateName === null) {
350
+ try {
351
+ requireValidFeatureOrRepoId(flags.feature, REPO_GATE_ID);
352
+ } catch (err) {
353
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
354
+ }
355
+ console.log(JSON.stringify(loadState(root, flags.feature), null, 2));
356
+ process.exit(0);
357
+ }
358
+ const { scopeId } = resolveGateArg(gateName, flags.feature);
359
+ console.log(JSON.stringify({ gate: gateName, feature: scopeId, record: getGate(root, scopeId, gateName) }, null, 2));
360
+ process.exit(0);
361
+ }
362
+
363
+ // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
364
+ // SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
365
+ // Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
366
+ // utility outside the gated workflow, same as `bskel gate show`.
367
+ function requirePreflightPassed(root) {
368
+ const result = requireNamedGate(root, 'preflight', null);
369
+ if (result.code !== EXIT.PASS) {
370
+ fail(result.code, gateReasonForCode(result.code), `blocked: \`preflight\` gate is ${result.status} -- run \`bskel preflight\` first.`, {
371
+ next_actions: [{ command: 'bskel preflight', reason: 'the preflight gate has not passed yet', mutating: true }],
372
+ });
373
+ }
374
+ }
375
+
376
+ const DISPOSITION_MODES = ['reuse', 'extend', 'replace', 'parallel'];
377
+
378
+ function deriveTerms(flags) {
379
+ const fromFlag = (flags.terms || '').split(',').map((s) => s.trim()).filter(Boolean);
380
+ const fromFeature = flags.feature ? slugWords(flags.feature) : [];
381
+ return [...new Set([...fromFlag, ...fromFeature])];
382
+ }
383
+
384
+ // A4 (D-db-schema-plane): resolves --db/--database-url-env into an already-computed `dbSchema`
385
+ // object BEFORE runScan() is ever called -- env var resolution and the live DB connection itself
386
+ // are CLI-boundary concerns (this function owns fail()/exit codes; scanners/index.mjs stays a
387
+ // synchronous, DB-I/O-free function). Returns null when --db wasn't passed at all (today's exact
388
+ // prior behavior, byte-identical). `--database-url-env` naming an unset variable is BAD_ARGS (a
389
+ // usage mistake); a real connection failure is REFRESH_FAILED (reused, not a new exit code --
390
+ // matches D2's conservatism, and is the same code `preflight`'s own "reached out to something
391
+ // external and failed" case already uses).
392
+ async function resolveDbSchemaOrExit(root, flags) {
393
+ if (!flags.db) return null;
394
+ const migrations = scanMigrations(root);
395
+ if (!flags['database-url-env']) return { migrations, live: null };
396
+
397
+ const connectionString = process.env[flags['database-url-env']];
398
+ if (!connectionString) {
399
+ 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)`);
400
+ }
401
+ let live;
402
+ try {
403
+ live = await introspectSchema({ connectionString, schema: flags.schema });
404
+ } catch (err) {
405
+ fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not introspect the live database: ${describeConnectionError(err)}`);
406
+ }
407
+ return { migrations, live };
408
+ }
409
+
410
+ async function cmdScan(args) {
411
+ const flags = parseCommand('scan', args);
412
+ if (flags.help) { console.log(renderCommandHelp('scan')); process.exit(0); }
413
+ setContext('scan', flags);
414
+ const root = requireRepoRoot();
415
+ const terms = deriveTerms(flags);
416
+ if (terms.length === 0) {
417
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan [--feature <id>] --terms a,b,c (need at least one search term, from --terms or a --feature slug)');
418
+ }
419
+ if (flags.feature) {
420
+ requireValidFeatureId(flags.feature);
421
+ requirePreflightPassed(root);
422
+ }
423
+
424
+ const dbSchema = await resolveDbSchemaOrExit(root, flags);
425
+
426
+ // G1: a broken adapter file doesn't stop the adapters that DID load, but every `scan` run
427
+ // says so loudly (also see `bskel doctor`, which exits 1 while any of these remain).
428
+ for (const e of LOAD_ERRORS) {
429
+ console.error(`warning: scanner adapter failed to load (${e.file}): ${e.message}`);
430
+ }
431
+ let report;
432
+ try {
433
+ report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema });
434
+ } catch (err) {
435
+ // Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
436
+ // unconditional) -- becomes reachable the moment a future adapter's detect() is
437
+ // conditional, or two adapters tie at the same specificity. See scanners/index.mjs.
438
+ fail(EXIT_CODES.NOT_PASSED, 'SCAN_FAILED', err.message);
439
+ }
440
+ if (flags.feature) report.feature_id = flags.feature;
441
+
442
+ if (!flags.feature) {
443
+ // Ad-hoc mode: no feature_id, no files written, no gate touched -- matches the plan's own
444
+ // example invocation `bskel scan --terms organization` for a quick look before committing
445
+ // to a feature_id.
446
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
447
+ else if (!flags.quiet) console.log(renderScanMarkdown(report));
448
+ // Process-exit audit (post-A3): exitCode, not exit() -- a scan report for a broad term can
449
+ // be large (reproduced live: `scan --terms a --json` against Team-IZ-Backend is 177583
450
+ // bytes; captured via a pipe with the old process.exit(0) here, it truncated at exactly
451
+ // 65536 bytes, the same pipe-buffer-sized cutoff A3 found and fixed in cmdContractEmit).
452
+ // This is a guard-clause exit (more code follows below for the --feature path), so the
453
+ // `return` is required -- exitCode alone does not stop execution the way exit() did.
454
+ process.exitCode = 0;
455
+ return;
456
+ }
457
+
458
+ // G3: a low-confidence (generic-grep) scan writes nothing and touches no gate without explicit
459
+ // acknowledgment -- regardless of verdict, including greenfield, which used to auto-pass the
460
+ // scan gate with zero confidence-awareness. The contract stage already refuses a zero-operation
461
+ // contract unconditionally (A5, contracts/completeness.mjs), but generic-grep's route-pattern
462
+ // grep can still mis-score a "collision"/"adjacent" verdict a human would act on in `scan
463
+ // disposition` -- see D-generic-grep-reconnaissance in DECISIONS.md.
464
+ if (report.confidence === 'low' && !flags['accept-low-confidence']) {
465
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
466
+ else if (!flags.quiet) console.log(renderScanMarkdown(report));
467
+ console.error(
468
+ '\nblocked: this scan used the low-confidence generic-grep adapter (route-pattern grep, ' +
469
+ 'not a real parser -- collapsed evidence, no operation IDs, never contract-grade). Re-run ' +
470
+ 'with --accept-low-confidence to proceed, or point this at a java-spring-shaped repo / use ' +
471
+ '--openapi-file at contract emit for a trustworthy result.',
472
+ );
473
+ // D-process-exit-audit: bounded by the report size already audited for the ad-hoc branch
474
+ // above (same report object, same command) -- no pipe-truncation risk. This exit carries a
475
+ // real payload (the report, already printed above) -- no diagnostic envelope on top of it.
476
+ process.exit(16);
477
+ }
478
+
479
+ const dir = specDir(root, flags.feature);
480
+ fs.mkdirSync(dir, { recursive: true });
481
+ writeScanReportOrExit(specPath(root, flags.feature, 'brownfield-scan.json'), report);
482
+ writeFileAtomic(specPath(root, flags.feature, 'brownfield-scan.md'), renderScanMarkdown(report));
483
+
484
+ let gateState;
485
+ if (report.verdict === 'greenfield') {
486
+ gateState = passNamedGate(root, 'scan', flags.feature, { verdict: report.verdict });
487
+ } else {
488
+ gateState = awaitNamedGateDisposition(root, 'scan', flags.feature, {
489
+ verdict: report.verdict,
490
+ related_modules: report.related_modules.map((m) => m.module),
491
+ });
492
+ }
493
+
494
+ if (flags.json) {
495
+ console.log(JSON.stringify(report, null, 2));
496
+ } else if (!flags.quiet) {
497
+ console.log(renderScanMarkdown(report));
498
+ console.log(`gate: scan -> ${gateState.gates.scan.status}`);
499
+ if (report.verdict !== 'greenfield') {
500
+ console.log(`\nblocked: run \`bskel scan disposition --feature ${flags.feature} --mode reuse|extend|replace|parallel --note "..."\` before continuing.`);
501
+ }
502
+ }
503
+ // Same truncation risk as the ad-hoc branch above, and the last statement in this function --
504
+ // safe to set exitCode directly, nothing else pending in this call path.
505
+ process.exitCode = report.verdict === 'greenfield' ? EXIT.PASS : EXIT.AWAITING_DISPOSITION;
506
+ }
507
+
508
+ function cmdScanDisposition(args) {
509
+ const flags = parseCommand('scan disposition', args);
510
+ if (flags.help) { console.log(renderCommandHelp('scan disposition')); process.exit(0); }
511
+ setContext('scan disposition', flags);
512
+ const root = requireRepoRoot();
513
+ requireValidFeatureId(flags.feature);
514
+ if (!DISPOSITION_MODES.includes(flags.mode)) {
515
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--mode must be one of: ${DISPOSITION_MODES.join(', ')}`);
516
+ }
517
+ if (flags.mode === 'replace' && !flags['breaking-approved']) {
518
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--mode replace requires --breaking-approved (this is a deliberate speed bump, not a bug)');
519
+ }
520
+
521
+ const reportPath = specPath(root, flags.feature, 'brownfield-scan.json');
522
+ const report = loadScanReportOrExit(root, flags.feature);
523
+ // S2 (D-gate-precision, part 2): if named explicitly, must be real -- same "fail loud, name
524
+ // the real choices" shape cmdScanExplain's unknown-module error already uses. If omitted,
525
+ // reuses selectModule()'s own default (the top-scored module) so a --module-less disposition
526
+ // never silently disagrees with what `contract emit`/`handles plan` would ALSO pick by
527
+ // default.
528
+ if (flags.module && !report.related_modules.some((m) => m.module === flags.module)) {
529
+ const known = report.related_modules.map((m) => m.module).join(', ') || '(none)';
530
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--module "${flags.module}" is not one of this scan report's related_modules -- known modules: ${known}`);
531
+ }
532
+ const dispositionModule = flags.module ?? selectModule(report, null)?.module ?? null;
533
+ report.feature_id = flags.feature;
534
+ report.disposition = { mode: flags.mode, note: flags.note, module: dispositionModule, at: new Date().toISOString() };
535
+ writeScanReportOrExit(reportPath, report);
536
+ writeFileAtomic(specPath(root, flags.feature, 'brownfield-scan.md'), renderScanMarkdown(report));
537
+ const planConstraints = renderPlanConstraints(report);
538
+ if (planConstraints) {
539
+ writeFileAtomic(specPath(root, flags.feature, 'plan-constraints.md'), planConstraints);
540
+ }
541
+
542
+ const gateState = passNamedGate(root, 'scan', flags.feature, { verdict: report.verdict, disposition_mode: flags.mode });
543
+ console.log(JSON.stringify(gateState.gates.scan));
544
+ process.exit(EXIT.PASS);
545
+ }
546
+
547
+ // D-scanner-evidence (D3): reads the ALREADY-PERSISTED scan report (loadScanReportOrExit, same
548
+ // validated choke point every other scan-report reader uses) rather than recomputing evidence --
549
+ // `bskel scan` is the only place evidence is ever calculated; this command only explains what
550
+ // that run already found and wrote to disk.
551
+ function cmdScanExplain(args) {
552
+ const flags = parseCommand('scan explain', args);
553
+ if (flags.help) { console.log(renderCommandHelp('scan explain')); process.exit(0); }
554
+ setContext('scan explain', flags);
555
+ const root = requireRepoRoot();
556
+ requireValidFeatureId(flags.feature);
557
+ const moduleName = flags._[0];
558
+ if (!moduleName) {
559
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan explain <module> --feature <id> [--json]');
560
+ }
561
+ const report = loadScanReportOrExit(root, flags.feature);
562
+ const mod = report.related_modules.find((m) => m.module === moduleName);
563
+ if (!mod) {
564
+ const known = report.related_modules.map((m) => m.module).join(', ') || '(none)';
565
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no module "${moduleName}" in this scan report's related_modules -- known modules: ${known}`);
566
+ }
567
+ if (flags.json) {
568
+ console.log(JSON.stringify(mod, null, 2));
569
+ } else {
570
+ console.log(renderScanExplain(mod));
571
+ }
572
+ process.exit(0);
573
+ }
574
+
575
+ // D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
576
+ // load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
577
+ // during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
578
+ // two concurrent `feature init` calls (same slug) could silently overwrite feature.json, and
579
+ // feature-index.json's own load->modify->save raced independently of that. `'feature-index'` is
580
+ // a distinct lock name from `'state'` (gate/waiver writes) so this doesn't unnecessarily
581
+ // serialize against unrelated `gate`/`contract waive` calls.
582
+ function cmdFeatureInit(args) {
583
+ const flags = parseCommand('feature init', args);
584
+ if (flags.help) { console.log(renderCommandHelp('feature init')); process.exit(0); }
585
+ setContext('feature init', flags);
586
+ const root = requireRepoRoot();
587
+ requirePreflightPassed(root);
588
+ requireValidSlug(flags.slug);
589
+
590
+ const record = withLockSync(root, 'feature-index', () => {
591
+ const featureId = `${nextFeatureNumber(path.join(root, 'specs'))}-${flags.slug}`;
592
+ const featureUid = randomUUID();
593
+ const rec = { schema: 'sbf.feature/1', feature_id: featureId, feature_uid: featureUid, created_at: new Date().toISOString() };
594
+ saveFeatureFile(root, featureId, rec);
595
+
596
+ const index = loadFeatureIndex(root);
597
+ index.by_uid[featureUid] = [featureId];
598
+ saveFeatureIndex(root, index);
599
+
600
+ return rec;
601
+ });
602
+
603
+ console.log(JSON.stringify(record));
604
+ process.exit(0);
605
+ }
606
+
607
+ function loadFeatureRecord(root, featureId) {
608
+ const record = loadFeatureFile(root, featureId);
609
+ if (!record) {
610
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no feature.json at specs/${featureId}/ -- run \`bskel feature init --slug ${slugWords(featureId).join('-')}\` first (or hand-write specs/${featureId}/feature.json with a minted feature_uid)`);
611
+ }
612
+ return record;
613
+ }
614
+
615
+ // D6: scans specs/*/feature.json directly (lib/featurelifecycle.mjs::listFeatures) -- archived
616
+ // features are hidden by default, --all shows everything.
617
+ function cmdFeatureList(args) {
618
+ const flags = parseCommand('feature list', args);
619
+ if (flags.help) { console.log(renderCommandHelp('feature list')); process.exit(0); }
620
+ setContext('feature list', flags);
621
+ const root = requireRepoRoot();
622
+ const records = listFeatures(root, { includeArchived: flags.all });
623
+
624
+ if (flags.json) {
625
+ console.log(JSON.stringify(records, null, 2));
626
+ } else if (records.length === 0) {
627
+ console.log('no features found -- run `bskel feature init --slug <name>` to create one.');
628
+ } else {
629
+ for (const r of records) {
630
+ const archivedNote = r.archived_at ? ` [archived: ${r.archived_reason}]` : '';
631
+ console.log(`${r.feature_id} ${r.feature_uid} ${r.created_at}${archivedNote}`);
632
+ }
633
+ }
634
+ process.exit(0);
635
+ }
636
+
637
+ // D6: feature-IDENTITY metadata (id/uid/created_at/archived/rename history/merge cross-
638
+ // reference/artifact-existence summary) -- deliberately NOT gate/workflow status, which
639
+ // `bskel status --feature <id>` (D1) already owns. Confirmed non-overlapping by reading
640
+ // cmdStatus's own output shape before designing this.
641
+ function cmdFeatureShow(args) {
642
+ const flags = parseCommand('feature show', args);
643
+ if (flags.help) { console.log(renderCommandHelp('feature show')); process.exit(0); }
644
+ setContext('feature show', flags);
645
+ const root = requireRepoRoot();
646
+ const featureId = flags._[0];
647
+ if (!featureId) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel feature show <id> [--json]');
648
+ requireValidFeatureId(featureId);
649
+ const record = loadFeatureRecord(root, featureId);
650
+
651
+ const index = loadFeatureIndex(root);
652
+ const uid = uidForFeatureId(index, featureId);
653
+ const renameHistory = uid ? index.by_uid[uid] : [featureId];
654
+ const mergedInto = index.merged_into?.[featureId] ?? null;
655
+ const artifacts = {
656
+ contract_emitted: fs.existsSync(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
657
+ handles_migration_present: fs.existsSync(specPath(root, featureId, 'handles', 'migration.sql')),
658
+ };
659
+
660
+ if (flags.json) {
661
+ console.log(JSON.stringify({ ...record, rename_history: renameHistory, merged_into: mergedInto, artifacts }, null, 2));
662
+ } else {
663
+ console.log(`# ${record.feature_id}`);
664
+ console.log(`- feature_uid: ${record.feature_uid}`);
665
+ console.log(`- created_at: ${record.created_at}`);
666
+ if (record.archived_at) console.log(`- archived: ${record.archived_at} (${record.archived_reason})`);
667
+ if (renameHistory.length > 1) console.log(`- previously known as: ${renameHistory.slice(0, -1).join(', ')}`);
668
+ if (mergedInto) console.log(`- merged into: ${mergedInto} (specs/handles/gate state were NOT moved -- see \`bskel feature link\`)`);
669
+ console.log(`- contract emitted: ${artifacts.contract_emitted}`);
670
+ console.log(`- handles migration present: ${artifacts.handles_migration_present}`);
671
+ }
672
+ process.exit(0);
673
+ }
674
+
675
+ function requireLifecycleReason(commandName, reason) {
676
+ if (!reason || !reason.trim()) {
677
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${commandName} requires --reason "..." -- every identity change must be auditable`);
678
+ }
679
+ }
680
+
681
+ // D6: a bskel-internal control-flow error carrying a real exit code/reason -- thrown from INSIDE
682
+ // a withLockSync() callback and caught OUTSIDE it, after the lock has actually been released.
683
+ // Found live, not designed in from the start: an early draft called fail() (which calls
684
+ // process.exit() directly) from inside the locked callback for a collision/missing-feature
685
+ // check -- process.exit() does NOT run pending `finally` blocks the way a thrown exception does,
686
+ // so lib/lock.mjs's own `finally { fs.rmSync(lockPath) }` never ran, leaving the lock directory
687
+ // behind forever and hanging every subsequent `feature init`/`rename`/`link` call in that repo.
688
+ // A real `throw` (unlike process.exit()) DOES unwind through withLockSync's finally correctly --
689
+ // this class exists so the CLI-facing exit code/reason survive that unwind to be reported once
690
+ // safely outside the lock.
691
+ class LockedCommandFailure extends Error {
692
+ constructor(code, reason, message) {
693
+ super(message);
694
+ this.code = code;
695
+ this.reason = reason;
696
+ }
697
+ }
698
+
699
+ function runLockedOrFail(root, lockName, fn) {
700
+ try {
701
+ return withLockSync(root, lockName, fn);
702
+ } catch (err) {
703
+ if (err instanceof LockedCommandFailure) fail(err.code, err.reason, err.message);
704
+ throw err;
705
+ }
706
+ }
707
+
708
+ // D6: the whole validate-then-migrate-then-index-update sequence runs under the SAME
709
+ // 'feature-index' lock `feature init` uses -- a rename racing another rename (or an init) must
710
+ // not interleave. Collision is checked INSIDE the lock, right before any mutation, to avoid a
711
+ // TOCTOU on the check itself; slug/reason validation happens outside the lock (fails fast, no
712
+ // need to hold it for a purely local validation). See lib/featurelifecycle.mjs::
713
+ // renameFeatureArtifacts() for the full migration this performs.
714
+ function cmdFeatureRename(args) {
715
+ const flags = parseCommand('feature rename', args);
716
+ if (flags.help) { console.log(renderCommandHelp('feature rename')); process.exit(0); }
717
+ setContext('feature rename', flags);
718
+ const root = requireRepoRoot();
719
+ const oldId = flags._[0];
720
+ if (!oldId) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel feature rename <id> --to <new-slug> --reason "..." [--json]');
721
+ requireValidFeatureId(oldId);
722
+ requireValidSlug(flags.to);
723
+ requireLifecycleReason('bskel feature rename', flags.reason);
724
+
725
+ const record = runLockedOrFail(root, 'feature-index', () => {
726
+ const existing = loadFeatureFile(root, oldId);
727
+ if (!existing) {
728
+ throw new LockedCommandFailure(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no feature.json at specs/${oldId}/`);
729
+ }
730
+ const nnn = oldId.match(/^[0-9]{3}/)[0];
731
+ const newId = `${nnn}-${flags.to}`;
732
+ if (newId === oldId) {
733
+ throw new LockedCommandFailure(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--to "${flags.to}" produces the same feature_id ("${newId}") -- nothing to rename`);
734
+ }
735
+ const index = loadFeatureIndex(root);
736
+ if (featureIdInUse(root, index, newId)) {
737
+ throw new LockedCommandFailure(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${newId}" is already in use (an existing specs/ directory or a retired id already in the feature index) -- choose a different --to`);
738
+ }
739
+
740
+ renameFeatureArtifacts(root, oldId, newId);
741
+
742
+ index.by_uid[existing.feature_uid] = [...(index.by_uid[existing.feature_uid] ?? [oldId]), newId];
743
+ saveFeatureIndex(root, index);
744
+
745
+ return loadFeatureFile(root, newId);
746
+ });
747
+
748
+ console.log(flags.json ? JSON.stringify(record, null, 2) : `renamed ${oldId} -> ${record.feature_id}`);
749
+ process.exit(0);
750
+ }
751
+
752
+ // D6: index-only (lib/featurelifecycle.mjs::linkFeature) -- deliberately does NOT touch either
753
+ // feature's specs/.sbf/ artifacts or attempt to merge scan/contract/handles state. See
754
+ // DECISIONS.md D-feature-lifecycle for why an automatic merge was rejected.
755
+ function cmdFeatureLink(args) {
756
+ const flags = parseCommand('feature link', args);
757
+ if (flags.help) { console.log(renderCommandHelp('feature link')); process.exit(0); }
758
+ setContext('feature link', flags);
759
+ const root = requireRepoRoot();
760
+ const keepId = flags._[0];
761
+ const aliasId = flags._[1];
762
+ if (!keepId || !aliasId) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel feature link <keepId> <aliasId> --reason "..." [--json]');
763
+ requireValidFeatureId(keepId);
764
+ requireValidFeatureId(aliasId);
765
+ if (keepId === aliasId) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel feature link requires two DIFFERENT feature ids');
766
+ requireLifecycleReason('bskel feature link', flags.reason);
767
+
768
+ const index = runLockedOrFail(root, 'feature-index', () => {
769
+ for (const id of [keepId, aliasId]) {
770
+ if (!loadFeatureFile(root, id)) {
771
+ throw new LockedCommandFailure(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no feature.json at specs/${id}/`);
772
+ }
773
+ }
774
+ const idx = loadFeatureIndex(root);
775
+ linkFeature(idx, keepId, aliasId);
776
+ saveFeatureIndex(root, idx);
777
+ return idx;
778
+ });
779
+
780
+ if (flags.json) {
781
+ console.log(JSON.stringify({ merged_into: index.merged_into }, null, 2));
782
+ } else {
783
+ console.log(`${aliasId} is now linked to ${keepId} -- specs/ and .sbf/ state for BOTH features are unchanged, this only records the cross-reference (reason: ${flags.reason})`);
784
+ }
785
+ process.exit(0);
786
+ }
787
+
788
+ // D6: soft-delete only (lib/featurelifecycle.mjs::archiveFeature) -- sets archived_at in place,
789
+ // no filesystem move, no lock needed (a single feature.json write, no cross-file coordination).
790
+ function cmdFeatureArchive(args) {
791
+ const flags = parseCommand('feature archive', args);
792
+ if (flags.help) { console.log(renderCommandHelp('feature archive')); process.exit(0); }
793
+ setContext('feature archive', flags);
794
+ const root = requireRepoRoot();
795
+ const featureId = flags._[0];
796
+ if (!featureId) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel feature archive <id> --reason "..." [--json]');
797
+ requireValidFeatureId(featureId);
798
+ requireLifecycleReason('bskel feature archive', flags.reason);
799
+ loadFeatureRecord(root, featureId); // exits cleanly if the feature doesn't exist
800
+ const updated = archiveFeature(root, featureId, flags.reason);
801
+
802
+ console.log(flags.json ? JSON.stringify(updated, null, 2) : `archived ${featureId} (${flags.reason})`);
803
+ process.exit(0);
804
+ }
805
+
806
+ // G1: intercepts BEFORE any adapter-specific codegen runs (in particular, before
807
+ // detectBasePackageOrExit's Spring-only base-package detection below) -- a repo scanned by an
808
+ // adapter that doesn't declare what this command needs gets an honest, actionable message
809
+ // instead of a confusing framework-specific failure. Before this existed, a generic-grep-scanned
810
+ // repo's ONLY visible error at `handles plan`/`handles emit` was detectBasePackageOrExit's "is
811
+ // this a Spring Boot project?" -- which reads as a broken Spring detector, not "the adapter that
812
+ // scanned this repo doesn't support handle codegen". See D-adapter-registry in DECISIONS.md.
813
+ // G2: `satisfiedBy` is a Set of flag names the caller has already confirmed were passed (e.g.
814
+ // `--openapi-file`) -- when a missing capability has a CAPABILITY_SATISFIERS entry and its flag is
815
+ // in this set, the check is skipped for that capability specifically. See CAPABILITY_SATISFIERS in
816
+ // scanners/capabilities.mjs for why this lives as data there, not as adapter- or command-specific
817
+ // logic here.
818
+ function requireCapabilitiesOrExit(scanReport, command, { featureId, scanReportPath, satisfiedBy = new Set() }) {
819
+ const adapter = adapterById(ADAPTERS, scanReport.adapter);
820
+ if (!adapter) {
821
+ const loadErr = LOAD_ERRORS.find((e) => path.basename(e.file, '.mjs') === scanReport.adapter);
822
+ const message = loadErr
823
+ ? `blocked: the "${scanReport.adapter}" adapter that produced this scan report failed to load: ${loadErr.message}`
824
+ : `blocked: this scan report was produced by adapter "${scanReport.adapter}", which this installed version of backend-skeleton does not have -- re-run \`bskel scan --feature ${featureId}\`.`;
825
+ fail(EXIT_CODES.NOT_PASSED, 'ADAPTER_UNAVAILABLE', message);
826
+ }
827
+ for (const capability of COMMAND_CAPABILITIES[command] ?? []) {
828
+ if (adapter.capabilities[capability]) continue;
829
+ const satisfier = CAPABILITY_SATISFIERS[capability];
830
+ if (satisfier && satisfiedBy.has(satisfier.flag)) continue;
831
+ fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', explainMissingCapability({ adapterId: adapter.id, capability, command, featureId, scanReportPath }));
832
+ }
833
+ }
834
+
835
+ function cmdContractEmit(args) {
836
+ const flags = parseCommand('contract emit', args);
837
+ if (flags.help) { console.log(renderCommandHelp('contract emit')); process.exit(0); }
838
+ setContext('contract emit', flags);
839
+ const root = requireRepoRoot();
840
+ requirePreflightPassed(root);
841
+ requireValidFeatureId(flags.feature);
842
+
843
+ // Contract emission is only meaningful once the scan gate has actually passed (greenfield
844
+ // auto-pass, or a recorded disposition) -- an unresolved collision must not be allowed to
845
+ // silently flow into a contract as if it had been addressed.
846
+ const scanResult = requireNamedGate(root, 'scan', flags.feature);
847
+ if (scanResult.code !== EXIT.PASS) {
848
+ fail(scanResult.code, gateReasonForCode(scanResult.code), `blocked: \`scan\` gate for ${flags.feature} is ${scanResult.status} -- run \`bskel scan --feature ${flags.feature}\` (and \`scan disposition\` if it collides) first.`, {
849
+ next_actions: [{ command: `bskel scan --feature ${flags.feature}`, reason: 'the scan gate has not passed yet', mutating: true }],
850
+ });
851
+ }
852
+
853
+ const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
854
+ if (!fs.existsSync(scanReportPath)) {
855
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${scanReportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
856
+ }
857
+ const scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
858
+ requireCapabilitiesOrExit(scanReport, 'contract emit', {
859
+ featureId: flags.feature,
860
+ scanReportPath,
861
+ satisfiedBy: flags['openapi-file'] ? new Set(['openapi-file']) : undefined,
862
+ });
863
+ const featureRecord = loadFeatureRecord(root, flags.feature);
864
+
865
+ // A1: computed before anything is written -- a bad --openapi-file (missing/unreadable/
866
+ // malformed/oversized) or an invalid --path-prefix must not leave a half-updated contract or
867
+ // touch the gate at all.
868
+ let reconciliation = null;
869
+ if (flags['openapi-file']) {
870
+ const targetModule = selectModule(scanReport, flags.module);
871
+ if (targetModule) {
872
+ const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'] });
873
+ if (!result.ok) {
874
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', result.error);
875
+ }
876
+ reconciliation = result;
877
+ }
878
+ // else: no module matched at all -- buildContract()'s existing CONTRACT_NO_MODULE/
879
+ // CONTRACT_EMPTY handling takes over unchanged; there is nothing to reconcile against.
880
+ }
881
+
882
+ const contract = buildContract({
883
+ featureId: flags.feature,
884
+ featureUid: featureRecord.feature_uid,
885
+ scanReport,
886
+ module: flags.module,
887
+ openapi: reconciliation,
888
+ });
889
+
890
+ // Written unconditionally, even when blocked/partial -- what the scan actually found is a
891
+ // real artifact worth inspecting, not just a side effect of a fully-passing run.
892
+ // S5 (D-persistence-integrity): validated before it touches disk -- same "fail loud here, not
893
+ // later" reasoning as every other write site this item touched.
894
+ {
895
+ const { ok, errors } = validateAgainstSchema('feature-contract.schema.json', contract);
896
+ if (!ok) {
897
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `refusing to write an invalid contract:\n${formatSchemaErrors(errors).join('\n')}`);
898
+ }
899
+ }
900
+ writeFileAtomic(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`), `${JSON.stringify(contract, null, 2)}\n`);
901
+
902
+ // A1: written BEFORE the gate is passed/awaited below -- lib/gate-definitions.mjs's contract
903
+ // token reads this file's hash at that moment, so writing it after would leave the gate
904
+ // looking at a stale (pre-snapshot) token.
905
+ const snapshotPath = specPath(root, flags.feature, 'contracts', `${flags.feature}.openapi.snapshot.json`);
906
+ if (reconciliation) {
907
+ const sourceFile = describeSourceFile(root, flags['openapi-file']);
908
+ const snapshot = snapshotFromReconciliation(reconciliation, { featureId: flags.feature, sourceFile });
909
+ writeFileAtomic(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`);
910
+ } else if (fs.existsSync(snapshotPath)) {
911
+ // A5-style conservative handling: a snapshot from a PREVIOUS --openapi-file run is not
912
+ // deleted just because this run didn't pass one -- deleting it here would also silently
913
+ // invalidate the contract gate's token (see lib/gate-definitions.mjs).
914
+ console.error(`note: an OpenAPI reconciliation snapshot from a previous run exists (specs/${flags.feature}/contracts/${flags.feature}.openapi.snapshot.json) but --openapi-file was not given this time -- left as-is.`);
915
+ }
916
+ // A2: unconditional (not gated behind !flags.json), same as the snapshot-reuse note above --
917
+ // a diagnostic side-channel note belongs on stderr regardless of what shape stdout takes.
918
+ if (reconciliation && !reconciliation.schemaProjection.enabled) {
919
+ console.error(`note: OpenAPI document declares version "${reconciliation.document.openapi_version ?? '(unknown)'}" -- schema projection needs 3.1.x, path/verb reconciliation above is unaffected`);
920
+ }
921
+
922
+ const resolution = loadResolution(root, flags.feature);
923
+ const evaluation = evaluateResolution(contract, resolution);
924
+ const evidence = {
925
+ operation_count: contract.completeness.operation_count,
926
+ endpoint_count: contract.completeness.endpoint_count,
927
+ completeness: evaluation.status,
928
+ warning_codes: countByCode(contract.warnings),
929
+ waived_count: evaluation.waived.length,
930
+ stale_waivers: evaluation.staleWaivers.length,
931
+ openapi: reconciliation
932
+ ? {
933
+ applied: true,
934
+ document_hash: reconciliation.document.hash,
935
+ path_prefix: reconciliation.prefix.value,
936
+ prefix_origin: reconciliation.prefix.origin,
937
+ // A2: schema_projection + the schema_resolved/unresolved/none/skipped_media_type
938
+ // counters arrive for free via this spread -- reconciliation.stats already carries
939
+ // them (initialized in contracts/openapi.mjs's reconcileModule), no separate
940
+ // derivation needed here.
941
+ schema_projection: reconciliation.schemaProjection,
942
+ ...reconciliation.stats,
943
+ }
944
+ : { applied: false },
945
+ };
946
+ // A5: "schema emitted" (this always happens) is not "complete enough to trust" (this gates).
947
+ // A partial/blocked contract awaits a human decision the same way an unresolved scan
948
+ // collision does -- awaiting_disposition, not a silent pass. See D-contract-completeness.
949
+ const gateState = evaluation.blocking
950
+ ? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
951
+ : passNamedGate(root, 'contract', flags.feature, evidence);
952
+
953
+ if (flags.json) {
954
+ console.log(JSON.stringify(contract, null, 2));
955
+ } else {
956
+ if (!flags.quiet) {
957
+ console.log(`wrote specs/${flags.feature}/contracts/${flags.feature}.schema.json -- ${contract.completeness.operation_count} operation(s), completeness: ${evaluation.status}`);
958
+ if (reconciliation) {
959
+ console.log(`openapi: ${reconciliation.stats.matched} path(s) corrected, ${reconciliation.stats.adopted} adopted (prefix ${reconciliation.prefix.value ?? '(none)'}, ${reconciliation.prefix.origin})`);
960
+ if (reconciliation.schemaProjection.enabled) {
961
+ const s = reconciliation.stats;
962
+ console.log(`openapi: ${s.schema_resolved} request body schema(s) projected, ${s.schema_unresolved} unresolved`);
963
+ console.log(`openapi: ${s.response_schema_resolved} response + ${s.error_schema_resolved} error schema(s) projected, ${s.response_schema_unresolved + s.error_schema_unresolved} unresolved`);
964
+ }
965
+ }
966
+ }
967
+ for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
968
+ if (!flags.quiet) console.log(`gate: contract -> ${gateState.gates.contract.status}`);
969
+ if (evaluation.staleWaivers.length > 0) {
970
+ console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
971
+ for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
972
+ }
973
+ if (evaluation.blocking) {
974
+ if (evaluation.status === 'blocked') {
975
+ 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).`);
976
+ } else {
977
+ const byCode = {};
978
+ for (const w of evaluation.unwaived) (byCode[w.code] ??= []).push(w);
979
+ console.error(`\nblocked: ${evaluation.unwaived.length} unresolved warning(s):`);
980
+ for (const [code, group] of Object.entries(byCode)) {
981
+ for (const w of group) console.error(` bskel contract waive --feature ${flags.feature} --code ${code} --subject "${w.subject}" --reason "..."`);
982
+ console.error(` # or all ${group.length} at once: bskel contract waive --feature ${flags.feature} --code ${code} --all --reason "..."`);
983
+ }
984
+ }
985
+ }
986
+ }
987
+ // A3: NOT process.exit() here -- found live, during real Team-IZ-Backend verification, not
988
+ // a hypothetical. A large `--json` contract (organization/member/projectexecution-sized,
989
+ // now routinely >64KB once response/error schemas are projected) written to a PIPE (not a
990
+ // TTY or a file) can have its stdout write still in flight when process.exit() forcibly
991
+ // tears the process down -- Node does not guarantee a pending async pipe write completes
992
+ // first. Reproduced directly: `contract emit --json` captured via a subshell truncated at
993
+ // exactly 65536 bytes (a classic pipe-buffer-sized cutoff) while the same command redirected
994
+ // to a file wrote its full, correct length. Setting exitCode (not calling exit()) lets the
995
+ // event loop drain -- including flushing this write -- before Node exits on its own with the
996
+ // same code. This is the last statement in this function, and cmdContractEmit is the last
997
+ // thing `main()` calls on this path, so there is nothing else pending that exitCode would
998
+ // incorrectly keep alive.
999
+ process.exitCode = evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS;
1000
+ }
1001
+
1002
+ function loadContract(root, featureId) {
1003
+ const contractPath = specPath(root, featureId, 'contracts', `${featureId}.schema.json`);
1004
+ if (!fs.existsSync(contractPath)) {
1005
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no contract at ${contractPath} -- run \`bskel contract emit --feature ${featureId}\` first`);
1006
+ }
1007
+ const parsed = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
1008
+ // S5 (D-persistence-integrity): validated against schemas/feature-contract.schema.json (the
1009
+ // meta-schema for THIS file's own shape -- not the same as contracts/validate.mjs, which
1010
+ // validates a runtime agent envelope's PAYLOAD against one operation inside an already-valid
1011
+ // contract).
1012
+ const { ok, errors } = validateAgainstSchema('feature-contract.schema.json', parsed);
1013
+ if (!ok) {
1014
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${contractPath}: does not match schemas/feature-contract.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
1015
+ }
1016
+ return parsed;
1017
+ }
1018
+
1019
+ // A6 (D-openapi-export): the export direction A1 never built -- renders an already-emitted,
1020
+ // gate-passing contract as a standalone OpenAPI 3.1 document. Gated on the `contract` gate having
1021
+ // PASSED, the same posture `handles emit` takes and deliberately not the ungated posture
1022
+ // `contract validate`/`contract tool-schema` take: those read a contract to answer a question about
1023
+ // one payload, this one hands a whole API description to a client generator or a mock server, where
1024
+ // a contract nobody has accepted yet is a materially different risk.
1025
+ //
1026
+ // Deliberately does NOT also require `preflight`, unlike `contract emit`/`handles emit`/`stack
1027
+ // apply`. Those either write into the target repo's own source tree or establish new state, so
1028
+ // "is this worktree even based on the real default branch" is a live question for them. This
1029
+ // command derives a read-only artifact from a contract that has ALREADY passed its gate -- and that
1030
+ // gate's own token transitively covers the scan report and the disposed module's files (S2), which
1031
+ // is the integrity property that actually matters here. Requiring preflight would mostly mean
1032
+ // failing an export because a 30-minute TTL expired (D-preflight-freshness), which says nothing
1033
+ // about whether the contract is trustworthy.
1034
+ //
1035
+ // The A5 completeness policy lands as three different behaviors, not one: a `blocked` (zero-
1036
+ // operation) contract is refused outright even when the gate was force-passed (see below); an
1037
+ // unwaived `partial` one never reaches here at all, because the gate itself has not passed; and a
1038
+ // `partial` one whose ERROR warnings were explicitly waived IS exportable -- this project already
1039
+ // decided a waived-partial contract is good enough to feed `handles emit`, and exporting it is not
1040
+ // a weaker bar. No completeness logic is re-derived here; the gate check above is the whole
1041
+ // mechanism.
1042
+ function cmdContractExport(args) {
1043
+ const flags = parseCommand('contract export', args);
1044
+ if (flags.help) { console.log(renderCommandHelp('contract export')); process.exit(0); }
1045
+ setContext('contract export', flags);
1046
+ const root = requireRepoRoot();
1047
+ requireValidFeatureId(flags.feature);
1048
+
1049
+ const statusCodes = flags['status-codes'];
1050
+ if (!STATUS_CODE_MODES.includes(statusCodes)) {
1051
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--status-codes must be one of: ${STATUS_CODE_MODES.join('|')} (got "${statusCodes}")`);
1052
+ }
1053
+
1054
+ const contractResult = requireNamedGate(root, 'contract', flags.feature);
1055
+ if (contractResult.code !== EXIT.PASS) {
1056
+ // Same reasoning as cmdHandlesEmit's own hint: `awaiting_disposition` almost always means a
1057
+ // contract WAS emitted but is partial/blocked, so "run contract emit first" would be wrong.
1058
+ const hint = contractResult.status === 'awaiting_disposition'
1059
+ ? `resolve it first -- \`bskel contract waive --feature ${flags.feature} --code <CODE> (--subject "..."|--all) --reason "..."\`, or \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if intentional.`
1060
+ : `run \`bskel contract emit --feature ${flags.feature}\` first.`;
1061
+ fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
1062
+ next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
1063
+ });
1064
+ }
1065
+
1066
+ const contract = loadContract(root, flags.feature);
1067
+ // A `paths: {}` document is a POSITIVE false claim that this API has no operations -- a
1068
+ // different and worse thing than an incomplete one. Refused even here, past a passing gate,
1069
+ // because `bskel gate force contract` can legitimately pass a blocked contract's gate (that
1070
+ // escape hatch exists so a module with genuinely no HTTP surface doesn't wedge the workflow)
1071
+ // and forcing a gate must not become a way to publish an empty API description. Exit 14 mirrors
1072
+ // cmdContractWaive's own blocked refusal exactly -- no new exit code for the same situation.
1073
+ if (Object.keys(contract.operations).length === 0) {
1074
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `\`${flags.feature}\`'s contract has zero operations (completeness: ${contract.completeness.status}) -- exporting it would produce a document positively claiming this API has no operations. Fix --module/--terms and re-run \`bskel contract emit --feature ${flags.feature}\`.`);
1075
+ }
1076
+
1077
+ // A1 §7's `path_prefix_signals` exist precisely because a source-annotation scan cannot see a
1078
+ // framework-level global prefix; a contract emitted without --openapi-file in that situation has
1079
+ // paths that are silently missing it. Publishing THOSE to a client generator is a
1080
+ // wrong-URL-at-runtime bug with no compile step to catch it, so it is refused by default rather
1081
+ // than warned about. Skipped entirely (not merely ignored) under --allow-unprefixed, so the
1082
+ // scan report is not even read when the user has already accepted the risk.
1083
+ if (!flags['allow-unprefixed']) {
1084
+ const scanReport = loadScanReportOrExit(root, flags.feature);
1085
+ const candidates = pathPrefixCandidates(scanReport.path_prefix_signals);
1086
+ const unreflected = unreflectedPathPrefixes(contract, candidates);
1087
+ if (unreflected.length > 0) {
1088
+ const signals = (scanReport.path_prefix_signals ?? []).map((s) => ` ${s.kind}: ${s.file} (${s.prefix ?? s.pattern})`).join('\n');
1089
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `blocked: this repo's scan found a global path-prefix signal (${unreflected.join(', ')}) that ${flags.feature}'s contract paths do not reflect:\n${signals}\nExporting these paths would hand a client generator URLs the real application does not serve. Re-run \`bskel contract emit --feature ${flags.feature} --openapi-file <real-generated-doc>\` to correct them (see D-openapi-reconciliation), or pass --allow-unprefixed if the signal genuinely does not apply to this feature.`);
1090
+ }
1091
+ }
1092
+
1093
+ // Provenance decoration only -- which real document this feature's paths were reconciled
1094
+ // against. A snapshot that fails to parse is reported and treated as absent rather than taking
1095
+ // the export down: it is not load-bearing for a single byte of the emitted document.
1096
+ const snapshotPath = specPath(root, flags.feature, 'contracts', `${flags.feature}.openapi.snapshot.json`);
1097
+ let snapshot = null;
1098
+ if (fs.existsSync(snapshotPath)) {
1099
+ try {
1100
+ snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
1101
+ } catch (err) {
1102
+ console.error(`note: could not read the OpenAPI reconciliation snapshot (${err.message}) -- exporting without its provenance details`);
1103
+ }
1104
+ }
1105
+
1106
+ const version = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8')).version;
1107
+ const built = buildOpenApiDocument({ contract, snapshot, options: { statusCodes, exportedBy: `bskel ${version}` } });
1108
+ if (!built.ok) {
1109
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `cannot export ${flags.feature}'s contract: ${built.error}`);
1110
+ }
1111
+
1112
+ // Once, not per operation -- the contract records no status codes at all, so under `literal`
1113
+ // EVERY operation gets the same stand-in and N copies of this note would say nothing more.
1114
+ // stderr and unconditional (not gated on !--json), the same side-channel treatment
1115
+ // cmdContractEmit gives its own snapshot/dialect notes.
1116
+ if (built.literalStatusStandIn) {
1117
+ console.error('note: --status-codes literal writes `200` for every documented success body. The source contract records no status codes whatsoever, so `200` is a bskel-chosen stand-in, NOT a claim that any of these operations actually returns 200 -- `--status-codes range` (the default) emits the spec-legal `2XX` range key and invents nothing.');
1118
+ }
1119
+
1120
+ const rendered = `${JSON.stringify(built.document, null, 2)}\n`;
1121
+ if (flags.out) {
1122
+ const outPath = path.resolve(process.cwd(), flags.out);
1123
+ writeFileAtomic(outPath, rendered);
1124
+ if (flags.json) {
1125
+ console.log(JSON.stringify({
1126
+ schema: 'sbf.contract-export/1',
1127
+ feature_id: contract.feature_id,
1128
+ out: flags.out,
1129
+ openapi: built.document.openapi,
1130
+ operation_count: Object.keys(contract.operations).length,
1131
+ completeness: contract.completeness.status,
1132
+ status_codes: built.statusCodes,
1133
+ contract_sha256: built.contractSha256,
1134
+ omitted: built.omissions,
1135
+ }, null, 2));
1136
+ } else if (!flags.quiet) {
1137
+ console.log(`wrote ${flags.out} -- OpenAPI ${built.document.openapi}, ${Object.keys(contract.operations).length} operation(s), status codes: ${built.statusCodes}`);
1138
+ console.log(`omitted (see info.x-bskel-omitted): ${built.omissions.join(', ')}`);
1139
+ }
1140
+ } else {
1141
+ // The document IS this command's payload here, so --quiet never touches it and --json is a
1142
+ // documented no-op (stdout is already exactly one JSON document either way) -- the same
1143
+ // treatment `scan disposition` and the always-JSON gate commands already get.
1144
+ console.log(JSON.stringify(built.document, null, 2));
1145
+ }
1146
+ // D-process-exit-audit: NOT process.exit(). An exported document for a schema-rich module is
1147
+ // routinely well past the 64KB pipe buffer that truncated cmdContractEmit's own --json output,
1148
+ // and this is the same shape of bug -- a large console.log immediately followed by a forced
1149
+ // exit. Last statement in the function; nothing else is pending on this path.
1150
+ process.exitCode = EXIT.PASS;
1151
+ }
1152
+
1153
+ // A5: the `scan disposition` of contracts -- lets a human explicitly accept a `partial`
1154
+ // contract's outstanding warnings so the `contract` gate can pass. Deliberately no wildcard
1155
+ // waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
1156
+ // individual entries -- a warning that doesn't exist yet (e.g. a new unannotated endpoint added
1157
+ // later) is never covered by an old waive. See D-contract-completeness in DECISIONS.md.
1158
+ function cmdContractWaive(args) {
1159
+ const flags = parseCommand('contract waive', args);
1160
+ if (flags.help) { console.log(renderCommandHelp('contract waive')); process.exit(0); }
1161
+ setContext('contract waive', flags);
1162
+ const root = requireRepoRoot();
1163
+ const usageText = 'usage: bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..."';
1164
+ try {
1165
+ requireWarningCode(flags.code);
1166
+ } catch (err) {
1167
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1168
+ }
1169
+ if (!flags.reason || !flags.reason.trim()) {
1170
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel contract waive requires --reason "..." -- every waiver must be auditable');
1171
+ }
1172
+ if (!flags.subject && !flags.all) {
1173
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', usageText);
1174
+ }
1175
+
1176
+ const contract = loadContract(root, flags.feature);
1177
+ if (contract.completeness.status === 'blocked') {
1178
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `\`${flags.feature}\`'s contract has zero operations -- there is nothing to waive. Fix --module/--terms, or use \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if this is intentional.`);
1179
+ }
1180
+
1181
+ const currentMatches = contract.warnings.filter((w) => w.code === flags.code && w.severity === 'error');
1182
+ let toWaive;
1183
+ if (flags.all) {
1184
+ toWaive = currentMatches;
1185
+ if (toWaive.length === 0) {
1186
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no current warning with code "${flags.code}" in this contract -- nothing to waive`);
1187
+ }
1188
+ } else {
1189
+ const match = currentMatches.find((w) => w.subject === flags.subject);
1190
+ if (!match) {
1191
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no current warning with code "${flags.code}" and subject "${flags.subject}" in this contract -- known ${flags.code} subjects: ${currentMatches.map((w) => w.subject).join(', ') || '(none)'}`);
1192
+ }
1193
+ toWaive = [match];
1194
+ }
1195
+
1196
+ // S5 (D-persistence-integrity): the whole load-modify-save cycle runs under one lock -- closes
1197
+ // the same lost-update race confirmed live in lib/state.mjs's setGate() during this item's own
1198
+ // grounding (two concurrent `contract waive` calls could otherwise silently drop one's
1199
+ // entries). Locking only the final write (inside saveResolution()) would NOT close this race --
1200
+ // the window is between this function's own loadResolution() read and its save, not inside the
1201
+ // write call itself.
1202
+ const { resolution: updatedResolution, newEntries } = withLockSync(root, 'state', () => {
1203
+ const resolution = loadResolution(root, flags.feature);
1204
+ const existingKeys = new Set((resolution.waivers ?? []).map(warningKey));
1205
+ const at = new Date().toISOString();
1206
+ const entries = toWaive
1207
+ .filter((w) => !existingKeys.has(warningKey(w)))
1208
+ .map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at }));
1209
+ const next = {
1210
+ schema: 'sbf.contract-resolution/1',
1211
+ feature_id: flags.feature,
1212
+ waivers: [...(resolution.waivers ?? []), ...entries],
1213
+ };
1214
+ saveResolution(root, flags.feature, next);
1215
+ return { resolution: next, newEntries: entries };
1216
+ });
1217
+
1218
+ const evaluation = evaluateResolution(contract, updatedResolution);
1219
+ const evidence = {
1220
+ operation_count: contract.completeness.operation_count,
1221
+ endpoint_count: contract.completeness.endpoint_count,
1222
+ completeness: evaluation.status,
1223
+ warning_codes: countByCode(contract.warnings),
1224
+ waived_count: evaluation.waived.length,
1225
+ stale_waivers: evaluation.staleWaivers.length,
1226
+ };
1227
+ const gateState = evaluation.blocking
1228
+ ? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
1229
+ : passNamedGate(root, 'contract', flags.feature, evidence);
1230
+
1231
+ if (flags.json) {
1232
+ console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
1233
+ } else {
1234
+ if (!flags.quiet) {
1235
+ console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}`);
1236
+ console.log(`gate: contract -> ${gateState.gates.contract.status}`);
1237
+ }
1238
+ if (evaluation.blocking) {
1239
+ console.error(`\nstill blocked: ${evaluation.unwaived.length} unresolved warning(s) remain:`);
1240
+ for (const w of evaluation.unwaived) console.error(` ${w.code} (${w.subject})`);
1241
+ }
1242
+ }
1243
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1244
+ }
1245
+
1246
+ function cmdContractValidate(args) {
1247
+ const flags = parseCommand('contract validate', args);
1248
+ if (flags.help) { console.log(renderCommandHelp('contract validate')); process.exit(0); }
1249
+ setContext('contract validate', flags);
1250
+ const root = requireRepoRoot();
1251
+ const contract = loadContract(root, flags.feature);
1252
+ let envelope;
1253
+ try {
1254
+ envelope = JSON.parse(fs.readFileSync(flags.file, 'utf8'));
1255
+ } catch (err) {
1256
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse ${flags.file}: ${err.message}`);
1257
+ }
1258
+
1259
+ const result = validateEnvelope(envelope, contract);
1260
+ console.log(JSON.stringify(result, null, 2));
1261
+ // Process-exit audit (post-A3): a validation failure against a schema-rich A2/A3 contract can
1262
+ // produce a very large `errors` array under ajv's allErrors:true -- reproduced live: 5000
1263
+ // wrong-typed array elements against a real registerTrainees contract produced a real,
1264
+ // correct 243926-byte result that a piped capture truncated at exactly 65536 bytes with the
1265
+ // old process.exit() here. Last statement in this function -- safe to set exitCode directly.
1266
+ // This exit code (0/1) carries a real payload (the result just printed), never a diagnostic
1267
+ // envelope on top of it.
1268
+ process.exitCode = result.ok ? 0 : 1;
1269
+ }
1270
+
1271
+ function cmdContractToolSchema(args) {
1272
+ const flags = parseCommand('contract tool-schema', args);
1273
+ if (flags.help) { console.log(renderCommandHelp('contract tool-schema')); process.exit(0); }
1274
+ setContext('contract tool-schema', flags);
1275
+ const root = requireRepoRoot();
1276
+ const contract = loadContract(root, flags.feature);
1277
+ // A1: same class of gap as D-security-1 (contracts/validate.mjs's Object.hasOwn fix) --
1278
+ // `contract.operations` is a plain object, so `--operation constructor` would otherwise
1279
+ // resolve an inherited Object.prototype property and be treated as a real, defined
1280
+ // operation. Reachability went up with A1: an operationId can now be adopted directly from
1281
+ // an external OpenAPI document, not just from Java source the repo owner controls.
1282
+ const op = Object.hasOwn(contract.operations, flags.operation) ? contract.operations[flags.operation] : undefined;
1283
+ if (!op) {
1284
+ fail(EXIT_CODES.NOT_PASSED, 'UNKNOWN_OPERATION', `operation "${flags.operation}" not in this feature's contract (known: ${Object.keys(contract.operations).join(', ') || '(none)'})`);
1285
+ }
1286
+
1287
+ // Anthropic tool-use `input_schema` is a JSON Schema subset -- the operation's payload
1288
+ // schema (already plain JSON Schema, no $ref/$defs) is directly usable as-is. A2: when `op`
1289
+ // carries a projected `requestBodySchema`, it flows through here for free -- this function
1290
+ // changed not at all; contracts/openapi.mjs's inlineSchema() is what guarantees the no-$ref
1291
+ // promise this comment makes.
1292
+ const toolSchema = {
1293
+ name: flags.operation,
1294
+ description: `${op.verb} ${op.path} (feature ${flags.feature})`,
1295
+ input_schema: operationPayloadSchema(op),
1296
+ };
1297
+ console.log(JSON.stringify(toolSchema, null, 2));
1298
+ process.exit(0);
1299
+ }
1300
+
1301
+ function renderStackPlan(plan) {
1302
+ const lines = [`# Stack apply plan: ${plan.choice}`, ''];
1303
+ lines.push(plan.alreadyDetected ? '**Already detected as applied** (files/env keys from `detect:` found) -- re-running is idempotent.' : 'Not yet applied.');
1304
+ lines.push('');
1305
+ lines.push('## Files');
1306
+ for (const f of plan.files) lines.push(`- [${f.action}] ${f.path}${f.mode ? ` (mode ${f.mode})` : ''}`);
1307
+ lines.push('');
1308
+ lines.push('## .env.example entries');
1309
+ for (const e of plan.envExampleActions) lines.push(`- [${e.action}] ${e.key}${e.required ? ' (required)' : ''}${e.secret ? ' (secret)' : ''} -- ${e.doc}`);
1310
+ lines.push('');
1311
+ if (plan.configChecks.length > 0) {
1312
+ lines.push('## Config checks (informational -- never auto-patched, see D-config-patch)');
1313
+ for (const c of plan.configChecks) lines.push(`- ${c.target}: **${c.status}**${c.status === 'needs-manual-patch' ? `\n ${c.note}` : ''}`);
1314
+ }
1315
+ return `${lines.join('\n')}\n`;
1316
+ }
1317
+
1318
+ function cmdStackApply(args) {
1319
+ const flags = parseCommand('stack apply', args);
1320
+ if (flags.help) { console.log(renderCommandHelp('stack apply')); process.exit(0); }
1321
+ setContext('stack apply', flags);
1322
+ const root = requireRepoRoot();
1323
+ requirePreflightPassed(root);
1324
+ if (!flags.choice) {
1325
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `usage: bskel stack apply --choice <id> [--apply] [--port N] (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
1326
+ }
1327
+
1328
+ let entry;
1329
+ try {
1330
+ entry = loadCatalogEntry(flags.choice);
1331
+ } catch (err) {
1332
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1333
+ }
1334
+ let plan;
1335
+ try {
1336
+ plan = planApply(root, entry, { port: Number.parseInt(flags.port, 10) });
1337
+ } catch (err) {
1338
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1339
+ }
1340
+
1341
+ if (!flags.apply) {
1342
+ // Dry-run is the default -- nothing is written without an explicit --apply, matching the
1343
+ // repo's own "minimal, explicit-approval" convention for anything that touches files.
1344
+ if (flags.json) console.log(JSON.stringify(plan, null, 2));
1345
+ else if (!flags.quiet) console.log(renderStackPlan(plan));
1346
+ process.exit(0);
1347
+ }
1348
+
1349
+ let written;
1350
+ try {
1351
+ written = applyPlan(root, plan);
1352
+ } catch (err) {
1353
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1354
+ }
1355
+ // S2: `applied_files` must be this choice's FULL file set in this repo (its desired state),
1356
+ // not just whatever `applyPlan()` happened to write THIS run -- applyPlan() skips files whose
1357
+ // action is 'unchanged', so a second, idempotent `--apply` used to overwrite this with `[]`,
1358
+ // erasing the only record of what the choice owns. That silently gutted the `stack` gate's new
1359
+ // applied-file hashing above (nothing left to hash -> nothing left to protect). `written` is
1360
+ // still what's reported to the user below -- unchanged output, only the persisted record fixed.
1361
+ const appliedFiles = [...new Set([
1362
+ ...plan.files.map((f) => f.path),
1363
+ ...(plan.envExampleActions.length > 0 ? ['.env.example'] : []),
1364
+ ])].sort();
1365
+ const stackRecord = {
1366
+ schema: 'sbf.stack/1', choice: flags.choice, applied_files: appliedFiles,
1367
+ env_example_keys: plan.envExampleActions.map((e) => e.key), at: new Date().toISOString(),
1368
+ };
1369
+ // S5 (D-persistence-integrity): schemas/stack-record.schema.json is new -- this record had NO
1370
+ // schema at all before (not the same file as stack-choice.schema.json, which validates a
1371
+ // stack/catalog/<id>.yml CATALOG ENTRY, a completely different persistence boundary). Validated
1372
+ // before it touches disk, same "fail loud here" reasoning as every other write site this item
1373
+ // touched. No corresponding read helper -- nothing in this codebase reads .sbf/stack.json back
1374
+ // (confirmed by grep before adding this), so there's no read boundary to close yet; adding an
1375
+ // unused loadStackRecord() export would just be dead code.
1376
+ {
1377
+ const { ok, errors } = validateAgainstSchema('stack-record.schema.json', stackRecord);
1378
+ if (!ok) {
1379
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to write an invalid stack record:\n${formatSchemaErrors(errors).join('\n')}`);
1380
+ }
1381
+ }
1382
+ writeFileAtomic(path.join(root, '.sbf', 'stack.json'), `${JSON.stringify(stackRecord, null, 2)}\n`);
1383
+
1384
+ const gateState = passNamedGate(root, 'stack', null, { choice: flags.choice });
1385
+
1386
+ if (flags.json) {
1387
+ console.log(JSON.stringify({ written, gate: gateState.gates.stack }, null, 2));
1388
+ } else if (!flags.quiet) {
1389
+ console.log(written.length > 0 ? `wrote: ${written.join(', ')}` : 'nothing to write (already up to date)');
1390
+ for (const c of plan.configChecks.filter((c) => c.status === 'needs-manual-patch')) {
1391
+ console.log(`\nmanual step needed -- ${c.target}:\n${c.note}`);
1392
+ }
1393
+ console.log(`gate: stack -> ${gateState.gates.stack.status}`);
1394
+ console.log(`\nnext: fill in ${entry.static?.env_example?.filter((e) => e.required).map((e) => e.key).join(', ') || 'the required'} in your .env, then run ./${entry.runtime.script}`);
1395
+ }
1396
+ process.exit(0);
1397
+ }
1398
+
1399
+ // P4 (D-extension-conformance): a {{VAR}}-shaped token that survives a real render -- the shared
1400
+ // renderer (lib/template.mjs) only ever substitutes {{PORT}} for a stack catalog entry, so anything
1401
+ // else left in rendered output is a variable no catalog author declared and nothing will ever fill
1402
+ // in at apply time. Scanning the RENDERED output (not the raw template source) catches this the
1403
+ // same way a real `stack apply` would produce it, without needing a separate variable-declaration+
1404
+ // injection system for a single current consumer (see D-extension-conformance in DECISIONS.md for
1405
+ // why that was rejected). P2b (D-greenfield-parameters) moved the regex itself into
1406
+ // lib/template.mjs, where `new/fastapi.mjs` became its second consumer -- imported above.
1407
+
1408
+ // P4: reuses loadCatalogEntry()'s existing schema validation and planApply()'s existing
1409
+ // assertContained() path-containment checks (template path, target path, config_check target
1410
+ // path -- all three) unchanged -- lint is just "run planApply() against a throwaway directory
1411
+ // nothing ever gets written to" rather than a second, parallel validation implementation.
1412
+ function lintCatalogEntry(choiceId) {
1413
+ let entry;
1414
+ try {
1415
+ entry = loadCatalogEntry(choiceId);
1416
+ } catch (err) {
1417
+ return { choice: choiceId, ok: false, errors: [err.message] };
1418
+ }
1419
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-catalog-lint-'));
1420
+ const errors = [];
1421
+ try {
1422
+ let plan;
1423
+ try {
1424
+ plan = planApply(scratch, entry, { port: 8080 });
1425
+ } catch (err) {
1426
+ errors.push(err.message);
1427
+ return { choice: choiceId, ok: false, errors };
1428
+ }
1429
+ for (const f of plan.files) {
1430
+ const residual = [...new Set(f.content.match(RESIDUAL_TEMPLATE_VAR_RE) ?? [])];
1431
+ for (const token of residual) {
1432
+ errors.push(`template for ${f.path} references undeclared variable ${token} -- this will never be substituted`);
1433
+ }
1434
+ }
1435
+ } finally {
1436
+ fs.rmSync(scratch, { recursive: true, force: true });
1437
+ }
1438
+ return { choice: choiceId, ok: errors.length === 0, errors };
1439
+ }
1440
+
1441
+ // P4: deliberately does NOT call requireRepoRoot() -- lint only ever touches this skill's own
1442
+ // stack/catalog/ (via listCatalogChoices/loadCatalogEntry) and a throwaway scratch directory, so
1443
+ // an extension author can lint a new catalog entry without even being inside a target repo.
1444
+ function cmdCatalogLint(args) {
1445
+ const flags = parseCommand('catalog lint', args);
1446
+ if (flags.help) { console.log(renderCommandHelp('catalog lint')); process.exit(0); }
1447
+ setContext('catalog lint', flags);
1448
+ const known = listCatalogChoices();
1449
+ if (flags._[0] && !known.includes(flags._[0])) {
1450
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `unknown stack choice "${flags._[0]}" -- known choices: ${known.join(', ') || '(none)'}`);
1451
+ }
1452
+ const choices = flags._[0] ? [flags._[0]] : known;
1453
+ const results = choices.map(lintCatalogEntry);
1454
+ const allOk = results.every((r) => r.ok);
1455
+ if (flags.json) {
1456
+ console.log(JSON.stringify(results, null, 2));
1457
+ } else {
1458
+ for (const r of results) {
1459
+ console.log(`${r.ok ? '✔' : '✖'} ${r.choice}`);
1460
+ for (const e of r.errors) console.log(` - ${e}`);
1461
+ }
1462
+ }
1463
+ // P4: CHECK_FAILED (not BAD_ARGS) -- this run itself was valid (a real command with valid
1464
+ // flags), the LINTED CONTENT is what's wrong, same distinction bskel already draws elsewhere
1465
+ // (e.g. contract emit's completeness verdict vs. a malformed CLI invocation).
1466
+ process.exit(allOk ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
1467
+ }
1468
+
1469
+ // S5 (D-persistence-integrity): the ONE choke point for reading brownfield-scan.json --
1470
+ // cmdScanDisposition() and cmdContractEmit() used to each duplicate this exact "exists? parse it"
1471
+ // logic inline; consolidated here so schema validation has a single place to live instead of
1472
+ // three copies to keep in sync.
1473
+ function loadScanReportOrExit(root, featureId) {
1474
+ const scanReportPath = specPath(root, featureId, 'brownfield-scan.json');
1475
+ if (!fs.existsSync(scanReportPath)) {
1476
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${scanReportPath} -- run \`bskel scan --feature ${featureId}\` first`);
1477
+ }
1478
+ const parsed = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
1479
+ const { ok, errors } = validateAgainstSchema('scan-report.schema.json', parsed);
1480
+ if (!ok) {
1481
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${scanReportPath}: does not match schemas/scan-report.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
1482
+ }
1483
+ return parsed;
1484
+ }
1485
+
1486
+ // S5 (D-persistence-integrity): the write-side sibling of loadScanReportOrExit() above -- validated
1487
+ // before it ever touches disk, same "fail loud here, not as a confusing error somewhere later"
1488
+ // reasoning as lib/state.mjs's saveState(). Used by both cmdScan()'s own write and
1489
+ // cmdScanDisposition()'s read-modify-write.
1490
+ function writeScanReportOrExit(reportPath, report) {
1491
+ const { ok, errors } = validateAgainstSchema('scan-report.schema.json', report);
1492
+ if (!ok) {
1493
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `refusing to write an invalid scan report to ${reportPath}:\n${formatSchemaErrors(errors).join('\n')}`);
1494
+ }
1495
+ writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
1496
+ }
1497
+
1498
+ // D4 (D-handles-dryrun): the marker vocabulary a human report uses for classifyFile()'s 6
1499
+ // possible actions (+ the java-spring-only 'spec' kind, which reuses the same 3 labels since it's
1500
+ // classified the same 3-way create/unchanged/update, just outside classifyFile() itself).
1501
+ const ACTION_MARKERS = { create: '+', unchanged: '=', update: '~', 'adopt-unchanged': '=', 'adopt-update': '~', conflict: '!' };
1502
+
1503
+ // D4: shared between `handles plan`'s preview and `handles emit --check`'s report -- both show
1504
+ // the exact same per-file action list, since they're both now backed by the same
1505
+ // emitUnits({dryRun:true}) computation. Diff bodies print only when computeDiff was actually
1506
+ // requested (an action only carries a.diff when it was).
1507
+ function renderFileActions(actions) {
1508
+ const lines = ['## File actions'];
1509
+ if (actions.length === 0) {
1510
+ lines.push('(no infra/resolver units in scope)');
1511
+ return lines.join('\n');
1512
+ }
1513
+ for (const a of actions) {
1514
+ const marker = ACTION_MARKERS[a.action] ?? '?';
1515
+ const specNote = a.kind === 'spec' ? ' [spec-owned: always regenerated on a real run, not conflict-tracked]' : '';
1516
+ lines.push(` [${marker}] ${a.action}\t${a.path}${a.resourceType ? ` (${a.resourceType})` : ''}${specNote}`);
1517
+ if (a.diff) {
1518
+ for (const dl of a.diff.split('\n')) if (dl) lines.push(` ${dl}`);
1519
+ }
1520
+ }
1521
+ return lines.join('\n');
1522
+ }
1523
+
1524
+ function renderHandlesPlan(plan, actions) {
1525
+ const lines = [`# Handles plan: module "${plan.module ?? '(none)'}"`, ''];
1526
+ if (plan.resources.length === 0) {
1527
+ lines.push('No candidate resources.');
1528
+ }
1529
+ for (const r of plan.resources) {
1530
+ lines.push(`## ${r.type}${r.willGenerateResolver ? '' : ' (resolver will NOT be generated -- see notes)'}`);
1531
+ lines.push(`- table: ${r.table ?? '(unknown)'}, PK field: ${r.idField ?? '(unknown)'}`);
1532
+ lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
1533
+ lines.push(`- requiredAuthority: ${r.requiredAuthority}`);
1534
+ lines.push('');
1535
+ }
1536
+ if (plan.notes.length > 0) {
1537
+ lines.push('## Notes');
1538
+ for (const n of plan.notes) lines.push(`- ${n}`);
1539
+ lines.push('');
1540
+ }
1541
+ if (actions) lines.push(renderFileActions(actions));
1542
+ return `${lines.join('\n')}\n`;
1543
+ }
1544
+
1545
+ // D-handles-providers (G4): selects the codegen provider for this scan report's adapter by exact
1546
+ // id match -- never arbitrated, since there is nothing to arbitrate (see handles/registry.mjs).
1547
+ // Reachable only after requireCapabilitiesOrExit has already confirmed codegen.handles === true
1548
+ // for this adapter, which by construction means a provider SHOULD exist -- the drift-bug branch
1549
+ // below exists only to fail loudly if that invariant is ever violated (e.g. the provider file
1550
+ // itself failed to load), not as an expected path.
1551
+ function selectProviderOrExit(scanReport) {
1552
+ const provider = providerById(PROVIDERS, scanReport.adapter);
1553
+ if (!provider) {
1554
+ const loadErr = PROVIDER_LOAD_ERRORS.find((e) => path.basename(e.file, '.mjs') === scanReport.adapter);
1555
+ const message = loadErr
1556
+ ? `blocked: the "${scanReport.adapter}" codegen provider failed to load: ${loadErr.message}`
1557
+ : `blocked: no codegen provider is registered for adapter "${scanReport.adapter}" even though it declares codegen.handles -- this is a drift bug, please report it.`;
1558
+ fail(EXIT_CODES.NOT_PASSED, 'PROVIDER_UNAVAILABLE', message);
1559
+ }
1560
+ return provider;
1561
+ }
1562
+
1563
+ // A provider declares its OWN capability requirements (e.g. java-spring/python-fastapi both need
1564
+ // `resource.fetch`) separately from the command-level dispatch capability (`codegen.handles`,
1565
+ // checked by requireCapabilitiesOrExit before the provider is even selected) -- see
1566
+ // D-handles-providers in DECISIONS.md for why this is two checks, not one.
1567
+ function requireProviderCapabilitiesOrExit(scanReport, provider, command, { featureId, scanReportPath }) {
1568
+ const adapter = adapterById(ADAPTERS, scanReport.adapter);
1569
+ for (const capability of provider.requiresCapabilities ?? []) {
1570
+ if (adapter.capabilities[capability]) continue;
1571
+ fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', explainMissingCapability({ adapterId: adapter.id, capability, command, featureId, scanReportPath }));
1572
+ }
1573
+ }
1574
+
1575
+ // A2 Phase 2 (D-java-ast-helper): compares the AST helper's real, symbol-resolved annotation
1576
+ // names against what the always-on regex classifier (patch-strategy.mjs) actually saw. The one
1577
+ // disagreement worth surfacing: a field whose annotation was written FULLY QUALIFIED (contains a
1578
+ // dot) and resolves to NotNull/Valid -- regex's own literal `/@NotNull\b/`/`/@Valid\b/` check can
1579
+ // never match that form (it only matches the bare simple name immediately after `@`), so this is
1580
+ // exactly the gap this item exists to close. Never auto-changes a bucket or an approval --
1581
+ // informational only, same "detect and warn, never silently override a human decision" precedent
1582
+ // this whole codebase already follows elsewhere.
1583
+ function computeAstDisagreements(resource, astResult) {
1584
+ const disagreements = [];
1585
+ for (const astField of astResult.fields ?? []) {
1586
+ const regexField = (resource.patchable ?? []).find((f) => f.field === astField.name);
1587
+ for (const annotation of astField.annotations ?? []) {
1588
+ const isQualifiedAsWritten = annotation.asWritten.includes('.');
1589
+ const resolvesToNotNullOrValid = /(^|\.)(NotNull|Valid)$/.test(annotation.resolvedFqn);
1590
+ if (!isQualifiedAsWritten || !resolvesToNotNullOrValid) continue;
1591
+ disagreements.push({
1592
+ field: astField.name,
1593
+ annotation: annotation.resolvedFqn,
1594
+ regexBucket: regexField?.bucket ?? null,
1595
+ reason: `written as "@${annotation.asWritten}" -- regex's own literal @NotNull/@Valid check can never match a fully-qualified annotation name, only the bare simple name`,
1596
+ });
1597
+ }
1598
+ }
1599
+ return disagreements;
1600
+ }
1601
+
1602
+ async function cmdHandlesPlan(args) {
1603
+ const flags = parseCommand('handles plan', args);
1604
+ if (flags.help) { console.log(renderCommandHelp('handles plan')); process.exit(0); }
1605
+ setContext('handles plan', flags);
1606
+ const root = requireRepoRoot();
1607
+ const scanReport = loadScanReportOrExit(root, flags.feature);
1608
+ const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
1609
+ requireCapabilitiesOrExit(scanReport, 'handles plan', { featureId: flags.feature, scanReportPath });
1610
+ const provider = selectProviderOrExit(scanReport);
1611
+ requireProviderCapabilitiesOrExit(scanReport, provider, 'handles plan', { featureId: flags.feature, scanReportPath });
1612
+ const resourceFilter = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
1613
+
1614
+ let plan;
1615
+ try {
1616
+ plan = provider.plan({ repoRoot: root, scanReport, module: flags.module, resourceFilter });
1617
+ } catch (err) {
1618
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
1619
+ }
1620
+ // D4 (D-handles-dryrun): a dry, never-writing preview of exactly what `handles emit` would do
1621
+ // to disk -- reuses the identical classifyFile()-backed engine `handles emit` itself calls,
1622
+ // just with dryRun:true. Always computed (cheap: no diff bodies unless --diff asks for them),
1623
+ // so `handles plan` becomes a true pre-write plan, not just an abstract resource list. Does NOT
1624
+ // require the contract gate -- same as the rest of this command, unaffected by this addition
1625
+ // since dryRun never writes.
1626
+ let actions;
1627
+ try {
1628
+ ({ actions } = provider.emit({ repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: false, reason: '', dryRun: true, computeDiff: flags.diff }));
1629
+ } catch (err) {
1630
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
1631
+ }
1632
+
1633
+ // A2 Phase 2 (D-java-ast-helper): explicit opt-in only -- classifyDtoFields() itself
1634
+ // (patch-strategy.mjs) is completely untouched, this runs the real AST helper ALONGSIDE it
1635
+ // and reports disagreements, never automatically. java-spring-only: updateDtoFile is a
1636
+ // java-spring plan() field, and no other provider has an AST helper.
1637
+ let astDisagreements = null;
1638
+ if (flags.ast) {
1639
+ if (scanReport.adapter !== 'java-spring') {
1640
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--ast is only supported for the java-spring adapter (this feature's scan used "${scanReport.adapter}")`);
1641
+ }
1642
+ const detection = detectAstHelperAvailable();
1643
+ if (!detection.available) {
1644
+ fail(EXIT_CODES.NOT_PASSED, 'AST_HELPER_UNAVAILABLE', `--ast requires the bundled AST helper: ${detection.reason}`);
1645
+ }
1646
+ const srcRoot = path.join(root, 'src', 'main', 'java');
1647
+ astDisagreements = [];
1648
+ for (const resource of plan.resources) {
1649
+ if (!resource.updateDtoFile) continue;
1650
+ let astResult;
1651
+ try {
1652
+ astResult = await runAstClassify(resource.updateDtoFile, srcRoot);
1653
+ } catch (err) {
1654
+ fail(EXIT_CODES.NOT_PASSED, 'AST_HELPER_FAILED', `--ast: ${err.message}`);
1655
+ }
1656
+ for (const d of computeAstDisagreements(resource, astResult)) {
1657
+ astDisagreements.push({ resourceType: resource.type, ...d });
1658
+ }
1659
+ }
1660
+ }
1661
+
1662
+ const output = { ...plan, actions, ...(astDisagreements !== null ? { ast_disagreements: astDisagreements } : {}) };
1663
+ if (flags.json) {
1664
+ console.log(JSON.stringify(output, null, 2));
1665
+ } else {
1666
+ console.log(renderHandlesPlan(plan, actions));
1667
+ if (astDisagreements !== null) {
1668
+ if (astDisagreements.length === 0) {
1669
+ console.log('\n## AST cross-check\nNo disagreements -- the regex classifier already agrees with the real, symbol-resolved AST analysis.');
1670
+ } else {
1671
+ const lines = ['', '## AST cross-check', `${astDisagreements.length} disagreement(s) found:`];
1672
+ for (const d of astDisagreements) {
1673
+ lines.push(`- ${d.resourceType}.${d.field}: ${d.annotation} -- ${d.reason} (regex classified this field as: ${d.regexBucket ?? '(not classified/not approved)'})`);
1674
+ }
1675
+ console.log(lines.join('\n'));
1676
+ }
1677
+ }
1678
+ }
1679
+ process.exit(0);
1680
+ }
1681
+
1682
+ function cmdHandlesEmit(args) {
1683
+ const flags = parseCommand('handles emit', args);
1684
+ if (flags.help) { console.log(renderCommandHelp('handles emit')); process.exit(0); }
1685
+ setContext('handles emit', flags);
1686
+ const root = requireRepoRoot();
1687
+ requirePreflightPassed(root);
1688
+ // O2: mirrors `cmdContractWaive`'s --reason requirement -- every overwrite of a diverged
1689
+ // generated file must be auditable, not silent. See DECISIONS.md D-handles-ownership.
1690
+ if (flags.force && (!flags.reason || !flags.reason.trim())) {
1691
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
1692
+ }
1693
+
1694
+ // Handles are only emitted for a feature whose contract has actually been established --
1695
+ // codegen against a feature nobody has scanned/contracted yet has nothing real to route to.
1696
+ const contractResult = requireNamedGate(root, 'contract', flags.feature);
1697
+ if (contractResult.code !== EXIT.PASS) {
1698
+ // A5: awaiting_disposition here almost always means a contract WAS emitted but is
1699
+ // partial/blocked (not "never ran") -- "run contract emit first" would be wrong advice in
1700
+ // that case, so point at `contract waive`/`gate force` instead.
1701
+ const hint = contractResult.status === 'awaiting_disposition'
1702
+ ? `resolve it first -- \`bskel contract waive --feature ${flags.feature} --code <CODE> (--subject "..."|--all) --reason "..."\`, or \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if intentional.`
1703
+ : `run \`bskel contract emit --feature ${flags.feature}\` first.`;
1704
+ fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
1705
+ next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
1706
+ });
1707
+ }
1708
+
1709
+ const scanReport = loadScanReportOrExit(root, flags.feature);
1710
+ const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
1711
+ requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
1712
+ const provider = selectProviderOrExit(scanReport);
1713
+ requireProviderCapabilitiesOrExit(scanReport, provider, 'handles emit', { featureId: flags.feature, scanReportPath });
1714
+ const resourceFilter = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
1715
+
1716
+ let plan;
1717
+ try {
1718
+ plan = provider.plan({ repoRoot: root, scanReport, module: flags.module, resourceFilter });
1719
+ } catch (err) {
1720
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
1721
+ }
1722
+ // D4 (D-handles-dryrun): --diff alone implies --check -- there is no sane reading of "show me
1723
+ // a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
1724
+ // does, without requiring both flags together.
1725
+ const dryRun = flags.check || flags.diff;
1726
+ const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
1727
+ repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff,
1728
+ });
1729
+ // D4: found live while grounding this against a real fixture -- `written` (pre-existing field,
1730
+ // unchanged semantics) unconditionally includes a java-spring `outputs.spec` file like
1731
+ // migration.sql even when its content is byte-identical (P4 already found this: it's never
1732
+ // manifest-tracked, always regenerated). Using `written.length` here would make --check report
1733
+ // "something changed" forever for java-spring, even on a truly up-to-date repo. `actions`
1734
+ // (this item's own new field) carries the real per-file classification, so it's the correct
1735
+ // source for "did anything actually change" -- 'unchanged'/'adopt-unchanged' both mean no.
1736
+ const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
1737
+ const allNotes = [...plan.notes, ...notes];
1738
+ if (flags.force && forced.length === 0 && conflicts.length === 0) allNotes.push('--force had no effect: 0 conflicts found in this run\'s scope');
1739
+ else if (flags.force && forced.length > 0) allNotes.push(`--force overwrote ${forced.length} diverged file(s): ${forced.join(', ')}`);
1740
+
1741
+ // O2: a conflict means SOME generated file diverged from what backend-skeleton last wrote --
1742
+ // files that were safe to (re)write still were, but the `handles` gate does not pass this run
1743
+ // (partial writes are intentional, see D-handles-ownership; blocking the gate on any conflict
1744
+ // is not). D4: --check reports the exact same verdict a real run WOULD reach (same exit 15),
1745
+ // without ever writing -- `written`/`forced` above already say "would" under dryRun since
1746
+ // emitUnits() populates them identically whether or not it actually touched disk.
1747
+ if (blocked) {
1748
+ if (flags.json) {
1749
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: true, gate: null, check: dryRun }, null, 2));
1750
+ } else {
1751
+ const verb = dryRun ? 'would be blocked' : 'blocked';
1752
+ console.error(`${verb}: ${conflicts.length} generated file(s) diverged from what backend-skeleton last wrote -- ${dryRun ? 'a real run would refuse to overwrite them' : 'refusing to overwrite'} without --force:`);
1753
+ for (const c of conflicts) console.error(` ${c.path} (${c.kind}${c.resourceType ? `: ${c.resourceType}` : ''})\n ${c.reason}`);
1754
+ if (written.length > 0) {
1755
+ console.error(`\n${written.length} other file(s) ${dryRun ? 'would still be written' : 'were still written this run'}:`);
1756
+ for (const w of written) console.error(` ${w}`);
1757
+ }
1758
+ if (!dryRun) console.error(`\nre-run with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --force --reason "..."`);
1759
+ if (orphans.length > 0) {
1760
+ console.error('\norphaned (previously generated, no longer in the current plan -- left untouched):');
1761
+ for (const o of orphans) console.error(` ${o.path} (${o.resourceType})`);
1762
+ }
1763
+ if (dryRun) console.error(`\n${renderFileActions(actions)}`);
1764
+ }
1765
+ // D-process-exit-audit: bounded by 7 + plan.resources.length units, no pipe-truncation risk.
1766
+ // Carries a real payload (already printed above in --json mode) -- no diagnostic envelope.
1767
+ // P4 precedent (catalog lint): reused, not a new exit code -- --check reaching the exact
1768
+ // same verdict a real run would (exit 15) is the point, not a distinct "check found a
1769
+ // conflict" code.
1770
+ process.exit(EXIT_CODES.HANDLES_CONFLICT);
1771
+ }
1772
+
1773
+ // D4: dryRun never marks the gate passed -- nothing real happened this run.
1774
+ const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
1775
+
1776
+ if (flags.json) {
1777
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
1778
+ } else if (!flags.quiet) {
1779
+ console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
1780
+ for (const w of written) console.log(` ${w}`);
1781
+ if (allNotes.length > 0) {
1782
+ console.log('\nnotes:');
1783
+ for (const n of allNotes) console.log(` - ${n}`);
1784
+ }
1785
+ if (orphans.length > 0) {
1786
+ console.log('\norphaned (previously generated, no longer in the current plan -- left untouched):');
1787
+ for (const o of orphans) console.log(` ${o.path} (${o.resourceType})`);
1788
+ }
1789
+ if (dryRun) {
1790
+ console.log(`\n${renderFileActions(actions)}`);
1791
+ } else {
1792
+ console.log(`\ngate: handles -> ${gateState.gates.handles.status}`);
1793
+ for (const n of postEmitNotes) console.log(`\n${n}`);
1794
+ }
1795
+ }
1796
+ // D4: --check is CI-friendly by the catalog's own explicit ask -- 0 means fully up to date
1797
+ // (every action is 'unchanged'/'adopt-unchanged'), CHECK_FAILED (the same code P4's `catalog
1798
+ // lint` established for "this run was valid, the checked content is not") means a real run
1799
+ // would actually change something.
1800
+ if (dryRun) process.exit(wouldChange ? EXIT_CODES.CHECK_FAILED : EXIT_CODES.OK);
1801
+ process.exit(0);
1802
+ }
1803
+
1804
+ // A3 (D-patch-strategy): the explicit human gate that must exist BEFORE handles emit generates
1805
+ // any patchField() switch-case -- mirrors cmdContractWaive's exact shape (withLockSync, --reason
1806
+ // required, append-only-by-key record). Re-runs the provider's own plan() to read the CURRENT
1807
+ // classifier output for {resource, field}, rather than trusting whatever --strategy the caller
1808
+ // typed -- an approval whose strategy doesn't match what the classifier says RIGHT NOW is
1809
+ // rejected outright (BAD_ARGS), so a human can never approve a strategy the classifier disagrees
1810
+ // with, and a stale approval from before a DTO change can never be created in the first place.
1811
+ function cmdHandlesPatchApprove(args) {
1812
+ const flags = parseCommand('handles patch approve', args);
1813
+ if (flags.help) { console.log(renderCommandHelp('handles patch approve')); process.exit(0); }
1814
+ setContext('handles patch approve', flags);
1815
+ const root = requireRepoRoot();
1816
+ if (!flags.reason || !flags.reason.trim()) {
1817
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch approve requires --reason "..." -- every approval must be auditable');
1818
+ }
1819
+
1820
+ const scanReport = loadScanReportOrExit(root, flags.feature);
1821
+ const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
1822
+ requireCapabilitiesOrExit(scanReport, 'handles patch approve', { featureId: flags.feature, scanReportPath });
1823
+ const provider = selectProviderOrExit(scanReport);
1824
+ requireProviderCapabilitiesOrExit(scanReport, provider, 'handles patch approve', { featureId: flags.feature, scanReportPath });
1825
+
1826
+ let plan;
1827
+ try {
1828
+ plan = provider.plan({ repoRoot: root, scanReport, module: flags.module, resourceFilter: [flags.resource] });
1829
+ } catch (err) {
1830
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
1831
+ }
1832
+ const resource = plan.resources.find((r) => r.type === flags.resource);
1833
+ if (!resource) {
1834
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no resource "${flags.resource}" found in this plan -- known resources: ${plan.resources.map((r) => r.type).join(', ') || '(none)'}`);
1835
+ }
1836
+ if (resource.updateServiceBlockedReason) {
1837
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.resource}" cannot have any field auto-generated: ${resource.updateServiceBlockedReason}`);
1838
+ }
1839
+ const field = (resource.patchable ?? []).find((f) => f.field === flags.field);
1840
+ if (!field) {
1841
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.resource}.${flags.field}" is not a classified patchable field -- known fields: ${(resource.patchable ?? []).map((f) => f.field).join(', ') || '(none -- see \`bskel handles plan\`\'s notes for why)'}`);
1842
+ }
1843
+ if (field.bucket !== flags.strategy) {
1844
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.resource}.${flags.field}" is currently classified "${field.bucket}", not "${flags.strategy}" -- re-run \`bskel handles plan\` and approve the strategy it actually reports (the DTO may have changed)`);
1845
+ }
1846
+ if (field.bucket !== 'patch-wrapper' && field.bucket !== 'null-means-unchanged') {
1847
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.resource}.${flags.field}" is classified "${field.bucket}" -- this strategy is never auto-generated (see D-patch-strategy in DECISIONS.md), approving it would have no effect`);
1848
+ }
1849
+
1850
+ const updated = withLockSync(root, 'state', () => {
1851
+ const current = loadPatchApprovals(root, flags.feature);
1852
+ const key = approvalKey(flags.resource, flags.field);
1853
+ const at = new Date().toISOString();
1854
+ const entry = { resource: flags.resource, field: flags.field, strategy: flags.strategy, reason: flags.reason, at };
1855
+ const withoutExisting = (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key);
1856
+ const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: [...withoutExisting, entry] };
1857
+ savePatchApprovals(root, flags.feature, next);
1858
+ return next;
1859
+ });
1860
+
1861
+ console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.resource}.${flags.field} -> ${flags.strategy}`);
1862
+ process.exit(0);
1863
+ }
1864
+
1865
+ // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
1866
+ // input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
1867
+ function describeStale(g) {
1868
+ if (g.status !== 'stale') return '';
1869
+ if (g.stale_reason === 'inputs_changed') return ` (stale: ${g.changed_inputs.join(', ')})`;
1870
+ if (g.stale_reason === 'no_recorded_inputs') return ' (stale: recorded before input snapshots existed -- re-run this step for a precise reason)';
1871
+ if (g.stale_reason === 'recorded_inputs_mismatch') return ' (stale: recorded inputs do not reproduce the recorded token -- .sbf state was hand-edited)';
1872
+ return ' (stale)';
1873
+ }
1874
+
1875
+ function renderVerifyReport({ featureId, gates, artifacts, conflicts, build, allowSkipBuild }) {
1876
+ const lines = [`# Verify: ${featureId}`, '', '## Gates'];
1877
+ for (const g of gates) {
1878
+ const marker = g.code === EXIT.PASS ? 'PASS' : g.blocking ? 'FAIL' : `SKIP (${g.status})`;
1879
+ const suffix = g.policy === 'required' ? '' : ` (${g.policy}, ${g.scope}-scoped)`;
1880
+ // A5: surfaces the contract gate's completeness (complete/partial/blocked) and how many
1881
+ // warnings were waived, right in the verify report -- not just visible via `contract emit`'s
1882
+ // own output.
1883
+ const evidence = g.record?.evidence;
1884
+ const completenessNote = g.gate === 'contract' && evidence?.completeness
1885
+ ? ` (${evidence.completeness}${evidence.waived_count ? `: ${evidence.waived_count} waived` : ''})`
1886
+ : '';
1887
+ // S4 (D-gate-history): a revoked gate's reason is exactly the kind of "why is this
1888
+ // blocking" detail describeStale() already surfaces for stale gates -- same treatment here.
1889
+ const revokedNote = g.status === 'revoked' && g.record?.reason ? ` (revoked: ${g.record.reason})` : '';
1890
+ lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${describeStale(g)}${revokedNote}`);
1891
+ }
1892
+ lines.push('', '## Artifacts');
1893
+ for (const a of artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
1894
+ // S6 (D-verify-integrity): only printed when non-empty -- most repos/features have zero
1895
+ // resolver conflicts, and this section existing-but-empty would read as "checked and found
1896
+ // nothing to report" noise on every single verify run.
1897
+ if (conflicts && conflicts.length > 0) {
1898
+ lines.push('', '## Conflicts');
1899
+ for (const c of conflicts) lines.push(`- [CONFLICT] ${c.path}: ${c.reason}`);
1900
+ }
1901
+ if (build) {
1902
+ lines.push('', '## Build');
1903
+ if (!build.ran) {
1904
+ // S6 (D-verify-integrity): an explicit --build request that found no recognized build
1905
+ // tool now BLOCKS the overall verdict unless --allow-skip-build was also passed -- this
1906
+ // note says which case applies, not just "SKIPPED" (which used to read as harmless).
1907
+ const note = allowSkipBuild ? ' (allowed via --allow-skip-build)' : ' (blocking -- pass --allow-skip-build to allow this)';
1908
+ lines.push(`- SKIPPED${note}: ${build.message}`);
1909
+ } else {
1910
+ lines.push(`- [${build.ok ? 'PASS' : 'FAIL'}] ${build.tool}`);
1911
+ if (!build.ok) lines.push('', '```', build.message, '```');
1912
+ }
1913
+ }
1914
+ return `${lines.join('\n')}\n`;
1915
+ }
1916
+
1917
+ function cmdVerify(args) {
1918
+ const flags = parseCommand('verify', args);
1919
+ if (flags.help) { console.log(renderCommandHelp('verify')); process.exit(0); }
1920
+ setContext('verify', flags);
1921
+ const root = requireRepoRoot();
1922
+ const gates = collectGateStatuses(root, flags.feature, { getGate, requireNamedGate });
1923
+ const artifacts = checkArtifacts(root, flags.feature, gates);
1924
+ const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
1925
+ const conflicts = checkResolverConflicts(root, flags.feature, handlesRan);
1926
+ const build = flags.build ? runBuildCheck(root) : null;
1927
+ const allowSkipBuild = flags['allow-skip-build'];
1928
+
1929
+ const gatesOk = gates.every((g) => !g.blocking);
1930
+ const artifactsPresent = artifacts.every((a) => a.exists);
1931
+ // S6 (D-verify-integrity): `conflicts` is deliberately NON-BLOCKING, same "detect and warn,
1932
+ // never gate" precedent as A1 §7's path-prefix signals and A4's DB drift reporting -- and the
1933
+ // SAME reasoning D-gate-precision (S2) already used to keep generated content OUT of the
1934
+ // handles gate's own token: classifyFile()'s `conflict` state cannot distinguish "genuinely
1935
+ // corrupted" from "intentionally hand-finished patchField()", which is the normal, PERMANENT
1936
+ // end state for those files. Confirmed live: an early draft that blocked verify on this made
1937
+ // every hand-finished resolver fail forever, exactly the trap D-gate-precision already warned
1938
+ // against -- caught by test/handles-cli.test.mjs's own existing regression test for it.
1939
+ // `conflicts` still surfaces in the report so a genuinely-unwanted divergence stays visible.
1940
+ // S6 (D-verify-integrity): an explicit --build request that found no recognized build tool
1941
+ // used to be silently treated as "doesn't block" -- confirmed live that this let `bskel verify
1942
+ // --build` report an overall PASS even though the build assurance the user explicitly asked
1943
+ // for never actually ran. Now only acceptable with the explicit --allow-skip-build opt-out.
1944
+ const buildOk = !build || build.ok || (!build.ran && allowSkipBuild);
1945
+ const overallPass = gatesOk && artifactsPresent && buildOk;
1946
+
1947
+ if (flags.json) {
1948
+ console.log(JSON.stringify({ feature: flags.feature, pass: overallPass, gates, artifacts, conflicts, build }, null, 2));
1949
+ } else if (!flags.quiet) {
1950
+ console.log(renderVerifyReport({ featureId: flags.feature, gates, artifacts, conflicts, build, allowSkipBuild }));
1951
+ console.log(overallPass ? 'VERIFY: PASS' : 'VERIFY: FAIL');
1952
+ }
1953
+ // This exit code (0/1) carries a real payload (the report just printed) -- never a diagnostic
1954
+ // envelope on top of it, matching the "one execution, one JSON document" rule.
1955
+ process.exit(overallPass ? 0 : 1);
1956
+ }
1957
+
1958
+ // D1: same per-gate line shape renderVerifyReport uses (reusing describeStale), but framed as
1959
+ // "where am I" rather than a pass/fail verdict -- no VERIFY: PASS/FAIL line, and blocked_by/
1960
+ // next_actions/optional_not_run are appended so a human doesn't have to re-derive them by eye.
1961
+ function renderStatusReport(featureId, state) {
1962
+ const lines = [`# Status: ${featureId ?? '(no feature -- repo scope only)'}`, '', '## Gates'];
1963
+ for (const g of state.gates) {
1964
+ const marker = g.code === EXIT.PASS ? 'PASS' : g.blocking ? 'BLOCKING' : `(${g.status})`;
1965
+ const suffix = g.policy === 'required' ? '' : ` (${g.policy}, ${g.scope}-scoped)`;
1966
+ lines.push(`- [${marker}] ${g.gate}${suffix}${describeStale(g)}`);
1967
+ }
1968
+ if (state.artifacts.length > 0) {
1969
+ lines.push('', '## Artifacts');
1970
+ for (const a of state.artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
1971
+ }
1972
+ lines.push('', '## Next');
1973
+ if (state.next_actions.length > 0) {
1974
+ lines.push(`- ${state.next_actions[0].command} # ${state.next_actions[0].reason}`);
1975
+ } else {
1976
+ lines.push('- nothing blocking');
1977
+ }
1978
+ if (state.optional_not_run.length > 0) {
1979
+ lines.push('', `## Optional, not yet run: ${state.optional_not_run.join(', ')}`);
1980
+ }
1981
+ return `${lines.join('\n')}\n`;
1982
+ }
1983
+
1984
+ function cmdStatus(args) {
1985
+ const flags = parseCommand('status', args);
1986
+ if (flags.help) { console.log(renderCommandHelp('status')); process.exit(0); }
1987
+ setContext('status', flags);
1988
+ const root = requireRepoRoot();
1989
+ if (flags.feature) requireValidFeatureId(flags.feature);
1990
+ const state = computeWorkflowState(root, flags.feature);
1991
+ if (flags.json) {
1992
+ console.log(JSON.stringify({ feature: flags.feature, ...state }, null, 2));
1993
+ } else if (!flags.quiet) {
1994
+ console.log(renderStatusReport(flags.feature, state));
1995
+ }
1996
+ process.exit(0);
1997
+ }
1998
+
1999
+ // D1: prints exactly ONE copy-pasteable command on stdout (nothing else) so `$(bskel next)` is
2000
+ // safe to eval directly -- the reason it was chosen goes to stderr instead, matching the same
2001
+ // stdout/stderr split cmdContractEmit/cmdHandlesEmit already use for "here's the data" vs. "here's
2002
+ // what went wrong" output. Deliberately no --execute flag -- see D-status-next's EXIT in
2003
+ // DECISIONS.md for why running the recommended (often mutating) command automatically is out of
2004
+ // scope for this slice. D2: this stdout line is `next`'s entire PAYLOAD, not narration -- --quiet
2005
+ // deliberately does not touch it (quieting it would defeat the command's whole purpose).
2006
+ function cmdNext(args) {
2007
+ const flags = parseCommand('next', args);
2008
+ if (flags.help) { console.log(renderCommandHelp('next')); process.exit(0); }
2009
+ setContext('next', flags);
2010
+ const root = requireRepoRoot();
2011
+ if (flags.feature) requireValidFeatureId(flags.feature);
2012
+ const state = computeWorkflowState(root, flags.feature);
2013
+ if (flags.json) {
2014
+ console.log(JSON.stringify({ feature: flags.feature, blocked_by: state.blocked_by, next_actions: state.next_actions, optional_not_run: state.optional_not_run }, null, 2));
2015
+ } else if (state.next_actions.length > 0) {
2016
+ console.log(state.next_actions[0].command);
2017
+ console.error(`# ${state.next_actions[0].reason}`);
2018
+ } else {
2019
+ console.log('# nothing blocking -- feature workflow complete (or no --feature given and preflight already passed)');
2020
+ }
2021
+ process.exit(0);
2022
+ }
2023
+
2024
+ // D5: renders whatever lib/doctor.mjs's computeDoctorChecks() decided -- this function is pure
2025
+ // CLI glue (arg parsing + printing), same split as D1's cmdStatus/lib/workflow.mjs.
2026
+ function cmdDoctor(args) {
2027
+ const flags = parseCommand('doctor', args);
2028
+ if (flags.help) { console.log(renderCommandHelp('doctor')); process.exit(0); }
2029
+ setContext('doctor', flags);
2030
+ const root = repoRoot();
2031
+
2032
+ let checks;
2033
+ let showAdapters;
2034
+ try {
2035
+ ({ checks, showAdapters } = computeDoctorChecks(root, { workflow: flags.workflow }));
2036
+ } catch (err) {
2037
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2038
+ }
2039
+
2040
+ const adapters = showAdapters
2041
+ ? ADAPTERS.map((a) => ({
2042
+ id: a.id, specificity: a.specificity, confidence: a.confidence, capabilities: a.capabilities,
2043
+ // `detect()` itself can return null on a legitimate non-match -- coerce to a real
2044
+ // boolean here so `null` unambiguously means "not applicable, no root" below, not
2045
+ // "detect() happened to return a falsy value".
2046
+ detects: root ? Boolean(a.detect(root)) : null,
2047
+ diagnostics: root && typeof a.diagnostics === 'function' ? a.diagnostics(root) : [],
2048
+ }))
2049
+ : [];
2050
+ const loadErrors = showAdapters ? LOAD_ERRORS : [];
2051
+
2052
+ // D5: `required:false` checks never affect the verdict -- this is the direct fix for `gh`
2053
+ // being unconditionally required before (missing `gh` failed `bskel doctor` even though
2054
+ // preflight itself already tolerates its absence). See D-doctor-workflow in DECISIONS.md.
2055
+ const allOk = checks.every((c) => c.required ? c.ok : true) && loadErrors.length === 0;
2056
+
2057
+ if (flags.json) {
2058
+ console.log(JSON.stringify({ workflow: flags.workflow, checks, adapters, load_errors: loadErrors, ok: allOk }, null, 2));
2059
+ process.exit(allOk ? 0 : 1);
2060
+ }
2061
+
2062
+ if (!flags.quiet) {
2063
+ for (const c of checks) {
2064
+ const marker = c.ok ? 'OK ' : (c.required ? 'FAIL' : 'WARN');
2065
+ console.log(`${marker} ${c.name}${c.detail ? ` (${c.detail})` : ''}`);
2066
+ if (!c.ok && c.remediation) console.log(` -> ${c.remediation}`);
2067
+ }
2068
+
2069
+ if (showAdapters) {
2070
+ console.log('');
2071
+ console.log('Scanner adapters:');
2072
+ for (const a of adapters) {
2073
+ const caps = Object.entries(a.capabilities).filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)';
2074
+ let line = ` ${a.id} (specificity ${a.specificity}, confidence ${a.confidence}) -- capabilities: ${caps}`;
2075
+ if (a.detects !== null) line += a.detects ? ' -- DETECTS this repo' : ' -- does not detect this repo';
2076
+ console.log(line);
2077
+ for (const d of a.diagnostics) console.log(` [${d.level}] ${d.code}: ${d.message}`);
2078
+ }
2079
+ for (const e of loadErrors) console.log(` FAIL ${e.file}: ${e.message}`);
2080
+ }
2081
+ }
2082
+
2083
+ process.exit(allOk ? 0 : 1);
2084
+ }
2085
+
2086
+ // P2 (D-greenfield-bootstrap): the one path into this tool that doesn't require an existing git
2087
+ // repo (contrast requireRepoRoot(), used by nearly everything else) -- `bskel new` is what CREATES
2088
+ // one. `--stack`'s two choices come from new/index.mjs's plain dispatch map, not a dynamic
2089
+ // registry (no third-party-extensibility need for exactly two first-party stacks). Deliberately
2090
+ // never creates a remote or auto-chains into `preflight` -- see this function's own printed
2091
+ // guidance for why `bskel preflight` cannot simply be "the next command" here (it requires a real
2092
+ // origin remote with a resolvable default branch, which a brand-new local-only repo doesn't have).
2093
+ // P2b (D-greenfield-parameters): every parameter check below runs BEFORE `stack.scaffold(...)` --
2094
+ // before any network call and before any filesystem write -- so a rejected invocation leaves
2095
+ // nothing behind at all. Order matters: an explicitly-refused flag gets its own cited reason first,
2096
+ // then a wrong-stack flag names the stack that actually takes it, then the local validators, and
2097
+ // only then the one check that costs a network round-trip (--java-version).
2098
+ function requireStackParams(stack, flags) {
2099
+ for (const param of ALL_STACK_PARAMS) {
2100
+ if (flags[param] == null) continue;
2101
+ const refusal = stack.refusedParams[param];
2102
+ if (refusal) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', refusal);
2103
+ if (stack.acceptedParams.includes(param)) continue;
2104
+ const owners = stacksAccepting(param);
2105
+ const owned = owners.length > 0 ? ` -- it applies to ${owners.map((id) => `--stack ${id}`).join(' / ')}` : '';
2106
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--${param} is not a \`--stack ${stack.id}\` parameter${owned}. Nothing was written.`);
2107
+ }
2108
+ }
2109
+
2110
+ // Returns the stack-specific half of the scaffold() call, with every value already validated.
2111
+ // Throws (never exits) so cmdNew's own single catch turns a validator message into one clean
2112
+ // BAD_ARGS line the same way every other domain validator in this CLI already does.
2113
+ async function resolveNewParams(stack, flags) {
2114
+ const common = {
2115
+ name: flags.name == null ? null : requireSingleLineText(flags.name, 'name'),
2116
+ description: flags.description == null ? null : requireSingleLineText(flags.description, 'description'),
2117
+ projectVersion: flags['project-version'] == null ? null : requireSingleLineText(flags['project-version'], 'project-version'),
2118
+ };
2119
+
2120
+ if (stack.id === 'fastapi') {
2121
+ const python = flags['python-version'] == null ? null : requireValidPythonVersion(flags['python-version']);
2122
+ return {
2123
+ params: {
2124
+ ...common,
2125
+ // Narrower than the shared single-line check: this one lands in pyproject.toml's
2126
+ // [project] name, which pip itself validates (see new/params.mjs -- found live).
2127
+ name: flags.name == null ? null : requireValidPythonProjectName(flags.name),
2128
+ requiresPython: python?.requiresPython ?? null,
2129
+ port: flags.port,
2130
+ license: flags.license == null ? null : requireValidLicense(flags.license),
2131
+ database: flags.database == null ? null : requireValidDatabase(flags.database),
2132
+ },
2133
+ warnings: python?.warnings ?? [],
2134
+ };
2135
+ }
2136
+
2137
+ const groupId = flags['group-id'] == null ? DEFAULT_GROUP_ID : requireValidJavaPackageName(flags['group-id'], 'group-id');
2138
+ const { dependencies, warnings } = resolveSpringDependencies({
2139
+ dependencies: flags.dependencies,
2140
+ addDependencies: flags['add-dependencies'],
2141
+ });
2142
+ // The one check that costs a network round-trip, so it runs last and only when a value that is
2143
+ // not already the default was actually passed. Never cached, never persisted -- start.spring.io's
2144
+ // own metadata document is the authority, consulted on demand (see new/params.mjs).
2145
+ if (flags['java-version'] != null && flags['java-version'] !== DEFAULT_JAVA_VERSION) {
2146
+ await requireSupportedJavaVersion(flags['java-version']);
2147
+ }
2148
+ return {
2149
+ params: {
2150
+ ...common,
2151
+ groupId,
2152
+ artifactId: flags['artifact-id'] == null ? null : requireValidArtifactId(flags['artifact-id']),
2153
+ packageName: flags['package-name'] == null ? null : requireValidJavaPackageName(flags['package-name'], 'package-name'),
2154
+ javaVersion: flags['java-version'] ?? DEFAULT_JAVA_VERSION,
2155
+ // Pass-through: start.spring.io answers an unknown packaging with a clean HTTP 400 whose
2156
+ // own `message` scaffoldSpring() now surfaces verbatim (measured -- see
2157
+ // D-greenfield-parameters' validation matrix). A local list would go stale; Initializr's
2158
+ // own answer cannot.
2159
+ packaging: flags.packaging,
2160
+ dependencies,
2161
+ },
2162
+ warnings,
2163
+ };
2164
+ }
2165
+
2166
+ async function cmdNew(args) {
2167
+ const flags = parseCommand('new', args);
2168
+ if (flags.help) { console.log(renderCommandHelp('new')); process.exit(0); }
2169
+ setContext('new', flags);
2170
+
2171
+ const stack = NEW_STACKS[flags.stack];
2172
+ if (!stack) {
2173
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `bskel new --stack must be one of: ${Object.keys(NEW_STACKS).join(', ')} (got ${JSON.stringify(flags.stack)})`);
2174
+ }
2175
+ requireValidSlug(flags.slug);
2176
+ requireStackParams(stack, flags);
2177
+
2178
+ let stackParams;
2179
+ let warnings;
2180
+ try {
2181
+ ({ params: stackParams, warnings } = await resolveNewParams(stack, flags));
2182
+ } catch (err) {
2183
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2184
+ }
2185
+ // Printed BEFORE the scaffold, so the danger is visible even if the download then fails -- and on
2186
+ // stderr, which this CLI's contract says is never suppressed by --quiet and never mixed into a
2187
+ // --json payload's stdout.
2188
+ for (const w of warnings) console.error(w);
2189
+
2190
+ const dir = flags.dir ? path.resolve(flags.dir) : path.resolve(process.cwd(), flags.slug);
2191
+
2192
+ let result;
2193
+ try {
2194
+ result = await stack.scaffold({ dir, slug: flags.slug, offline: flags.offline, ...stackParams });
2195
+ } catch (err) {
2196
+ fail(EXIT_CODES.NOT_PASSED, 'SCAFFOLD_FAILED', err.message);
2197
+ }
2198
+
2199
+ execFileSync('git', ['init', '--quiet'], { cwd: dir });
2200
+ execFileSync('git', ['add', '-A'], { cwd: dir });
2201
+ // A genuinely fresh environment (a container, a CI runner, an agent-driven bootstrap) may have
2202
+ // no git identity configured anywhere -- `git commit` would otherwise fail outright. Only
2203
+ // supplies a placeholder identity when NEITHER user.email NOR user.name is already resolvable
2204
+ // (any config scope) -- a real user's own already-configured identity is never overridden.
2205
+ // Found live: this exact gap broke CI (a fresh runner, no global git config) even though it
2206
+ // worked locally throughout development (a real identity was already configured there).
2207
+ const hasGitIdentity = (key) => {
2208
+ try {
2209
+ return execFileSync('git', ['config', key], { cwd: dir, encoding: 'utf8' }).trim() !== '';
2210
+ } catch {
2211
+ return false;
2212
+ }
2213
+ };
2214
+ const commitArgs = ['commit', '--quiet', '-m', `chore: scaffold ${flags.stack} project via bskel new`];
2215
+ if (!hasGitIdentity('user.email') || !hasGitIdentity('user.name')) {
2216
+ commitArgs.unshift('-c', 'user.email=bskel@localhost', '-c', 'user.name=bskel');
2217
+ }
2218
+ execFileSync('git', commitArgs, { cwd: dir });
2219
+
2220
+ const { postScaffoldNotes = [], ...resultRest } = result;
2221
+ if (flags.json) {
2222
+ console.log(JSON.stringify({ stack: flags.stack, dir, ...resultRest, warnings, postScaffoldNotes }, null, 2));
2223
+ } else if (!flags.quiet) {
2224
+ console.log(`scaffolded a new ${flags.stack} project at ${dir}`);
2225
+ // Same shape as `handles emit`'s postEmitNotes: "this really happened, and here is the part
2226
+ // that deliberately did NOT happen", on stdout as narration rather than stderr as a warning.
2227
+ for (const n of postScaffoldNotes) {
2228
+ console.log('');
2229
+ console.log(n);
2230
+ }
2231
+ console.log('');
2232
+ console.log('git init + an initial commit were made locally -- bskel preflight needs a REAL remote');
2233
+ console.log('with a resolvable default branch, which this command deliberately does not create:');
2234
+ console.log(` 1. cd ${dir}`);
2235
+ console.log(' 2. create a remote repo yourself (e.g. `gh repo create <name> --private --source=. --push`), or push to one you already own');
2236
+ console.log(' 3. git remote set-head origin --auto (or: git remote set-head origin <branch>)');
2237
+ console.log(' 4. bskel preflight');
2238
+ }
2239
+ process.exit(0);
2240
+ }
2241
+
2242
+ function printVersion(json) {
2243
+ const pkg = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8'));
2244
+ if (json) console.log(JSON.stringify({ name: 'bskel', version: pkg.version }));
2245
+ else console.log(`bskel ${pkg.version}`);
2246
+ process.exit(0);
2247
+ }
2248
+
2249
+ // D2: `--help`/`help`/bare `bskel` and `--version` are handled BEFORE this switch -- they are not
2250
+ // "a command's own arguments are bad", they're requests for information that never touch a repo,
2251
+ // a gate, or any command-specific parsing. Every other thrown error (a CliUsageError from
2252
+ // parseCommand(), or a plain Error from a domain validator like requireValidFeatureId/
2253
+ // requireValidSlug that used to propagate as an uncaught exception) is caught here and turned
2254
+ // into a clean, single-line diagnosis -- see D-cli-contract in DECISIONS.md for the crash this
2255
+ // fixes (`bskel verify --feature --json` used to print a full Node stack trace).
2256
+ // `new` (P2/D-greenfield-bootstrap) is the one command needing a real `await` (its network call
2257
+ // to start.spring.io) -- main()/dispatchCommand() are `async` for that one case only; every other
2258
+ // command stays a plain synchronous function returning immediately (awaiting a non-Promise value
2259
+ // is a harmless no-op), so this is a minimal-diff change, not a rewrite of the dispatch shape.
2260
+ async function main() {
2261
+ const argv = process.argv.slice(2);
2262
+ const [cmd, ...rest] = argv;
2263
+
2264
+ if (cmd === undefined || cmd === 'help' || cmd === '--help') {
2265
+ printUsageToStdout();
2266
+ process.exit(0);
2267
+ }
2268
+ if (cmd === '--version') {
2269
+ printVersion(rest.includes('--json'));
2270
+ return;
2271
+ }
2272
+
2273
+ try {
2274
+ await dispatchCommand(cmd, rest);
2275
+ } catch (err) {
2276
+ // A JS-native error class (TypeError/ReferenceError/RangeError) signals something this
2277
+ // codebase itself got wrong, not a bad user input -- everything else reaching here is
2278
+ // either a CliUsageError (parseCommand()) or a plain, message-only Error a domain
2279
+ // validator (requireValidFeatureId/requireValidSlug/requireValidFeatureOrRepoId, or a
2280
+ // malformed-state read) deliberately threw with an already user-facing message.
2281
+ const isInternalBug = err instanceof TypeError || err instanceof ReferenceError || err instanceof RangeError;
2282
+ const jsonRequested = CTX.command ? CTX.json : argv.includes('--json');
2283
+ const commandName = CTX.command ?? cmd ?? '(none)';
2284
+
2285
+ if (isInternalBug) {
2286
+ console.error(`bskel: internal error: ${err.message}`);
2287
+ if (process.env.BSKEL_DEBUG === '1') console.error(err.stack);
2288
+ if (jsonRequested) console.log(JSON.stringify(diagnostic({ command: commandName, code: EXIT_CODES.CHECK_FAILED, reason: 'INTERNAL_ERROR', message: err.message }), null, 2));
2289
+ process.exit(EXIT_CODES.CHECK_FAILED);
2290
+ }
2291
+
2292
+ console.error(err.message);
2293
+ if (jsonRequested) console.log(JSON.stringify(diagnostic({ command: commandName, code: EXIT_CODES.BAD_ARGS, reason: 'BAD_ARGS', message: err.message }), null, 2));
2294
+ process.exit(EXIT_CODES.BAD_ARGS);
2295
+ }
2296
+ }
2297
+
2298
+ async function dispatchCommand(cmd, rest) {
2299
+ switch (cmd) {
2300
+ case 'preflight':
2301
+ cmdPreflight(rest);
2302
+ break;
2303
+ case 'scan': {
2304
+ if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
2305
+ if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
2306
+ await cmdScan(rest);
2307
+ break;
2308
+ }
2309
+ case 'feature': {
2310
+ if (rest[0] === 'init') return cmdFeatureInit(rest.slice(1));
2311
+ if (rest[0] === 'list') return cmdFeatureList(rest.slice(1));
2312
+ if (rest[0] === 'show') return cmdFeatureShow(rest.slice(1));
2313
+ if (rest[0] === 'rename') return cmdFeatureRename(rest.slice(1));
2314
+ if (rest[0] === 'link') return cmdFeatureLink(rest.slice(1));
2315
+ if (rest[0] === 'archive') return cmdFeatureArchive(rest.slice(1));
2316
+ usage();
2317
+ process.exit(14);
2318
+ break;
2319
+ }
2320
+ case 'contract': {
2321
+ const sub = rest[0];
2322
+ const subArgs = rest.slice(1);
2323
+ if (sub === 'emit') return cmdContractEmit(subArgs);
2324
+ if (sub === 'export') return cmdContractExport(subArgs);
2325
+ if (sub === 'validate') return cmdContractValidate(subArgs);
2326
+ if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
2327
+ if (sub === 'waive') return cmdContractWaive(subArgs);
2328
+ usage();
2329
+ process.exit(14);
2330
+ break;
2331
+ }
2332
+ case 'stack': {
2333
+ if (rest[0] === 'apply') return cmdStackApply(rest.slice(1));
2334
+ usage();
2335
+ process.exit(14);
2336
+ break;
2337
+ }
2338
+ case 'catalog': {
2339
+ if (rest[0] === 'lint') return cmdCatalogLint(rest.slice(1));
2340
+ usage();
2341
+ process.exit(14);
2342
+ break;
2343
+ }
2344
+ case 'handles': {
2345
+ if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
2346
+ if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
2347
+ if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
2348
+ usage();
2349
+ process.exit(14);
2350
+ break;
2351
+ }
2352
+ case 'verify':
2353
+ cmdVerify(rest);
2354
+ break;
2355
+ case 'status':
2356
+ cmdStatus(rest);
2357
+ break;
2358
+ case 'next':
2359
+ cmdNext(rest);
2360
+ break;
2361
+ case 'gate': {
2362
+ const sub = rest[0];
2363
+ const subArgs = rest.slice(1);
2364
+ if (sub === 'require') return cmdGateRequire(subArgs);
2365
+ if (sub === 'force') return cmdGateForce(subArgs);
2366
+ if (sub === 'revoke') return cmdGateRevoke(subArgs);
2367
+ if (sub === 'history') return cmdGateHistory(subArgs);
2368
+ if (sub === 'show') return cmdGateShow(subArgs);
2369
+ usage();
2370
+ process.exit(14);
2371
+ break;
2372
+ }
2373
+ case 'doctor':
2374
+ cmdDoctor(rest);
2375
+ break;
2376
+ case 'new':
2377
+ return cmdNew(rest);
2378
+ default:
2379
+ usage();
2380
+ process.exit(cmd ? 14 : 0);
2381
+ }
2382
+ }
2383
+
2384
+ main();