@dev-loops/core 0.8.0 → 1.0.0-rc.1
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/package.json +8 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +43 -4
- package/src/config/config.mjs +454 -5
- package/src/config/extension-defaults.yaml +7 -1
- package/src/debt/shape.mjs +0 -12
- package/src/loop/copilot-loop-state.mjs +38 -6
- package/src/loop/gate-carry-forward.mjs +244 -0
- package/src/loop/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +372 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +289 -0
- package/src/loop/ui-review-teardown.mjs +292 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teardown + side-effect ledger orchestrator for the ui_review route (Stage 5).
|
|
3
|
+
*
|
|
4
|
+
* Terminal cleanup for a running-app review: stop the app booted in Stage 1,
|
|
5
|
+
* drop the dev-DB rows the Stage-2 drive tagged, and remove the provisioned
|
|
6
|
+
* worktree. The core safety property of this stage is that a side-effect ledger
|
|
7
|
+
* is ALWAYS emitted — enumerating every migration applied, row created/dropped,
|
|
8
|
+
* the worktree path, and any process left running — so nothing the loop touched
|
|
9
|
+
* is ever silently orphaned, whether teardown succeeds, is skipped, or partially
|
|
10
|
+
* fails.
|
|
11
|
+
*
|
|
12
|
+
* Two safety rails:
|
|
13
|
+
* - Destructive steps (row drops, worktree removal) run ONLY on explicit
|
|
14
|
+
* confirmation. Without it the destructive steps are skipped and the ledger
|
|
15
|
+
* records what remains. Stopping the app is a clean shutdown of a process
|
|
16
|
+
* the loop itself started, not a destructive mutation of persisted state, so
|
|
17
|
+
* it runs regardless of confirmation.
|
|
18
|
+
* - A failed kill/drop/removal is REPORTED in the ledger and the result's
|
|
19
|
+
* errors list, never swallowed.
|
|
20
|
+
*
|
|
21
|
+
* This module is PURE orchestration: the process kill, the row drop, and the
|
|
22
|
+
* worktree removal are injected seams so it is fully testable without real side
|
|
23
|
+
* effects. The thin CLI wires the real ones.
|
|
24
|
+
*
|
|
25
|
+
* Non-goals (explicit): NO rollback of the branch's dev-DB migrations by default
|
|
26
|
+
* (they were applied to a dev DB; reversal is a separate explicit action — the
|
|
27
|
+
* ledger records they were applied, not reverted). NO production teardown.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Row-drop model: Stage 2 stamps each mutating step with a drive-session id and
|
|
32
|
+
* emits a session-tagged row manifest. Given that manifest (and confirmation),
|
|
33
|
+
* this stage drops exactly the rows tagged with that session. Only the fallback
|
|
34
|
+
* case — a drive that mutated but handed in no manifest — CANNOT know which rows
|
|
35
|
+
* to drop and MUST NOT guess: the ledger reports rows "may remain (untagged)"
|
|
36
|
+
* rather than dropping anything.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const ROW_STATUS = Object.freeze({
|
|
40
|
+
DROPPED: "dropped",
|
|
41
|
+
DROP_FAILED: "drop-failed",
|
|
42
|
+
MAY_REMAIN_UNTAGGED: "may-remain-untagged",
|
|
43
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
44
|
+
NONE: "none",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const WORKTREE_STATUS = Object.freeze({
|
|
48
|
+
REMOVED: "removed",
|
|
49
|
+
REMOVE_FAILED: "remove-failed",
|
|
50
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
51
|
+
// No worktree path in the provision result at all. Distinct from
|
|
52
|
+
// SKIPPED_UNCONFIRMED (which is the confirmation gate) so the ledger says WHY
|
|
53
|
+
// removal did not run — a missing path, not a withheld confirmation.
|
|
54
|
+
MISSING_PATH: "missing-path",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const PROCESS_STATUS = Object.freeze({
|
|
58
|
+
STOPPED: "stopped",
|
|
59
|
+
KILL_FAILED: "kill-failed",
|
|
60
|
+
MAY_BE_RUNNING: "may-be-running",
|
|
61
|
+
SKIPPED: "skipped",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const GIST_STATUS = Object.freeze({
|
|
65
|
+
DELETED: "deleted",
|
|
66
|
+
DELETE_FAILED: "delete-failed",
|
|
67
|
+
SKIPPED_UNCONFIRMED: "skipped-unconfirmed",
|
|
68
|
+
NONE: "none",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Did the Stage-2 drive potentially create dev-DB rows? Without row tagging this
|
|
73
|
+
* is a coarse but honest signal: a drive that actually walked steps exercised
|
|
74
|
+
* create/edit/upload interactions, so rows may have been created. A drive that
|
|
75
|
+
* stopped before driving anything (e.g. auth failure) created nothing.
|
|
76
|
+
*/
|
|
77
|
+
function driveMayHaveCreatedRows(driveResult) {
|
|
78
|
+
if (!driveResult || driveResult.stopped) return false;
|
|
79
|
+
return Array.isArray(driveResult.steps) && driveResult.steps.length > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the teardown sequence and always return a result carrying the side-effect
|
|
84
|
+
* ledger.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} input
|
|
87
|
+
* @param {object} input.provisionResult - Stage-1 result: `boot.pid` (app PID),
|
|
88
|
+
* `migrations` (applied count/detail), and `worktreePath`.
|
|
89
|
+
* @param {object|null} [input.driveResult] - Stage-2 result: the rows-created
|
|
90
|
+
* signal (whether the drive walked mutating steps). Null when no drive ran.
|
|
91
|
+
* @param {Array<object>|null} [input.rowManifest] - Explicit rows to drop, when
|
|
92
|
+
* a session tag/manifest is available. Absent/empty => untagged fallback.
|
|
93
|
+
* @param {{id?:string|null,url?:string|null}|null} [input.gist] - The Stage-4
|
|
94
|
+
* GitHub-native hosting artifact (a secret gist) to prune, when one was
|
|
95
|
+
* published off-Claude. Absent => nothing to prune.
|
|
96
|
+
* @param {boolean} [input.confirm] - Explicit authorization for the destructive
|
|
97
|
+
* steps (row drop, worktree removal, gist deletion). Fail-safe: absent means
|
|
98
|
+
* NOT confirmed.
|
|
99
|
+
* @param {boolean} [input.stopApp] - Stop the Stage-1 app (default true). This is
|
|
100
|
+
* a clean shutdown, NOT gated on confirmation.
|
|
101
|
+
* @param {object} seams
|
|
102
|
+
* @param {(a:{pid:number})=>Promise<{stopped:boolean,forced:boolean,detail:string,mayBeRunning?:boolean}>} seams.killProcess
|
|
103
|
+
* `mayBeRunning:true` marks a NOT-ATTEMPTED outcome (e.g. win32, where process-group
|
|
104
|
+
* signalling is unsupported) — mapped to MAY_BE_RUNNING (non-fatal), not KILL_FAILED.
|
|
105
|
+
* @param {(a:{rows:Array<object>})=>Promise<{ok:boolean,dropped:number,detail:string}>} seams.dropRows
|
|
106
|
+
* @param {(a:{worktreePath:string})=>Promise<{removed:string|null,ok:boolean,detail:string}>} seams.removeWorktree
|
|
107
|
+
* @param {(a:{id:string})=>Promise<{ok:boolean,detail:string}>} [seams.deleteGist] - Prune the
|
|
108
|
+
* Stage-4 hosting gist. Only invoked when a gist id is present AND confirmed.
|
|
109
|
+
* @param {(msg:string)=>void} [seams.log]
|
|
110
|
+
* @returns {Promise<{ok:boolean,confirmed:boolean,ledger:object,errors:string[],logs:string[]}>}
|
|
111
|
+
*/
|
|
112
|
+
export async function teardown(
|
|
113
|
+
{ provisionResult, driveResult = null, rowManifest = null, gist = null, confirm = false, stopApp = true },
|
|
114
|
+
{ killProcess, dropRows, removeWorktree, deleteGist, log = () => {} } = {},
|
|
115
|
+
) {
|
|
116
|
+
const logs = [];
|
|
117
|
+
const errors = [];
|
|
118
|
+
const record = (msg) => {
|
|
119
|
+
logs.push(msg);
|
|
120
|
+
log(msg);
|
|
121
|
+
};
|
|
122
|
+
const fail = (msg) => {
|
|
123
|
+
errors.push(msg);
|
|
124
|
+
record(msg);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const provision = provisionResult ?? {};
|
|
128
|
+
// `worktreePath` is read from disk (trust boundary). A non-string (number,
|
|
129
|
+
// object, array) must never reach `removeWorktree` — the CLI's cleanup path
|
|
130
|
+
// calls `path.resolve(worktreePath)`, which throws on a non-string, breaking
|
|
131
|
+
// the always-emit-ledger invariant. Coerce to a usable string or null here.
|
|
132
|
+
const rawWorktreePath = provision.worktreePath ?? null;
|
|
133
|
+
const worktreePath = typeof rawWorktreePath === "string" ? rawWorktreePath : null;
|
|
134
|
+
const worktreePathMalformed = rawWorktreePath != null && typeof rawWorktreePath !== "string";
|
|
135
|
+
const pid = provision.boot?.pid ?? null;
|
|
136
|
+
const migrations = provision.migrations ?? { applied: 0, pending: 0, destructive: [], detail: "no provision migrations" };
|
|
137
|
+
|
|
138
|
+
// 1. Stop the app (clean shutdown; NOT confirmation-gated). Uses the Stage-1
|
|
139
|
+
// boot PID. A missing OR unusable PID means we cannot stop it — the ledger
|
|
140
|
+
// reports it may still be running rather than guessing. `boot.pid` is read
|
|
141
|
+
// from disk (trust boundary), so anything that is not a positive integer
|
|
142
|
+
// (0, negative, NaN, float, string) is rejected here: passing it to the
|
|
143
|
+
// kill seam would let the CLI's process-group kill signal `process.kill(0)`
|
|
144
|
+
// (this loop's OWN group) or `process.kill(-1)` (every process).
|
|
145
|
+
const usablePid = Number.isInteger(pid) && pid > 0;
|
|
146
|
+
let processLedger;
|
|
147
|
+
if (!stopApp) {
|
|
148
|
+
processLedger = { pid: usablePid ? pid : null, status: PROCESS_STATUS.SKIPPED, forced: false, detail: "app stop skipped by request" };
|
|
149
|
+
record(`app stop skipped (pid ${usablePid ? pid : "n/a"})`);
|
|
150
|
+
} else if (!usablePid) {
|
|
151
|
+
const detail = pid == null
|
|
152
|
+
? "no PID captured from Stage 1; process may still be running"
|
|
153
|
+
: "no usable PID from Stage 1 (not a positive integer); process may still be running";
|
|
154
|
+
processLedger = { pid: null, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: false, detail };
|
|
155
|
+
record(`app stop: ${detail}`);
|
|
156
|
+
} else {
|
|
157
|
+
// A seam that THROWS must still yield a fully-emitted ledger: catch it,
|
|
158
|
+
// record KILL_FAILED, and press on. The always-emit invariant holds even
|
|
159
|
+
// when a real IO seam rejects.
|
|
160
|
+
try {
|
|
161
|
+
const kill = await killProcess({ pid });
|
|
162
|
+
if (kill.stopped) {
|
|
163
|
+
processLedger = { pid, status: PROCESS_STATUS.STOPPED, forced: !!kill.forced, detail: kill.detail };
|
|
164
|
+
record(`app stopped (pid ${pid})${kill.forced ? " [force-killed: SIGKILL fallback]" : ""}: ${kill.detail}`);
|
|
165
|
+
} else if (kill.mayBeRunning) {
|
|
166
|
+
// The kill was NOT ATTEMPTED (e.g. win32 process-group signalling is
|
|
167
|
+
// unsupported) — this is a "couldn't stop", not a failed attempt, so it
|
|
168
|
+
// is non-fatal (matches the null-PID may-be-running treatment): the
|
|
169
|
+
// ledger reports the app may still be running and `ok` is left intact.
|
|
170
|
+
processLedger = { pid, status: PROCESS_STATUS.MAY_BE_RUNNING, forced: !!kill.forced, detail: kill.detail };
|
|
171
|
+
record(`app stop: ${kill.detail}`);
|
|
172
|
+
} else {
|
|
173
|
+
processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: !!kill.forced, detail: kill.detail };
|
|
174
|
+
fail(`app stop FAILED (pid ${pid}): ${kill.detail}`);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
processLedger = { pid, status: PROCESS_STATUS.KILL_FAILED, forced: false, detail: `kill seam threw: ${err?.message ?? err}` };
|
|
178
|
+
fail(`app stop FAILED (pid ${pid}): kill seam threw: ${err?.message ?? err}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 2. Drop dev-DB rows — DESTRUCTIVE, confirmation-gated, dev DB only. Only ever
|
|
183
|
+
// drops an explicit manifest; never guesses untagged rows (see file header).
|
|
184
|
+
const hasManifest = Array.isArray(rowManifest) && rowManifest.length > 0;
|
|
185
|
+
let rowsLedger;
|
|
186
|
+
if (!confirm) {
|
|
187
|
+
if (hasManifest) {
|
|
188
|
+
rowsLedger = { status: ROW_STATUS.SKIPPED_UNCONFIRMED, dropped: 0, candidates: rowManifest.length, detail: `${rowManifest.length} row(s) NOT dropped: teardown not confirmed` };
|
|
189
|
+
record(`row drop skipped (not confirmed): ${rowManifest.length} manifest row(s) remain`);
|
|
190
|
+
} else if (driveMayHaveCreatedRows(driveResult)) {
|
|
191
|
+
rowsLedger = { status: ROW_STATUS.MAY_REMAIN_UNTAGGED, dropped: 0, candidates: 0, detail: "rows may remain (untagged): drive created rows but no session tag/manifest to target them, and teardown not confirmed" };
|
|
192
|
+
record("row drop skipped: rows may remain (untagged)");
|
|
193
|
+
} else {
|
|
194
|
+
rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
|
|
195
|
+
}
|
|
196
|
+
} else if (hasManifest) {
|
|
197
|
+
try {
|
|
198
|
+
const drop = await dropRows({ rows: rowManifest });
|
|
199
|
+
if (drop.ok) {
|
|
200
|
+
rowsLedger = { status: ROW_STATUS.DROPPED, dropped: drop.dropped ?? rowManifest.length, candidates: rowManifest.length, detail: drop.detail };
|
|
201
|
+
record(`dev-DB rows dropped: ${drop.dropped ?? rowManifest.length} (${drop.detail})`);
|
|
202
|
+
} else {
|
|
203
|
+
rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: drop.dropped ?? 0, candidates: rowManifest.length, detail: drop.detail };
|
|
204
|
+
fail(`dev-DB row drop FAILED: ${drop.detail}`);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
rowsLedger = { status: ROW_STATUS.DROP_FAILED, dropped: 0, candidates: rowManifest.length, detail: `drop seam threw: ${err?.message ?? err}` };
|
|
208
|
+
fail(`dev-DB row drop FAILED: drop seam threw: ${err?.message ?? err}`);
|
|
209
|
+
}
|
|
210
|
+
} else if (driveMayHaveCreatedRows(driveResult)) {
|
|
211
|
+
// Confirmed, but nothing to target: honesty over a guess-drop.
|
|
212
|
+
rowsLedger = { status: ROW_STATUS.MAY_REMAIN_UNTAGGED, dropped: 0, candidates: 0, detail: "rows may remain (untagged): drive created rows but no session tag/manifest to target them; refusing to guess which rows to drop" };
|
|
213
|
+
record("row drop: rows may remain (untagged) — no manifest to target, not guessing");
|
|
214
|
+
} else {
|
|
215
|
+
rowsLedger = { status: ROW_STATUS.NONE, dropped: 0, candidates: 0, detail: "no rows created (drive drove no mutating steps)" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. Remove the worktree — DESTRUCTIVE, confirmation-gated. Delegated to the
|
|
219
|
+
// shared cleanup path, which refuses anything outside the loop namespace.
|
|
220
|
+
let worktreeLedger;
|
|
221
|
+
if (worktreePathMalformed) {
|
|
222
|
+
worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}` };
|
|
223
|
+
fail(`worktree removal FAILED: malformed worktree path in provision result (not a string): ${typeof rawWorktreePath}`);
|
|
224
|
+
} else if (!worktreePath) {
|
|
225
|
+
worktreeLedger = { path: null, removed: false, status: WORKTREE_STATUS.MISSING_PATH, detail: "no worktree path in provision result" };
|
|
226
|
+
record("worktree removal skipped: no worktree path in provision result");
|
|
227
|
+
} else if (!confirm) {
|
|
228
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.SKIPPED_UNCONFIRMED, detail: "worktree retained: teardown not confirmed" };
|
|
229
|
+
record(`worktree removal skipped (not confirmed): ${worktreePath} retained`);
|
|
230
|
+
} else {
|
|
231
|
+
try {
|
|
232
|
+
const rm = await removeWorktree({ worktreePath });
|
|
233
|
+
if (rm.removed) {
|
|
234
|
+
worktreeLedger = { path: worktreePath, removed: true, status: WORKTREE_STATUS.REMOVED, detail: rm.detail };
|
|
235
|
+
record(`worktree removed: ${worktreePath} (${rm.detail})`);
|
|
236
|
+
} else {
|
|
237
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: rm.detail };
|
|
238
|
+
fail(`worktree removal FAILED: ${worktreePath} (${rm.detail})`);
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
worktreeLedger = { path: worktreePath, removed: false, status: WORKTREE_STATUS.REMOVE_FAILED, detail: `removeWorktree seam threw: ${err?.message ?? err}` };
|
|
242
|
+
fail(`worktree removal FAILED: ${worktreePath} (removeWorktree seam threw: ${err?.message ?? err})`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 4. Prune the Stage-4 hosting gist — DESTRUCTIVE, confirmation-gated. A gist
|
|
247
|
+
// accretes one secret entry per run; deleting it keeps the hosting target
|
|
248
|
+
// from piling up. Only ever acts on an explicit gist id from the report
|
|
249
|
+
// result; a missing id is NONE (nothing published, or Claude-hosted).
|
|
250
|
+
const gistId = typeof gist?.id === "string" && gist.id.trim().length > 0 ? gist.id.trim() : null;
|
|
251
|
+
let gistLedger;
|
|
252
|
+
if (!gistId) {
|
|
253
|
+
gistLedger = { id: null, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.NONE, detail: "no hosting gist to prune" };
|
|
254
|
+
} else if (!confirm) {
|
|
255
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.SKIPPED_UNCONFIRMED, detail: "hosting gist retained: teardown not confirmed" };
|
|
256
|
+
record(`gist prune skipped (not confirmed): ${gistId} retained`);
|
|
257
|
+
} else {
|
|
258
|
+
try {
|
|
259
|
+
const del = await deleteGist({ id: gistId });
|
|
260
|
+
if (del.ok) {
|
|
261
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: true, status: GIST_STATUS.DELETED, detail: del.detail };
|
|
262
|
+
record(`hosting gist deleted: ${gistId} (${del.detail})`);
|
|
263
|
+
} else {
|
|
264
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: del.detail };
|
|
265
|
+
fail(`hosting gist delete FAILED: ${gistId} (${del.detail})`);
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
gistLedger = { id: gistId, url: gist?.url ?? null, deleted: false, status: GIST_STATUS.DELETE_FAILED, detail: `deleteGist seam threw: ${err?.message ?? err}` };
|
|
269
|
+
fail(`hosting gist delete FAILED: ${gistId} (deleteGist seam threw: ${err?.message ?? err})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The ledger is ALWAYS emitted (every case), enumerating every known side
|
|
274
|
+
// effect. Migrations are recorded as applied-not-reverted by design.
|
|
275
|
+
const ledger = {
|
|
276
|
+
confirmed: confirm,
|
|
277
|
+
migrations: {
|
|
278
|
+
applied: migrations.applied ?? 0,
|
|
279
|
+
reverted: false,
|
|
280
|
+
detail: migrations.detail ?? null,
|
|
281
|
+
note: "not reverted (dev DB; migration reversal is a separate explicit action)",
|
|
282
|
+
},
|
|
283
|
+
rows: rowsLedger,
|
|
284
|
+
worktree: worktreeLedger,
|
|
285
|
+
gist: gistLedger,
|
|
286
|
+
process: processLedger,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return { ok: errors.length === 0, confirmed: confirm, ledger, errors, logs };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export { ROW_STATUS, WORKTREE_STATUS, PROCESS_STATUS, GIST_STATUS };
|