@smartmemory/compose 0.1.5-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
@@ -994,6 +994,416 @@ if (cmd === 'roadmap') {
994
994
  process.exit(0)
995
995
  }
996
996
 
997
+ if (cmd === 'record-completion') {
998
+ // compose record-completion <feature_code> --commit-sha=<full-40-hex> [options]
999
+ //
1000
+ // Flags:
1001
+ // --commit-sha=<sha> required; full 40-char hex SHA (Decision 9)
1002
+ // --tests-pass=<bool> default true
1003
+ // --notes=<string> optional
1004
+ // --files-changed-from-stdin read newline-separated paths from stdin
1005
+ // --no-status set_status: false (don't flip status to COMPLETE)
1006
+ // --force force-replace an existing same-(code,sha) record
1007
+ // --idempotency-key=<key> caller-supplied idempotency key
1008
+ //
1009
+ // Positional: <feature_code> is the first non-flag argument.
1010
+
1011
+ // Tiny flag parser: handles --key=value, --key value, --no-key
1012
+ function parseFlags(rawArgs) {
1013
+ const flags = {}
1014
+ const positionals = []
1015
+ let i = 0
1016
+ while (i < rawArgs.length) {
1017
+ const a = rawArgs[i]
1018
+ if (a.startsWith('--')) {
1019
+ const stripped = a.slice(2)
1020
+ if (stripped.startsWith('no-')) {
1021
+ flags[stripped.slice(3)] = false
1022
+ i++
1023
+ } else if (stripped.includes('=')) {
1024
+ const eq = stripped.indexOf('=')
1025
+ flags[stripped.slice(0, eq)] = stripped.slice(eq + 1)
1026
+ i++
1027
+ } else if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('--')) {
1028
+ flags[stripped] = rawArgs[i + 1]
1029
+ i += 2
1030
+ } else {
1031
+ flags[stripped] = true
1032
+ i++
1033
+ }
1034
+ } else {
1035
+ positionals.push(a)
1036
+ i++
1037
+ }
1038
+ }
1039
+ return { flags, positionals }
1040
+ }
1041
+
1042
+ if (args[0] === '--help' || args[0] === '-h') {
1043
+ console.log('Usage: compose record-completion <feature_code> --commit-sha=<full-40-hex> [options]')
1044
+ console.log('')
1045
+ console.log('Options:')
1046
+ console.log(' --commit-sha=<sha> Full 40-char hex SHA (required)')
1047
+ console.log(' --tests-pass=<bool> Whether tests passed (default: true)')
1048
+ console.log(' --notes=<string> Optional provenance notes')
1049
+ console.log(' --files-changed-from-stdin Read newline-separated repo-relative paths from stdin')
1050
+ console.log(' --no-status Do not flip feature status to COMPLETE')
1051
+ console.log(' --force Replace existing record with same (feature_code, commit_sha)')
1052
+ console.log(' --idempotency-key=<key> Caller-supplied idempotency key')
1053
+ process.exit(0)
1054
+ }
1055
+
1056
+ const { flags, positionals } = parseFlags(args)
1057
+ const featureCode = positionals[0]
1058
+ if (!featureCode) {
1059
+ console.error('Error: feature_code is required as the first positional argument')
1060
+ console.error('Usage: compose record-completion <feature_code> --commit-sha=<sha>')
1061
+ process.exit(1)
1062
+ }
1063
+
1064
+ const commitSha = flags['commit-sha']
1065
+ if (!commitSha) {
1066
+ console.error('Error: --commit-sha is required (full 40-char hex SHA)')
1067
+ process.exit(1)
1068
+ }
1069
+
1070
+ // Parse tests-pass: default true
1071
+ let testsPass = true
1072
+ if (flags['tests-pass'] !== undefined) {
1073
+ const tp = flags['tests-pass']
1074
+ if (tp === 'false' || tp === false) testsPass = false
1075
+ else if (tp === 'true' || tp === true) testsPass = true
1076
+ else {
1077
+ console.error(`Error: --tests-pass must be true or false, got "${tp}"`)
1078
+ process.exit(1)
1079
+ }
1080
+ }
1081
+
1082
+ // Read files_changed from stdin if flag set
1083
+ let filesChanged = []
1084
+ if (flags['files-changed-from-stdin'] === true) {
1085
+ const { createReadStream } = await import('fs')
1086
+ const { createInterface } = await import('readline')
1087
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity })
1088
+ for await (const line of rl) {
1089
+ const trimmed = line.trim()
1090
+ if (trimmed) filesChanged.push(trimmed)
1091
+ }
1092
+ }
1093
+
1094
+ const completionArgs = {
1095
+ feature_code: featureCode,
1096
+ commit_sha: commitSha,
1097
+ tests_pass: testsPass,
1098
+ files_changed: filesChanged,
1099
+ }
1100
+ if (flags['notes']) completionArgs.notes = flags['notes']
1101
+ if (flags['status'] === false) completionArgs.set_status = false // --no-status
1102
+ if (flags['force'] === true) completionArgs.force = true
1103
+ if (flags['idempotency-key']) completionArgs.idempotency_key = flags['idempotency-key']
1104
+
1105
+ const cwd = findProjectRoot(process.cwd())
1106
+ const { recordCompletion } = await import('../lib/completion-writer.js')
1107
+ try {
1108
+ const result = await recordCompletion(cwd, completionArgs)
1109
+ console.log(JSON.stringify({
1110
+ completion_id: result.completion_id,
1111
+ idempotent: result.idempotent,
1112
+ status_changed: result.status_changed,
1113
+ }, null, 2))
1114
+ process.exit(0)
1115
+ } catch (err) {
1116
+ let msg = err && err.code ? `[${err.code}]: ${err.message}` : err.message
1117
+ if (err && err.cause && typeof err.cause.message === 'string') {
1118
+ msg += err.cause.code
1119
+ ? `\n Caused by [${err.cause.code}]: ${err.cause.message}`
1120
+ : `\n Caused by: ${err.cause.message}`
1121
+ }
1122
+ console.error(msg)
1123
+ process.exit(1)
1124
+ }
1125
+ }
1126
+
1127
+ if (cmd === 'hooks') {
1128
+ // compose hooks {install,uninstall,status}
1129
+ const sub = args[0]
1130
+
1131
+ // Tiny flag parser reused here
1132
+ function parseHookFlags(rawArgs) {
1133
+ const flags = {}
1134
+ let i = 0
1135
+ while (i < rawArgs.length) {
1136
+ const a = rawArgs[i]
1137
+ if (a.startsWith('--')) {
1138
+ const stripped = a.slice(2)
1139
+ if (stripped.includes('=')) {
1140
+ const eq = stripped.indexOf('=')
1141
+ flags[stripped.slice(0, eq)] = stripped.slice(eq + 1)
1142
+ i++
1143
+ } else {
1144
+ flags[stripped] = true
1145
+ i++
1146
+ }
1147
+ }
1148
+ i = i < rawArgs.length && !rawArgs[i].startsWith('--') ? i + 1 : i
1149
+ }
1150
+ return flags
1151
+ }
1152
+
1153
+ // Fix: simpler flag parsing
1154
+ const hookFlags = {}
1155
+ for (let i = 1; i < args.length; i++) {
1156
+ const a = args[i]
1157
+ if (a === '--force') hookFlags.force = true
1158
+ else if (a.startsWith('--')) {
1159
+ const stripped = a.slice(2)
1160
+ if (stripped.includes('=')) {
1161
+ const eq = stripped.indexOf('=')
1162
+ hookFlags[stripped.slice(0, eq)] = stripped.slice(eq + 1)
1163
+ } else {
1164
+ hookFlags[stripped] = true
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ const { readFileSync: rfSync, writeFileSync: wfSync, existsSync: exSync, chmodSync } = await import('fs')
1170
+ const { join: pjoin, resolve: presolve } = await import('path')
1171
+ const { fileURLToPath: futp } = await import('url')
1172
+
1173
+ const projectRoot = findProjectRoot(process.cwd())
1174
+ const gitDir = pjoin(projectRoot, '.git')
1175
+ if (!exSync(gitDir)) {
1176
+ console.error('Error: not a git repository (no .git directory found)')
1177
+ process.exit(1)
1178
+ }
1179
+
1180
+ const hooksDir = pjoin(gitDir, 'hooks')
1181
+ const { mkdirSync: mSync } = await import('fs')
1182
+ mSync(hooksDir, { recursive: true })
1183
+
1184
+ // Resolve absolute paths for substitution
1185
+ const composeBin = presolve(presolve(futp(import.meta.url), '..'), 'compose.js')
1186
+ const composeNode = process.execPath
1187
+
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]
1211
+ let template
1212
+ try {
1213
+ template = rfSync(tplPath, 'utf-8')
1214
+ } catch (err) {
1215
+ console.error(`Error: could not read ${type} template: ${err.message}`)
1216
+ return 1
1217
+ }
1218
+ if (exSync(dest)) {
1219
+ const existing = rfSync(dest, 'utf-8')
1220
+ const isOurs = existing.includes(marker)
1221
+ if (!isOurs && !hookFlags.force) {
1222
+ console.error(`Error: a foreign ${type} hook already exists at ${dest}`)
1223
+ console.error('')
1224
+ console.error(`Run \`compose hooks install --${type} --force\` to overwrite.`)
1225
+ return 1
1226
+ }
1227
+ }
1228
+ const substituted = template
1229
+ .replace(/__COMPOSE_NODE__/g, composeNode)
1230
+ .replace(/__COMPOSE_BIN__/g, composeBin)
1231
+ wfSync(dest, substituted)
1232
+ chmodSync(dest, 0o755)
1233
+ console.log(`Installed ${type} hook at ${dest}`)
1234
+ console.log(` COMPOSE_NODE=${composeNode}`)
1235
+ console.log(` COMPOSE_BIN=${composeBin}`)
1236
+ return 0
1237
+ }
1238
+
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
1244
+ }
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
1249
+ }
1250
+ const { rmSync: rmS } = require('fs')
1251
+ rmS(dest)
1252
+ console.log(`Removed ${type} hook at ${dest}`)
1253
+ return 0
1254
+ }
1255
+
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
1261
+ }
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
1266
+ }
1267
+ const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1268
+ const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1269
+ if (nodeMatch && binMatch) {
1270
+ console.log(`${type}: installed (current)`)
1271
+ } else {
1272
+ console.log(`${type}: installed (stale paths — re-run install)`)
1273
+ if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1274
+ if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
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)
1306
+ process.exit(0)
1307
+ }
1308
+
1309
+ console.error(`Unknown hooks subcommand: "${sub}". Use: install | uninstall | status`)
1310
+ process.exit(1)
1311
+ }
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
+
997
1407
  if (cmd === 'pipeline') {
998
1408
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
999
1409
  try {
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # Compose post-commit hook — auto-records completions from `Records-completion:` trailers.
3
+ # Installed by `compose hooks install`; placeholders below are substituted at install time.
4
+ # Failures are logged and non-blocking.
5
+
6
+ set -u
7
+ COMPOSE_NODE="__COMPOSE_NODE__"
8
+ COMPOSE_BIN="__COMPOSE_BIN__"
9
+ LOG="${COMPOSE_HOOK_LOG:-.compose/data/post-commit.log}"
10
+ mkdir -p "$(dirname "$LOG")"
11
+
12
+ trailers=$(git log -1 --pretty=%B | git interpret-trailers --parse 2>/dev/null || true)
13
+ if [[ -z "$trailers" ]]; then exit 0; fi
14
+
15
+ sha=$(git rev-parse HEAD)
16
+ files=$(git diff-tree --no-commit-id --name-only -r HEAD)
17
+ subject=$(git log -1 --pretty=%s)
18
+
19
+ echo "$trailers" | while IFS= read -r line; do
20
+ header=$(echo "$line" | awk -F': ' 'NR==1{print tolower($1)}')
21
+ if [[ "$header" != "records-completion" ]]; then continue; fi
22
+ value=$(echo "$line" | awk -F': ' '{ for (i=2; i<=NF; i++) printf "%s%s", $i, (i==NF?"":": ") }')
23
+ code=$(echo "$value" | awk '{print $1}')
24
+ rest=$(echo "$value" | awk '{$1=""; sub(/^ /, ""); print}')
25
+
26
+ tp="true"
27
+ notes="$subject"
28
+
29
+ # Extract notes="..." first (greedy quoted-string), then strip it from rest
30
+ # so its content can't pollute key=value token scanning.
31
+ if [[ "$rest" =~ notes=\"([^\"]*)\" ]]; then
32
+ notes="${BASH_REMATCH[1]}"
33
+ rest="${rest/notes=\"${BASH_REMATCH[1]}\"/}"
34
+ fi
35
+
36
+ # Tokenize remaining rest on whitespace; parse known key=value pairs.
37
+ for token in $rest; do
38
+ key="${token%%=*}"
39
+ val="${token#*=}"
40
+ case "$key" in
41
+ tests_pass)
42
+ if [[ "$val" == "true" ]]; then tp="true"; fi
43
+ if [[ "$val" == "false" ]]; then tp="false"; fi
44
+ ;;
45
+ "")
46
+ # empty token after stripping notes="...", skip silently
47
+ ;;
48
+ *)
49
+ echo "[$(date -Iseconds)] hook: unknown qualifier \"$token\" for $code" | tee -a "$LOG" >&2
50
+ ;;
51
+ esac
52
+ done
53
+
54
+ if ! echo "$files" | "$COMPOSE_NODE" "$COMPOSE_BIN" record-completion "$code" \
55
+ --commit-sha="$sha" --tests-pass="$tp" --notes="$notes" \
56
+ --files-changed-from-stdin >> "$LOG" 2>&1; then
57
+ echo "[$(date -Iseconds)] hook: record_completion failed for $code (sha=$sha) — see above" >> "$LOG"
58
+ fi
59
+ done
60
+
61
+ exit 0
@@ -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
+ }