@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 +410 -0
- package/bin/git-hooks/post-commit.template +61 -0
- 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/changelog-writer.js +647 -0
- package/lib/completion-writer.js +464 -0
- package/lib/feature-code.js +29 -0
- package/lib/feature-validator.js +629 -0
- package/lib/feature-writer.js +325 -5
- package/lib/journal-writer.js +928 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +80 -0
- package/server/compose-mcp.js +244 -1
- package/server/schema-validator.js +50 -9
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
|
+
}
|