@dev-loops/core 0.5.0 → 0.7.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 +4 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/asset-generation.mjs +26 -0
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +167 -97
- package/src/config/extension-defaults.yaml +0 -11
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- package/src/loop/async-start-contract.mjs +9 -2
- package/src/loop/bash-command-classify.mjs +333 -29
- package/src/loop/conductor-routing.mjs +0 -27
- package/src/loop/copilot-loop-state.mjs +25 -2
- package/src/loop/gate-fanin.mjs +92 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +236 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +94 -237
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +51 -7
- package/src/loop/queue-board-sync.mjs +61 -2
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/ui-e2e-scoping.mjs +162 -0
- package/bin/capture-deep-persona-signals.mjs +0 -143
- package/src/debt/deep-persona-signals.mjs +0 -266
- package/src/harness/claude-extension-adapter.mjs +0 -102
- package/src/refinement/ac-dod-matrix.mjs +0 -95
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
transitionEntry,
|
|
10
10
|
snapshotEntry,
|
|
11
11
|
nextReadyEntry,
|
|
12
|
+
findEntry,
|
|
12
13
|
allDone,
|
|
13
14
|
RECOVERABLE_FAILURES,
|
|
14
15
|
appendBugIssue,
|
|
@@ -19,7 +20,13 @@ import {
|
|
|
19
20
|
boardColumnForLoopState,
|
|
20
21
|
loadStateColumnMap,
|
|
21
22
|
} from "./queue-board-sync.mjs";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
resolveNextUpOrder,
|
|
25
|
+
REASON_NEXT_UP_EMPTY,
|
|
26
|
+
REASON_BOARD_QUERY_ERROR,
|
|
27
|
+
REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
|
|
28
|
+
EMPTY_NEXT_UP_MESSAGE,
|
|
29
|
+
} from "./queue-board-ordering.mjs";
|
|
23
30
|
|
|
24
31
|
export const DEFAULT_QUEUE_DRIVER_OPTIONS = {
|
|
25
32
|
mergeAuthorized: false,
|
|
@@ -92,23 +99,88 @@ export async function runQueue(repoRoot, repo, options = {}) {
|
|
|
92
99
|
// (e.g. a configured "Ready for Review") still syncs. (#793 round-1 #1)
|
|
93
100
|
const lastSyncedColumn = new Map();
|
|
94
101
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
102
|
+
// Next Up is the NORMATIVE, fail-closed pickup source (#1091). When a board is
|
|
103
|
+
// configured, the driver picks ONLY entries whose target is in Next Up, by
|
|
104
|
+
// POSITION ascending; entries absent from Next Up are never auto-picked. It
|
|
105
|
+
// NEVER falls back to Backlog or to the non-board local queue order.
|
|
106
|
+
//
|
|
107
|
+
// Single-issue/PR runs do not reach this gating at all — they run via the
|
|
108
|
+
// dev-loop routing path, not the queue driver — so an explicit --issue/--pr
|
|
109
|
+
// target is inherently unaffected by Next Up.
|
|
110
|
+
const ordering = !allDone(queue)
|
|
99
111
|
? await resolveNextUpOrder(repo, repoRoot, opts.env ?? process.env, opts.queueBoardSyncDependencies ?? {})
|
|
100
|
-
: { ok: true, order: [], reason: "
|
|
101
|
-
|
|
112
|
+
: { ok: true, configured: false, order: [], reason: "queue idle" };
|
|
113
|
+
|
|
114
|
+
// (b) Board-query ERROR → surface it and stop. Do NOT fall back to Backlog
|
|
115
|
+
// or local order (fail-closed). Distinct from an empty Next Up below.
|
|
116
|
+
if (ordering.ok === false) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
stopped: true,
|
|
120
|
+
reason: REASON_BOARD_QUERY_ERROR,
|
|
121
|
+
message: `Next Up query failed (${ordering.reason}); refusing to fall back to Backlog/local order`,
|
|
122
|
+
error: ordering.reason ?? "board query failed",
|
|
123
|
+
results: [],
|
|
124
|
+
queue,
|
|
125
|
+
ordering,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Board-gated only when a board is configured.
|
|
130
|
+
const boardGated = ordering.configured === true;
|
|
131
|
+
const orderHint = ordering.order;
|
|
132
|
+
const allowedTargets = boardGated ? new Set(orderHint) : null;
|
|
133
|
+
|
|
134
|
+
// (a) Empty Next Up (successful query, zero items) → fail CLOSED: idle/stop
|
|
135
|
+
// with an actionable, machine-readable outcome. Never pull from Backlog.
|
|
136
|
+
if (boardGated && orderHint.length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
idle: true,
|
|
140
|
+
reason: REASON_NEXT_UP_EMPTY,
|
|
141
|
+
message: EMPTY_NEXT_UP_MESSAGE,
|
|
142
|
+
results: [],
|
|
143
|
+
queue,
|
|
144
|
+
ordering,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// (a2) Next Up resolved one or more targets that have NO matching local queue
|
|
149
|
+
// entry (membership reconcile not run/persisted, or the board changed between
|
|
150
|
+
// reconcile and this query). Filtering them out would return a silent empty
|
|
151
|
+
// idle while real Next Up work goes undispatched — so fail CLOSED with an
|
|
152
|
+
// actionable stop instead. Distinct from the genuine empty-Next-Up idle above.
|
|
153
|
+
// Never pull from Backlog. (#1091)
|
|
154
|
+
if (boardGated) {
|
|
155
|
+
const missingTargets = orderHint.filter((t) => !findEntry(queue, t));
|
|
156
|
+
if (missingTargets.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
stopped: true,
|
|
160
|
+
reason: REASON_NEXT_UP_TARGET_MISSING_LOCALLY,
|
|
161
|
+
missingTargets,
|
|
162
|
+
message:
|
|
163
|
+
"Next Up contains items with no local queue entry — run membership reconcile / re-add them",
|
|
164
|
+
results: [],
|
|
165
|
+
queue,
|
|
166
|
+
ordering,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
102
170
|
|
|
103
171
|
let autoFiledCount = 0;
|
|
104
172
|
const results = [];
|
|
105
173
|
let incomplete = false;
|
|
106
174
|
|
|
107
175
|
while (!allDone(queue)) {
|
|
108
|
-
const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint);
|
|
176
|
+
const entry = nextReadyEntry(queue, opts.reDispatchMaxRetries, orderHint, allowedTargets);
|
|
109
177
|
if (!entry) {
|
|
178
|
+
// When board-gated, entries absent from Next Up are intentionally NOT
|
|
179
|
+
// picked (and are not "blocked by deps") — only unfinished Next Up members
|
|
180
|
+
// count toward an incomplete verdict.
|
|
110
181
|
const remaining = queue.entries.filter(
|
|
111
182
|
(e) => e.status !== "done" && e.status !== "blocked" && e.status !== "failed"
|
|
183
|
+
&& (!allowedTargets || allowedTargets.has(e.target))
|
|
112
184
|
);
|
|
113
185
|
if (remaining.length > 0) {
|
|
114
186
|
incomplete = true;
|
package/src/loop/queue-state.mjs
CHANGED
|
@@ -183,9 +183,20 @@ function applyOrderHint(ordered, orderHint) {
|
|
|
183
183
|
return [...inHint, ...rest];
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Pick the next ready entry.
|
|
188
|
+
*
|
|
189
|
+
* @param {object} queue
|
|
190
|
+
* @param {number} maxRetries
|
|
191
|
+
* @param {number[]} orderHint - preferred order (targets sorted to the front).
|
|
192
|
+
* @param {Set<number>|null} allowedTargets - when non-null, ONLY entries whose
|
|
193
|
+
* target is in this set are eligible. Used for board-gated (Next Up)
|
|
194
|
+
* selection (#1091): entries absent from Next Up are never auto-picked.
|
|
195
|
+
*/
|
|
196
|
+
export function nextReadyEntry(queue, maxRetries = 1, orderHint = [], allowedTargets = null) {
|
|
187
197
|
const ordered = topologicalOrder(queue.entries);
|
|
188
|
-
const
|
|
198
|
+
const restricted = allowedTargets ? ordered.filter((e) => allowedTargets.has(e.target)) : ordered;
|
|
199
|
+
const sorted = applyOrderHint(restricted, orderHint);
|
|
189
200
|
for (const entry of sorted) {
|
|
190
201
|
if (entry.status === "queued" && entryDependenciesSatisfied(queue, entry)) {
|
|
191
202
|
return entry;
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
|
|
5
5
|
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
6
6
|
* human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
|
|
7
|
-
* Code) that inject no native per-subagent run id.
|
|
8
|
-
* dispatching an async subagent.
|
|
7
|
+
* Code) that inject no native per-subagent run id. For those harnesses dev-loops itself mints
|
|
8
|
+
* and sets `DEVLOOPS_RUN_ID` when dispatching an async subagent.
|
|
9
|
+
*
|
|
10
|
+
* Other harnesses may already inject their own run-id var: the Pi runtime injects
|
|
11
|
+
* `PI_SUBAGENT_RUN_ID` (not `DEVLOOPS_RUN_ID`) into each async subagent's child env, so that
|
|
12
|
+
* name is honored as a recognized run-id alias (precedence after the neutral primary). It is
|
|
13
|
+
* an externally-injected Pi-runtime contract var, not a dev-loops-owned var — dev-loops still
|
|
14
|
+
* mints/propagates only the neutral `DEVLOOPS_RUN_ID`.
|
|
9
15
|
*
|
|
10
16
|
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
11
17
|
* which take an injectable `fs` and `root` for testability.
|
|
@@ -17,9 +23,10 @@ import path from "node:path";
|
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
20
|
-
* The neutral `DEVLOOPS_RUN_ID` is the
|
|
26
|
+
* The neutral `DEVLOOPS_RUN_ID` is primary; `PI_SUBAGENT_RUN_ID` is the alias the Pi
|
|
27
|
+
* runtime injects into async-subagent child envs (the only run-id marker present under Pi).
|
|
21
28
|
*/
|
|
22
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
|
|
29
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
|
|
23
30
|
|
|
24
31
|
/** Neutral env var name used when minting/propagating a run id. */
|
|
25
32
|
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// UI e2e auto-scoping (issue #976).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, path-triggered criterion: a PR that adds or modifies a
|
|
4
|
+
// *rendered* HTML artifact (a presentation deck, an article page, or the
|
|
5
|
+
// inspect-run viewer's served page/component) MUST run the shared UI e2e
|
|
6
|
+
// assertions (mobile + desktop) AND have that artifact registered in the e2e
|
|
7
|
+
// suite (DECK_REGISTRY / ARTICLE_REGISTRY / VIEWER_REGISTRY). Inclusion is
|
|
8
|
+
// triggered by the changed-file set, never by a human annotating the PR.
|
|
9
|
+
//
|
|
10
|
+
// This module is the testable core of that criterion: classify changed paths
|
|
11
|
+
// → rendered-artifact set → check each is registered → fail closed if a
|
|
12
|
+
// rendered artifact changed with no registered/passing coverage.
|
|
13
|
+
|
|
14
|
+
// Explicit path globs for rendered artifacts. Kept conservative and explicit
|
|
15
|
+
// (issue #976 scope discipline): only artifacts that render to a page/component.
|
|
16
|
+
export const RENDERED_ARTIFACT_GLOBS = Object.freeze([
|
|
17
|
+
"docs/articles/*.html",
|
|
18
|
+
"docs/presentations/*.html",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// The inspect-run viewer is served from a component, not a static .html file,
|
|
22
|
+
// so its trigger is the served-page source (matches the existing
|
|
23
|
+
// inspect-run-viewer-ci-changes.mjs trigger seam).
|
|
24
|
+
export const VIEWER_SOURCE_PATHS = Object.freeze([
|
|
25
|
+
"scripts/loop/inspect-run-viewer.mjs",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Registered artifacts — keyed by FULL repo-relative path (not basename), so
|
|
29
|
+
// docs/articles/X.html and docs/presentations/X.html (which share basenames,
|
|
30
|
+
// e.g. introducing-dev-loops.html) are DISTINCT and can never alias onto each
|
|
31
|
+
// other. Mirrors the registries' actual on-disk locations:
|
|
32
|
+
// decks → DECK_REGISTRY served from docs/presentations/<deck>
|
|
33
|
+
// articles→ ARTICLE_REGISTRY served from docs/articles/<file>
|
|
34
|
+
// Note: kept as an explicit list here rather than importing the harness
|
|
35
|
+
// (which pulls @playwright/test into core); the ui-e2e-scoping.test.mjs sync
|
|
36
|
+
// test fails if a registry entry is added without updating this list, so it
|
|
37
|
+
// can't silently drift.
|
|
38
|
+
export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
39
|
+
"docs/presentations/introducing-dev-loops.html",
|
|
40
|
+
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
|
+
"docs/articles/introducing-dev-loops.html",
|
|
42
|
+
"docs/articles/dev-loops-deep-dive.html",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
|
|
46
|
+
|
|
47
|
+
// CI check names that constitute the shared UI e2e coverage. The detect layer
|
|
48
|
+
// reads these from the statusCheckRollup to set uiE2ePassed. Note: a plain
|
|
49
|
+
// name match against the rollup is enough; the gate only needs to know whether
|
|
50
|
+
// the suite passed for this head. Each rendered-artifact family has a stable CI
|
|
51
|
+
// job whose name appears here; an absent check is unknown → fails closed.
|
|
52
|
+
export const UI_E2E_CHECK_NAMES = Object.freeze(["viewer-smoke", "deck-smoke", "article-smoke"]);
|
|
53
|
+
|
|
54
|
+
function normalizePath(filePath) {
|
|
55
|
+
return String(filePath ?? "").trim().replace(/^\.\/+/u, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Match a single explicit "dir/*.ext" glob (one path segment, no recursion).
|
|
59
|
+
function matchesGlob(normalizedPath, glob) {
|
|
60
|
+
const [dir, file] = [glob.slice(0, glob.lastIndexOf("/")), glob.slice(glob.lastIndexOf("/") + 1)];
|
|
61
|
+
if (!file.startsWith("*.")) return normalizedPath === glob;
|
|
62
|
+
const ext = file.slice(1); // ".html"
|
|
63
|
+
if (!normalizedPath.startsWith(`${dir}/`)) return false;
|
|
64
|
+
const rest = normalizedPath.slice(dir.length + 1);
|
|
65
|
+
return rest.length > 0 && !rest.includes("/") && rest.endsWith(ext);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Classify one changed path into a rendered-artifact descriptor, or null.
|
|
69
|
+
// A descriptor carries the path, a stable `id` (the deck filename or the
|
|
70
|
+
// viewer id) and whether that id is registered in the e2e suite.
|
|
71
|
+
export function classifyRenderedArtifactPath(filePath) {
|
|
72
|
+
const normalized = normalizePath(filePath);
|
|
73
|
+
if (normalized.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
if (VIEWER_SOURCE_PATHS.includes(normalized)) {
|
|
76
|
+
return { path: normalized, kind: "viewer", id: VIEWER_ARTIFACT_ID, registered: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const glob of RENDERED_ARTIFACT_GLOBS) {
|
|
80
|
+
if (matchesGlob(normalized, glob)) {
|
|
81
|
+
// Key registration on the FULL repo-relative path so an article and a
|
|
82
|
+
// deck that share a basename are distinct artifacts. id is the full path
|
|
83
|
+
// too, so the fail-closed reason names the exact file to register.
|
|
84
|
+
return {
|
|
85
|
+
path: normalized,
|
|
86
|
+
kind: normalized.startsWith("docs/articles/") ? "article" : "deck",
|
|
87
|
+
id: normalized,
|
|
88
|
+
registered: REGISTERED_ARTIFACT_PATHS.includes(normalized),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deterministic UI e2e scoping check.
|
|
97
|
+
*
|
|
98
|
+
* @param {string[]} changedPaths - PR changed-file paths.
|
|
99
|
+
* @param {{ uiE2ePassed?: boolean|null }} [coverage]
|
|
100
|
+
* uiE2ePassed: whether the shared UI e2e suite passed for this head.
|
|
101
|
+
* null/undefined means "not run / unknown" → fails closed.
|
|
102
|
+
* @returns {{
|
|
103
|
+
* required: boolean,
|
|
104
|
+
* artifacts: Array<{path,kind,id,registered}>,
|
|
105
|
+
* unregistered: string[],
|
|
106
|
+
* satisfied: boolean,
|
|
107
|
+
* reason: string|null,
|
|
108
|
+
* }}
|
|
109
|
+
*/
|
|
110
|
+
export function evaluateUiE2eScoping(changedPaths = [], { uiE2ePassed = null } = {}) {
|
|
111
|
+
const artifacts = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
|
|
114
|
+
const descriptor = classifyRenderedArtifactPath(p);
|
|
115
|
+
if (descriptor && !seen.has(descriptor.path)) {
|
|
116
|
+
seen.add(descriptor.path);
|
|
117
|
+
artifacts.push(descriptor);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const required = artifacts.length > 0;
|
|
122
|
+
if (!required) {
|
|
123
|
+
return { required: false, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fail closed: any touched rendered artifact that is not registered blocks
|
|
127
|
+
// and names itself so the fix is unambiguous (register it in the suite).
|
|
128
|
+
const unregistered = artifacts.filter((a) => !a.registered).map((a) => a.id);
|
|
129
|
+
if (unregistered.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
required: true,
|
|
132
|
+
artifacts,
|
|
133
|
+
unregistered,
|
|
134
|
+
satisfied: false,
|
|
135
|
+
reason:
|
|
136
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ` +
|
|
137
|
+
`${unregistered.join(", ")} that are not registered in the shared UI e2e suite ` +
|
|
138
|
+
`(DECK_REGISTRY or ARTICLE_REGISTRY in test/playwright/harness/deck-fit-harness.mjs, ` +
|
|
139
|
+
`or VIEWER_REGISTRY in test/playwright/harness/inspect-run-viewer-harness.mjs). ` +
|
|
140
|
+
`Register the artifact and add a spec that runs ` +
|
|
141
|
+
`the mobile + desktop assertions before this gate can pass.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All touched artifacts are registered — coverage must have actually passed.
|
|
146
|
+
if (uiE2ePassed !== true) {
|
|
147
|
+
const touched = artifacts.map((a) => a.id).join(", ");
|
|
148
|
+
return {
|
|
149
|
+
required: true,
|
|
150
|
+
artifacts,
|
|
151
|
+
unregistered: [],
|
|
152
|
+
satisfied: false,
|
|
153
|
+
reason:
|
|
154
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ${touched}, ` +
|
|
155
|
+
`but the shared UI e2e suite (mobile + desktop) has not passed for this head ` +
|
|
156
|
+
`(uiE2ePassed=${String(uiE2ePassed)}). Run the UI/mobile e2e loop and let it pass ` +
|
|
157
|
+
`before this gate can proceed.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { required: true, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
162
|
+
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseReviewThreads, readInput, parseJsonText, formatCliError } from "../src/github/review-threads.mjs";
|
|
6
|
-
import { extractDeepPersonaSignals } from "../src/debt/deep-persona-signals.mjs";
|
|
7
|
-
|
|
8
|
-
export const USAGE = [
|
|
9
|
-
"Usage: capture-deep-persona-signals.mjs --input <path> --pr-number <n> --pr-url <url> [--output-dir <path>]",
|
|
10
|
-
"",
|
|
11
|
-
"Arguments:",
|
|
12
|
-
" --input <path> Path to normalized review-thread JSON (required)",
|
|
13
|
-
" --pr-number <n> PR number for metadata (required)",
|
|
14
|
-
" --pr-url <url> PR URL for metadata (required)",
|
|
15
|
-
" --output-dir <path> Output directory for emitted artifact (default: .pi/debt/signals/)",
|
|
16
|
-
].join("\n");
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Parse CLI arguments for the capture-deep-persona-signals CLI.
|
|
20
|
-
*
|
|
21
|
-
* @param {string[]} argv - Argument list (e.g. process.argv.slice(2))
|
|
22
|
-
* @returns {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }}
|
|
23
|
-
*/
|
|
24
|
-
export function parseArgs(argv) {
|
|
25
|
-
const args = [...argv];
|
|
26
|
-
const options = {
|
|
27
|
-
inputPath: undefined,
|
|
28
|
-
prNumber: undefined,
|
|
29
|
-
prUrl: undefined,
|
|
30
|
-
outputDir: ".pi/debt/signals",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
while (args.length > 0) {
|
|
34
|
-
const token = args.shift();
|
|
35
|
-
|
|
36
|
-
switch (token) {
|
|
37
|
-
case "--input": {
|
|
38
|
-
const value = args.shift();
|
|
39
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
40
|
-
throw Object.assign(new Error("Missing value for --input"), { usage: USAGE });
|
|
41
|
-
}
|
|
42
|
-
options.inputPath = value;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
case "--pr-number": {
|
|
46
|
-
const value = args.shift();
|
|
47
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
48
|
-
throw Object.assign(new Error("Missing value for --pr-number"), { usage: USAGE });
|
|
49
|
-
}
|
|
50
|
-
if (!/^\d+$/.test(value)) {
|
|
51
|
-
throw Object.assign(new Error(`--pr-number must be a positive integer, got: ${value}`), { usage: USAGE });
|
|
52
|
-
}
|
|
53
|
-
options.prNumber = value;
|
|
54
|
-
break;
|
|
55
|
-
}
|
|
56
|
-
case "--pr-url": {
|
|
57
|
-
const value = args.shift();
|
|
58
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
59
|
-
throw Object.assign(new Error("Missing value for --pr-url"), { usage: USAGE });
|
|
60
|
-
}
|
|
61
|
-
options.prUrl = value;
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
case "--output-dir": {
|
|
65
|
-
const value = args.shift();
|
|
66
|
-
if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
|
|
67
|
-
throw Object.assign(new Error("Missing value for --output-dir"), { usage: USAGE });
|
|
68
|
-
}
|
|
69
|
-
options.outputDir = value;
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
default:
|
|
73
|
-
throw Object.assign(new Error(`Unknown argument: ${token}`), { usage: USAGE });
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!options.inputPath) {
|
|
78
|
-
throw Object.assign(new Error("--input is required"), { usage: USAGE });
|
|
79
|
-
}
|
|
80
|
-
if (!options.prNumber) {
|
|
81
|
-
throw Object.assign(new Error("--pr-number is required"), { usage: USAGE });
|
|
82
|
-
}
|
|
83
|
-
if (!options.prUrl) {
|
|
84
|
-
throw Object.assign(new Error("--pr-url is required"), { usage: USAGE });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return /** @type {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }} */ (options);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Generate the output filename.
|
|
92
|
-
* @param {string} prNumber
|
|
93
|
-
* @returns {string}
|
|
94
|
-
*/
|
|
95
|
-
export function outputFilename(prNumber) {
|
|
96
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
97
|
-
return `deep-persona-signals-${prNumber}-${ts}.json`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function run(argv = process.argv.slice(2)) {
|
|
101
|
-
const options = parseArgs(argv);
|
|
102
|
-
|
|
103
|
-
// Read and parse the review-thread input
|
|
104
|
-
const rawText = await readInput({ inputPath: options.inputPath });
|
|
105
|
-
const parsed = parseReviewThreads(parseJsonText(rawText));
|
|
106
|
-
|
|
107
|
-
// Extract deep-persona signals
|
|
108
|
-
const signals = extractDeepPersonaSignals(parsed, {
|
|
109
|
-
prNumber: options.prNumber,
|
|
110
|
-
prUrl: options.prUrl,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Build artifact envelope
|
|
114
|
-
const artifact = {
|
|
115
|
-
version: 1,
|
|
116
|
-
generatedAt: new Date().toISOString(),
|
|
117
|
-
prNumber: Number(options.prNumber),
|
|
118
|
-
prUrl: options.prUrl,
|
|
119
|
-
source: "pr_review_deep_persona",
|
|
120
|
-
signalCount: signals.length,
|
|
121
|
-
signals,
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
// Write to output directory
|
|
125
|
-
const outDir = resolve(options.outputDir);
|
|
126
|
-
await mkdir(outDir, { recursive: true });
|
|
127
|
-
const outPath = join(outDir, outputFilename(options.prNumber));
|
|
128
|
-
await writeFile(outPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
129
|
-
|
|
130
|
-
process.stdout.write(JSON.stringify({ ok: true, outputPath: outPath, signalCount: signals.length }) + "\n");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Only auto-run when executed directly (not imported)
|
|
134
|
-
const scriptPath = fileURLToPath(import.meta.url);
|
|
135
|
-
if (process.argv[1] === scriptPath) {
|
|
136
|
-
run().catch((error) => {
|
|
137
|
-
if (error.usage) {
|
|
138
|
-
process.stderr.write(error.usage + "\n\n");
|
|
139
|
-
}
|
|
140
|
-
process.stderr.write(formatCliError(error) + "\n");
|
|
141
|
-
process.exitCode = 1;
|
|
142
|
-
});
|
|
143
|
-
}
|