@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 +186 -55
- package/bin/git-hooks/pre-push.template +26 -0
- package/contracts/feature-json.schema.json +115 -0
- package/contracts/roadmap-row.schema.json +23 -0
- package/contracts/vision-state.schema.json +64 -0
- package/lib/completion-writer.js +1 -2
- package/lib/feature-code.js +29 -0
- package/lib/feature-validator.js +629 -0
- package/lib/feature-writer.js +1 -1
- package/lib/journal-writer.js +1 -1
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +18 -0
- package/server/compose-mcp.js +28 -0
- package/server/schema-validator.js +50 -9
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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(
|
|
1213
|
+
template = rfSync(tplPath, 'utf-8')
|
|
1198
1214
|
} catch (err) {
|
|
1199
|
-
console.error(`Error: could not read
|
|
1200
|
-
|
|
1215
|
+
console.error(`Error: could not read ${type} template: ${err.message}`)
|
|
1216
|
+
return 1
|
|
1201
1217
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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(
|
|
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(`
|
|
1213
|
-
|
|
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
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1236
|
+
return 0
|
|
1230
1237
|
}
|
|
1231
1238
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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(
|
|
1238
|
-
if (!content.includes(
|
|
1239
|
-
console.warn(
|
|
1240
|
-
|
|
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 } =
|
|
1243
|
-
rmS(
|
|
1244
|
-
console.log(`Removed
|
|
1245
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
if (!exSync(
|
|
1251
|
-
console.log(
|
|
1252
|
-
|
|
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(
|
|
1255
|
-
if (!content.includes(
|
|
1256
|
-
console.log(
|
|
1257
|
-
|
|
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
|
-
|
|
1260
|
-
const
|
|
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(
|
|
1270
|
+
console.log(`${type}: installed (current)`)
|
|
1264
1271
|
} else {
|
|
1265
|
-
console.log(
|
|
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
|
+
}
|
package/lib/completion-writer.js
CHANGED
|
@@ -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
|
+
}
|