@smartmemory/compose 0.1.4-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
package/lib/build.js CHANGED
@@ -27,7 +27,8 @@ import { CliProgress } from './cli-progress.js';
27
27
  import { BuildStreamWriter } from './build-stream-writer.js';
28
28
  import { resolveAgentConfig } from './agent-string.js';
29
29
  import { installFactoryShim } from './connector-factory-shim.js';
30
- import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers } from './sections.js';
30
+ import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
31
+ import { SECTIONS_DIR } from './constants.js';
31
32
 
32
33
  import YAML from 'yaml';
33
34
  import { updateFeature, readFeature, writeFeature } from './feature-json.js';
@@ -937,6 +938,7 @@ export async function runBuild(featureCode, opts = {}) {
937
938
  // COMP-PLAN-SECTIONS T7: append "What Was Built" trailers to all
938
939
  // section files after a successful ship. No-op if sections/ doesn't
939
940
  // exist. Wrapped so trailer-append failure never fails the ship.
941
+ let postShipAnalysis = null;
940
942
  try {
941
943
  if (shipResult.commit) {
942
944
  const trailerResult = appendSectionTrailers({
@@ -945,18 +947,44 @@ export async function runBuild(featureCode, opts = {}) {
945
947
  filesChanged: shipResult.filesChanged ?? [],
946
948
  cwd: agentCwd,
947
949
  });
950
+ // COMP-PLAN-SECTIONS-REPORT T4: read-only analyzer feeds the
951
+ // trailer event with `unattributed` and primes writeRollup.
952
+ const sectionsDir = join(featureDir, SECTIONS_DIR);
953
+ postShipAnalysis = analyzeRollup({
954
+ sectionsDir,
955
+ filesChanged: shipResult.filesChanged ?? [],
956
+ });
948
957
  if (trailerResult.trailed?.length > 0) {
949
- streamWriter.write({
958
+ const payload = {
950
959
  type: 'build_sections_trailed',
951
960
  featureCode,
952
961
  count: trailerResult.trailed.length,
953
962
  sections: trailerResult.trailed,
954
- });
963
+ };
964
+ if (postShipAnalysis && Array.isArray(postShipAnalysis.unattributed)) {
965
+ payload.unattributed = postShipAnalysis.unattributed;
966
+ }
967
+ streamWriter.write(payload);
955
968
  }
956
969
  }
957
970
  } catch (err) {
958
971
  try { streamWriter.write({ type: 'build_error', message: `sections trailer append failed: ${err.message}`, stepId: 'ship' }); } catch { /* ignore */ }
959
972
  }
973
+ // COMP-PLAN-SECTIONS-REPORT T4: roll-up write isolated in its own
974
+ // try/catch — failure must not suppress the trailer-success event.
975
+ try {
976
+ if (shipResult.commit && postShipAnalysis) {
977
+ const today = new Date().toISOString().slice(0, 10);
978
+ writeRollup({
979
+ featureDir,
980
+ analysis: postShipAnalysis,
981
+ commit: shipResult.commit,
982
+ date: today,
983
+ });
984
+ }
985
+ } catch (err) {
986
+ try { streamWriter.write({ type: 'build_error', message: `sections rollup write failed: ${err.message}`, stepId: 'ship' }); } catch { /* ignore */ }
987
+ }
960
988
  // COMP-HEALTH: collect plan_completion signal from ship result (if present)
961
989
  if (shipResult.planCompletionPct != null || shipResult.plan_completion_pct != null) {
962
990
  buildSignals.plan_completion = {