@smartmemory/compose 0.1.6-beta → 0.1.7-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/compose.js CHANGED
@@ -1180,92 +1180,129 @@ if (cmd === 'hooks') {
1180
1180
  const hooksDir = pjoin(gitDir, 'hooks')
1181
1181
  const { mkdirSync: mSync } = await import('fs')
1182
1182
  mSync(hooksDir, { recursive: true })
1183
- const hookPath = pjoin(hooksDir, 'post-commit')
1184
-
1185
- // Marker line used to identify our hooks
1186
- const HOOK_MARKER = '# Compose post-commit hook —'
1187
1183
 
1188
1184
  // Resolve absolute paths for substitution
1189
1185
  const composeBin = presolve(presolve(futp(import.meta.url), '..'), 'compose.js')
1190
1186
  const composeNode = process.execPath
1191
1187
 
1192
- if (sub === 'install') {
1193
- // Read template
1194
- const templatePath = pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template')
1188
+ // Hook-type table. Each entry knows its template, marker, and destination.
1189
+ const HOOK_TYPES = {
1190
+ 'post-commit': {
1191
+ template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template'),
1192
+ marker: '# Compose post-commit hook —',
1193
+ dest: pjoin(hooksDir, 'post-commit'),
1194
+ },
1195
+ 'pre-push': {
1196
+ template: pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'pre-push.template'),
1197
+ marker: '# Compose pre-push hook —',
1198
+ dest: pjoin(hooksDir, 'pre-push'),
1199
+ },
1200
+ }
1201
+
1202
+ // Determine which hook types this invocation operates on.
1203
+ // Flags: --pre-push, --post-commit, or none (default = post-commit, back-compat).
1204
+ const selectedTypes = []
1205
+ if (hookFlags['pre-push']) selectedTypes.push('pre-push')
1206
+ if (hookFlags['post-commit']) selectedTypes.push('post-commit')
1207
+ if (selectedTypes.length === 0) selectedTypes.push('post-commit') // default = back-compat
1208
+
1209
+ function installOne(type) {
1210
+ const { template: tplPath, marker, dest } = HOOK_TYPES[type]
1195
1211
  let template
1196
1212
  try {
1197
- template = rfSync(templatePath, 'utf-8')
1213
+ template = rfSync(tplPath, 'utf-8')
1198
1214
  } catch (err) {
1199
- console.error(`Error: could not read hook template: ${err.message}`)
1200
- process.exit(1)
1215
+ console.error(`Error: could not read ${type} template: ${err.message}`)
1216
+ return 1
1201
1217
  }
1202
-
1203
- // Check existing hook
1204
- if (exSync(hookPath)) {
1205
- const existing = rfSync(hookPath, 'utf-8')
1206
- const isOurs = existing.includes(HOOK_MARKER)
1218
+ if (exSync(dest)) {
1219
+ const existing = rfSync(dest, 'utf-8')
1220
+ const isOurs = existing.includes(marker)
1207
1221
  if (!isOurs && !hookFlags.force) {
1208
- console.error('Error: a foreign post-commit hook already exists at ' + hookPath)
1209
- console.error('')
1210
- console.error('To chain our hook after yours, add this to your existing hook:')
1222
+ console.error(`Error: a foreign ${type} hook already exists at ${dest}`)
1211
1223
  console.error('')
1212
- console.error(` "${composeNode}" "${composeBin}" record-completion ...`)
1213
- console.error('')
1214
- console.error('Or run `compose hooks install --force` to overwrite.')
1215
- process.exit(1)
1224
+ console.error(`Run \`compose hooks install --${type} --force\` to overwrite.`)
1225
+ return 1
1216
1226
  }
1217
1227
  }
1218
-
1219
- // Substitute placeholders
1220
1228
  const substituted = template
1221
1229
  .replace(/__COMPOSE_NODE__/g, composeNode)
1222
1230
  .replace(/__COMPOSE_BIN__/g, composeBin)
1223
-
1224
- wfSync(hookPath, substituted)
1225
- chmodSync(hookPath, 0o755)
1226
- console.log(`Installed post-commit hook at ${hookPath}`)
1231
+ wfSync(dest, substituted)
1232
+ chmodSync(dest, 0o755)
1233
+ console.log(`Installed ${type} hook at ${dest}`)
1227
1234
  console.log(` COMPOSE_NODE=${composeNode}`)
1228
1235
  console.log(` COMPOSE_BIN=${composeBin}`)
1229
- process.exit(0)
1236
+ return 0
1230
1237
  }
1231
1238
 
1232
- if (sub === 'uninstall') {
1233
- if (!exSync(hookPath)) {
1234
- console.log('No post-commit hook installed.')
1235
- process.exit(0)
1239
+ function uninstallOne(type) {
1240
+ const { marker, dest } = HOOK_TYPES[type]
1241
+ if (!exSync(dest)) {
1242
+ console.log(`No ${type} hook installed.`)
1243
+ return 0
1236
1244
  }
1237
- const content = rfSync(hookPath, 'utf-8')
1238
- if (!content.includes(HOOK_MARKER)) {
1239
- console.warn('Warning: post-commit hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.')
1240
- process.exit(0)
1245
+ const content = rfSync(dest, 'utf-8')
1246
+ if (!content.includes(marker)) {
1247
+ console.warn(`Warning: ${type} hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.`)
1248
+ return 0
1241
1249
  }
1242
- const { rmSync: rmS } = await import('fs')
1243
- rmS(hookPath)
1244
- console.log(`Removed post-commit hook at ${hookPath}`)
1245
- process.exit(0)
1250
+ const { rmSync: rmS } = require('fs')
1251
+ rmS(dest)
1252
+ console.log(`Removed ${type} hook at ${dest}`)
1253
+ return 0
1246
1254
  }
1247
1255
 
1248
- // status (default)
1249
- if (!sub || sub === 'status') {
1250
- if (!exSync(hookPath)) {
1251
- console.log('absent — no post-commit hook installed')
1252
- process.exit(0)
1256
+ function statusOne(type) {
1257
+ const { marker, dest } = HOOK_TYPES[type]
1258
+ if (!exSync(dest)) {
1259
+ console.log(`${type}: absent — no hook installed`)
1260
+ return
1253
1261
  }
1254
- const content = rfSync(hookPath, 'utf-8')
1255
- if (!content.includes(HOOK_MARKER)) {
1256
- console.log('foreign — post-commit hook exists but is not a Compose hook')
1257
- process.exit(0)
1262
+ const content = rfSync(dest, 'utf-8')
1263
+ if (!content.includes(marker)) {
1264
+ console.log(`${type}: foreign — hook exists but is not a Compose hook`)
1265
+ return
1258
1266
  }
1259
- // Check if paths match current
1260
- const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1261
- const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1267
+ const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1268
+ const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1262
1269
  if (nodeMatch && binMatch) {
1263
- console.log('installed (current)')
1270
+ console.log(`${type}: installed (current)`)
1264
1271
  } else {
1265
- console.log('installed (stale paths — re-run install)')
1272
+ console.log(`${type}: installed (stale paths — re-run install)`)
1266
1273
  if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1267
1274
  if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1268
1275
  }
1276
+ }
1277
+
1278
+ if (sub === 'install') {
1279
+ let exitCode = 0
1280
+ for (const t of selectedTypes) exitCode = installOne(t) || exitCode
1281
+ process.exit(exitCode)
1282
+ }
1283
+
1284
+ if (sub === 'uninstall') {
1285
+ const { rmSync: _rmS } = await import('fs') // ensure fs.rmSync is available
1286
+ // uninstallOne calls require('fs') but we're ESM — replace with import-based deletion
1287
+ for (const t of selectedTypes) {
1288
+ const { marker, dest } = HOOK_TYPES[t]
1289
+ if (!exSync(dest)) { console.log(`No ${t} hook installed.`); continue }
1290
+ const content = rfSync(dest, 'utf-8')
1291
+ if (!content.includes(marker)) {
1292
+ console.warn(`Warning: ${t} hook exists but does not appear to be a Compose hook (marker not found). Leaving alone.`)
1293
+ continue
1294
+ }
1295
+ _rmS(dest)
1296
+ console.log(`Removed ${t} hook at ${dest}`)
1297
+ }
1298
+ process.exit(0)
1299
+ }
1300
+
1301
+ // status (default)
1302
+ if (!sub || sub === 'status') {
1303
+ // Status reports on ALL known hook types (selection flags ignored), so users
1304
+ // see the full picture. Selection only affects install/uninstall.
1305
+ for (const t of Object.keys(HOOK_TYPES)) statusOne(t)
1269
1306
  process.exit(0)
1270
1307
  }
1271
1308
 
@@ -1273,6 +1310,100 @@ if (cmd === 'hooks') {
1273
1310
  process.exit(1)
1274
1311
  }
1275
1312
 
1313
+ if (cmd === 'validate') {
1314
+ // compose validate [--scope=feature|project] [--code=CODE] [--block-on=error|warning|info] [--json]
1315
+ let scope = 'project'
1316
+ let code = null
1317
+ let blockOn = 'error'
1318
+ let asJson = false
1319
+ for (let i = 0; i < args.length; i++) {
1320
+ const a = args[i]
1321
+ if (a === '--help' || a === '-h') {
1322
+ console.log(`Usage: compose validate [options]
1323
+
1324
+ Options:
1325
+ --scope=feature|project Scope (default: project)
1326
+ --code=CODE Feature code (required when scope=feature)
1327
+ --block-on=LEVEL Exit non-zero if any finding >= LEVEL (default: error)
1328
+ LEVEL: error | warning | info
1329
+ --json Emit findings as JSON (default: human-readable)
1330
+
1331
+ Exit codes:
1332
+ 0 no findings >= block-on threshold
1333
+ 1 findings >= block-on threshold present
1334
+ 2 usage error`)
1335
+ process.exit(0)
1336
+ }
1337
+ if (a === '--json') { asJson = true; continue }
1338
+ if (a.startsWith('--scope=')) scope = a.slice('--scope='.length)
1339
+ else if (a === '--scope') scope = args[++i]
1340
+ else if (a.startsWith('--code=')) code = a.slice('--code='.length)
1341
+ else if (a === '--code') code = args[++i]
1342
+ else if (a.startsWith('--block-on=')) blockOn = a.slice('--block-on='.length)
1343
+ else if (a === '--block-on') blockOn = args[++i]
1344
+ else if (a.startsWith('--')) {
1345
+ console.error(`Unknown flag: ${a}`)
1346
+ process.exit(2)
1347
+ }
1348
+ }
1349
+ if (!['feature', 'project'].includes(scope)) {
1350
+ console.error(`Invalid --scope=${scope}; expected feature or project`)
1351
+ process.exit(2)
1352
+ }
1353
+ if (scope === 'feature' && !code) {
1354
+ console.error(`--scope=feature requires --code=<CODE>`)
1355
+ process.exit(2)
1356
+ }
1357
+ if (!['error', 'warning', 'info'].includes(blockOn)) {
1358
+ console.error(`Invalid --block-on=${blockOn}; expected error, warning, or info`)
1359
+ process.exit(2)
1360
+ }
1361
+
1362
+ const { validateFeature, validateProject } = await import('../lib/feature-validator.js')
1363
+ let result
1364
+ try {
1365
+ result = scope === 'feature'
1366
+ ? await validateFeature(process.cwd(), code)
1367
+ : await validateProject(process.cwd())
1368
+ } catch (err) {
1369
+ if (err.code === 'INVALID_INPUT') {
1370
+ console.error(`Error [INVALID_INPUT]: ${err.message}`)
1371
+ process.exit(2)
1372
+ }
1373
+ console.error(`Error: ${err.message}`)
1374
+ process.exit(2)
1375
+ }
1376
+
1377
+ // Threshold: findings at or above this severity block the exit code
1378
+ const SEV_RANK = { error: 3, warning: 2, info: 1 }
1379
+ const threshold = SEV_RANK[blockOn]
1380
+ const blocking = result.findings.filter((f) => SEV_RANK[f.severity] >= threshold)
1381
+
1382
+ if (asJson) {
1383
+ console.log(JSON.stringify(result, null, 2))
1384
+ } else {
1385
+ const byKind = {}
1386
+ for (const f of result.findings) {
1387
+ const sev = f.severity.toUpperCase()
1388
+ const tag = `[${sev}] ${f.kind}${f.feature_code ? ' ' + f.feature_code : ''}`
1389
+ if (!byKind[tag]) byKind[tag] = []
1390
+ byKind[tag].push(f.detail)
1391
+ }
1392
+ if (result.findings.length === 0) {
1393
+ console.log(`compose validate: no findings (scope=${scope}${code ? ' code=' + code : ''})`)
1394
+ } else {
1395
+ console.log(`compose validate findings (scope=${scope}${code ? ' code=' + code : ''}):`)
1396
+ for (const tag of Object.keys(byKind).sort()) {
1397
+ console.log(` ${tag}`)
1398
+ for (const detail of byKind[tag]) console.log(` - ${detail}`)
1399
+ }
1400
+ console.log(`\n${result.findings.length} finding(s); ${blocking.length} at or above --block-on=${blockOn}`)
1401
+ }
1402
+ }
1403
+
1404
+ process.exit(blocking.length > 0 ? 1 : 0)
1405
+ }
1406
+
1276
1407
  if (cmd === 'pipeline') {
1277
1408
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
1278
1409
  try {
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Compose pre-push hook — runs `compose validate` and blocks the push on
3
+ # any error-severity drift finding. Installed by `compose hooks install --pre-push`;
4
+ # placeholders below are substituted at install time.
5
+
6
+ set -u
7
+ COMPOSE_NODE="__COMPOSE_NODE__"
8
+ COMPOSE_BIN="__COMPOSE_BIN__"
9
+ LOG="${COMPOSE_HOOK_LOG:-.compose/data/pre-push.log}"
10
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
11
+
12
+ OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error 2>&1)
13
+ EXIT_CODE=$?
14
+
15
+ if [ "$EXIT_CODE" -ne 0 ]; then
16
+ echo "$OUTPUT" | tee -a "$LOG" >&2
17
+ echo "" >&2
18
+ echo "compose validate found error-severity drift. Push aborted." >&2
19
+ echo "Run \`compose validate\` to see findings, then fix and retry." >&2
20
+ echo "Bypass at your own risk: git push --no-verify" >&2
21
+ exit "$EXIT_CODE"
22
+ fi
23
+
24
+ # Below-threshold findings (warnings/info) are still printed for visibility.
25
+ echo "$OUTPUT"
26
+ exit 0
@@ -0,0 +1,115 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://compose.smartmemory.dev/contracts/feature-json.schema.json",
4
+ "title": "feature.json",
5
+ "description": "Per-feature state record at docs/features/<CODE>/feature.json. Codifies the de facto union of fields produced today by typed writers and runtime code. Intentionally permissive (additionalProperties: true) until COMP-MCP-VALIDATE-SCHEMA-TIGHTEN follow-up.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "required": ["code"],
9
+ "properties": {
10
+ "code": {
11
+ "type": "string",
12
+ "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$",
13
+ "description": "Strict feature code. Matches FEATURE_CODE_RE_STRICT."
14
+ },
15
+ "description": { "type": "string" },
16
+ "name": {
17
+ "type": "string",
18
+ "description": "Legacy alternative to description (de facto). Prefer description on new files."
19
+ },
20
+ "status": {
21
+ "type": "string",
22
+ "enum": ["PLANNED", "IN_PROGRESS", "PARTIAL", "COMPLETE", "SUPERSEDED", "PARKED", "BLOCKED", "KILLED"]
23
+ },
24
+ "phase": { "type": ["string", "null"] },
25
+ "complexity": {
26
+ "oneOf": [
27
+ { "type": "string", "enum": ["S", "M", "L", "XL"] },
28
+ { "type": "number" }
29
+ ],
30
+ "description": "S/M/L/XL on new files; numeric on legacy files (e.g. COMP-DEBUG-1). Convergence deferred to COMP-MCP-VALIDATE-SCHEMA-TIGHTEN."
31
+ },
32
+ "position": { "type": ["integer", "string"] },
33
+ "parent": { "type": "string" },
34
+ "depends_on": {
35
+ "type": "array",
36
+ "items": { "type": "string" },
37
+ "description": "De facto field on legacy files. Cross-feature dependency codes."
38
+ },
39
+ "source": {
40
+ "type": "string",
41
+ "description": "De facto field on legacy files."
42
+ },
43
+ "profile": {
44
+ "oneOf": [
45
+ { "type": "string" },
46
+ { "type": "object", "additionalProperties": true }
47
+ ],
48
+ "description": "Either a string identifier or an inline profile object (de facto on COMP-DEBUG-1 etc.)."
49
+ },
50
+ "created": { "type": "string", "format": "date" },
51
+ "updated": { "type": "string", "format": "date" },
52
+ "commit_sha": { "type": "string" },
53
+ "tags": { "type": "array", "items": { "type": "string" } },
54
+ "artifacts": {
55
+ "type": "array",
56
+ "description": "Linked non-canonical artifacts (journal/snapshot pointers, external docs). Canonical artifacts (design.md, plan.md, etc.) under the feature folder are auto-discovered and explicitly NOT registered here.",
57
+ "items": {
58
+ "type": "object",
59
+ "required": ["type", "path"],
60
+ "properties": {
61
+ "type": { "type": "string", "enum": ["design", "prd", "architecture", "blueprint", "plan", "report", "journal", "snapshot"] },
62
+ "path": { "type": "string" },
63
+ "status": { "type": "string" }
64
+ }
65
+ }
66
+ },
67
+ "links": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "object",
71
+ "required": ["kind", "to_code"],
72
+ "properties": {
73
+ "kind": { "type": "string", "enum": ["surfaced_by", "blocks", "depends_on", "follow_up", "supersedes", "related"] },
74
+ "to_code": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$" },
75
+ "note": { "type": "string" }
76
+ }
77
+ }
78
+ },
79
+ "completions": {
80
+ "type": "array",
81
+ "items": {
82
+ "type": "object",
83
+ "required": ["completion_id", "feature_code", "commit_sha"],
84
+ "properties": {
85
+ "completion_id": { "type": "string" },
86
+ "feature_code": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$" },
87
+ "commit_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
88
+ "commit_sha_short": { "type": "string" },
89
+ "tests_pass": { "type": "boolean" },
90
+ "files_changed": { "type": "array", "items": { "type": "string" } },
91
+ "recorded_at": { "type": "string" },
92
+ "recorded_by": { "type": "string" },
93
+ "notes": { "type": "string" }
94
+ }
95
+ }
96
+ },
97
+ "lifecycle": {
98
+ "type": "object",
99
+ "additionalProperties": true,
100
+ "properties": {
101
+ "currentPhase": { "type": "string" },
102
+ "phaseHistory": { "type": "array" }
103
+ }
104
+ },
105
+ "phaseHistory": {
106
+ "type": "array",
107
+ "description": "Empty array on every existing file today. Validator does not flag empty."
108
+ },
109
+ "filesChanged": {
110
+ "type": "array",
111
+ "items": { "type": "string" },
112
+ "description": "Written by runBuild() on completion. De facto field; not tightened in v1."
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://compose.smartmemory.dev/contracts/roadmap-row.schema.json",
4
+ "title": "Roadmap Row",
5
+ "description": "Single parsed ROADMAP.md row, mirroring lib/roadmap-parser.js FeatureEntry typedef. Validator filters out _anon_* sentinel codes (parser emits these for unrecognized rows) before applying this schema.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["code", "description", "status", "phaseId", "position"],
9
+ "properties": {
10
+ "code": {
11
+ "type": "string",
12
+ "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$",
13
+ "description": "Strict feature code. Anonymous _anon_<n> sentinel codes from the parser are filtered before this schema runs."
14
+ },
15
+ "description": { "type": "string" },
16
+ "status": {
17
+ "type": "string",
18
+ "enum": ["PLANNED", "IN_PROGRESS", "PARTIAL", "COMPLETE", "SUPERSEDED", "PARKED", "BLOCKED", "KILLED"]
19
+ },
20
+ "phaseId": { "type": "string" },
21
+ "position": { "type": "integer", "minimum": 1 }
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://compose.smartmemory.dev/contracts/vision-state.schema.json",
4
+ "title": "vision-state.json",
5
+ "description": "Per-project vision-state document at .compose/data/vision-state.json. Codifies the shape produced by VisionStore (server/vision-store.js).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["items", "connections", "gates"],
9
+ "properties": {
10
+ "items": { "type": "array", "items": { "$ref": "#/definitions/Item" } },
11
+ "connections": { "type": "array" },
12
+ "gates": { "type": "array" },
13
+ "version": { "type": ["string", "number"] }
14
+ },
15
+ "definitions": {
16
+ "Item": {
17
+ "type": "object",
18
+ "additionalProperties": true,
19
+ "required": ["id", "type"],
20
+ "properties": {
21
+ "id": { "type": "string", "format": "uuid" },
22
+ "type": { "type": "string" },
23
+ "title": { "type": "string" },
24
+ "description": { "type": "string" },
25
+ "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 4 },
26
+ "status": { "type": "string", "enum": ["planned", "in_progress", "complete", "blocked", "parked", "killed", "superseded"] },
27
+ "phase": { "type": ["string", "null"], "enum": ["vision", "requirements", "specification", "design", "planning", "implementation", "verification", "release", null] },
28
+ "parentId": { "type": ["string", "null"] },
29
+ "files": { "type": "array", "items": { "type": "string" } },
30
+ "priority": { "type": ["string", "number", "null"] },
31
+ "assignedTo": { "type": ["string", "null"] },
32
+ "governance": { "type": ["object", "null"] },
33
+ "featureCode": {
34
+ "type": ["string", "null"],
35
+ "description": "Top-level feature code. Current baseline for 41/42 items. lifecycle.featureCode is preferred where present; both are legal. Migration tracked as COMP-VISION-STATE-LIFECYCLE-MIGRATE."
36
+ },
37
+ "group": { "type": ["string", "null"] },
38
+ "slug": { "type": ["string", "null"] },
39
+ "position": {
40
+ "type": ["object", "null"],
41
+ "additionalProperties": true,
42
+ "properties": {
43
+ "x": { "type": "number" },
44
+ "y": { "type": "number" }
45
+ }
46
+ },
47
+ "createdAt": { "type": "string" },
48
+ "updatedAt": { "type": "string" },
49
+ "summary": { "type": ["string", "null"] },
50
+ "stratumFlowId": { "type": ["string", "null"] },
51
+ "evidence": { "type": ["object", "array", "null"] },
52
+ "lifecycle": {
53
+ "type": ["object", "null"],
54
+ "additionalProperties": true,
55
+ "properties": {
56
+ "featureCode": { "type": ["string", "null"] },
57
+ "currentPhase": { "type": ["string", "null"] },
58
+ "lifecycle_ext": { "type": "object", "additionalProperties": true }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
@@ -27,12 +27,11 @@ import { join, dirname, posix } from 'path';
27
27
  import { readFeature, updateFeature, listFeatures } from './feature-json.js';
28
28
  import { appendEvent, normalizeSince } from './feature-events.js';
29
29
  import { checkOrInsert } from './idempotency.js';
30
+ import { FEATURE_CODE_RE_STRICT as FEATURE_CODE_RE } from './feature-code.js';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Constants + regexes
33
34
  // ---------------------------------------------------------------------------
34
-
35
- const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
36
35
  const SHA_RE = /^[0-9a-f]{40}$/i; // FULL SHA only (Decision 9). Case-insensitive input; normalize to lowercase.
37
36
  const SHORT_LEN = 8; // Display only — never the dedup key.
38
37
  const DEFAULT_LIMIT = 50;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Strict feature-code regex used by every typed writer to validate input.
3
+ * Contract: starts with uppercase letter, contains uppercase/digits/hyphens,
4
+ * ends in uppercase or digit (no trailing hyphen, no leading hyphen).
5
+ *
6
+ * Three writer sites import from here: feature-writer, completion-writer,
7
+ * journal-writer. The roadmap parser deliberately uses a looser regex
8
+ * (`/^[A-Z][\w-]*-\d+/`) to match anonymous/legacy table rows and is exempt
9
+ * from this extraction.
10
+ *
11
+ * Introduced by COMP-MCP-VALIDATE.
12
+ */
13
+
14
+ export const FEATURE_CODE_RE_STRICT = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
15
+
16
+ /**
17
+ * Throws an Error with `code: 'INVALID_INPUT'` if `code` is not a strict
18
+ * feature code. Otherwise returns silently.
19
+ *
20
+ * @param {unknown} code
21
+ * @throws {Error & { code: 'INVALID_INPUT' }}
22
+ */
23
+ export function validateCode(code) {
24
+ if (typeof code !== 'string' || !FEATURE_CODE_RE_STRICT.test(code)) {
25
+ const err = new Error(`Invalid feature code: ${JSON.stringify(code)}`);
26
+ err.code = 'INVALID_INPUT';
27
+ throw err;
28
+ }
29
+ }