@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 +279 -0
- package/bin/git-hooks/post-commit.template +61 -0
- package/lib/build.js +31 -3
- package/lib/changelog-writer.js +647 -0
- package/lib/completion-writer.js +465 -0
- package/lib/feature-events.js +114 -0
- package/lib/feature-writer.js +585 -0
- package/lib/idempotency.js +138 -0
- package/lib/journal-writer.js +928 -0
- package/lib/roadmap-parser.js +3 -1
- package/lib/sections.js +188 -0
- package/package.json +5 -1
- package/server/compose-mcp-tools.js +82 -0
- package/server/compose-mcp.js +273 -1
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
|
-
|
|
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 = {
|