@smartmemory/compose 0.1.5-beta → 0.1.6-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,285 @@ 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
+ const hookPath = pjoin(hooksDir, 'post-commit')
1184
+
1185
+ // Marker line used to identify our hooks
1186
+ const HOOK_MARKER = '# Compose post-commit hook —'
1187
+
1188
+ // Resolve absolute paths for substitution
1189
+ const composeBin = presolve(presolve(futp(import.meta.url), '..'), 'compose.js')
1190
+ const composeNode = process.execPath
1191
+
1192
+ if (sub === 'install') {
1193
+ // Read template
1194
+ const templatePath = pjoin(presolve(futp(import.meta.url), '..'), 'git-hooks', 'post-commit.template')
1195
+ let template
1196
+ try {
1197
+ template = rfSync(templatePath, 'utf-8')
1198
+ } catch (err) {
1199
+ console.error(`Error: could not read hook template: ${err.message}`)
1200
+ process.exit(1)
1201
+ }
1202
+
1203
+ // Check existing hook
1204
+ if (exSync(hookPath)) {
1205
+ const existing = rfSync(hookPath, 'utf-8')
1206
+ const isOurs = existing.includes(HOOK_MARKER)
1207
+ 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:')
1211
+ 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)
1216
+ }
1217
+ }
1218
+
1219
+ // Substitute placeholders
1220
+ const substituted = template
1221
+ .replace(/__COMPOSE_NODE__/g, composeNode)
1222
+ .replace(/__COMPOSE_BIN__/g, composeBin)
1223
+
1224
+ wfSync(hookPath, substituted)
1225
+ chmodSync(hookPath, 0o755)
1226
+ console.log(`Installed post-commit hook at ${hookPath}`)
1227
+ console.log(` COMPOSE_NODE=${composeNode}`)
1228
+ console.log(` COMPOSE_BIN=${composeBin}`)
1229
+ process.exit(0)
1230
+ }
1231
+
1232
+ if (sub === 'uninstall') {
1233
+ if (!exSync(hookPath)) {
1234
+ console.log('No post-commit hook installed.')
1235
+ process.exit(0)
1236
+ }
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)
1241
+ }
1242
+ const { rmSync: rmS } = await import('fs')
1243
+ rmS(hookPath)
1244
+ console.log(`Removed post-commit hook at ${hookPath}`)
1245
+ process.exit(0)
1246
+ }
1247
+
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)
1253
+ }
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)
1258
+ }
1259
+ // Check if paths match current
1260
+ const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1261
+ const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1262
+ if (nodeMatch && binMatch) {
1263
+ console.log('installed (current)')
1264
+ } else {
1265
+ console.log('installed (stale paths — re-run install)')
1266
+ if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1267
+ if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1268
+ }
1269
+ process.exit(0)
1270
+ }
1271
+
1272
+ console.error(`Unknown hooks subcommand: "${sub}". Use: install | uninstall | status`)
1273
+ process.exit(1)
1274
+ }
1275
+
997
1276
  if (cmd === 'pipeline') {
998
1277
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
999
1278
  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