@dev-loops/core 0.6.0 → 0.7.2
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 +7 -7
- package/src/analysis/change-classifier.mjs +50 -6
- package/src/analysis/diff-analyzer.mjs +68 -12
- package/src/claude/hook-decisions.mjs +138 -15
- package/src/config/config.mjs +247 -98
- package/src/config/extension-defaults.yaml +6 -11
- package/src/github/copilot-helpers.mjs +143 -0
- package/src/harness/extension-adapter.mjs +1 -0
- package/src/harness/index.mjs +0 -1
- 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 +137 -0
- package/src/loop/handoff-envelope.mjs +142 -70
- package/src/loop/issue-refinement-artifact.mjs +259 -8
- package/src/loop/lifecycle-state.mjs +1 -1
- package/src/loop/pr-gate-coordination.mjs +158 -238
- package/src/loop/pr-lifecycle.mjs +79 -0
- package/src/loop/public-dev-loop-routing.mjs +2 -2
- package/src/loop/queue-board-ordering.mjs +52 -8
- package/src/loop/queue-board-sync.mjs +62 -3
- package/src/loop/queue-driver.mjs +80 -8
- package/src/loop/queue-state.mjs +13 -2
- package/src/loop/reviewer-loop-state.mjs +20 -2
- package/src/projects/list-queue-items.mjs +380 -0
- package/src/projects/move-queue-item.mjs +394 -0
- package/src/projects/resolve-project.mjs +183 -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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* This module owns:
|
|
13
13
|
* - canonical section-name matching for AC / DoD blocks
|
|
14
|
-
* - bullet-item extraction (
|
|
14
|
+
* - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
|
|
15
15
|
* - linked-refinement-doc detection from issue body
|
|
16
16
|
*
|
|
17
17
|
* It deliberately does NOT:
|
|
@@ -47,9 +47,44 @@ const DOD_SECTION_PATTERNS = Object.freeze([
|
|
|
47
47
|
/^dod\s*$/i,
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Fenced-code-span tracker. Given the previous fence state and the current
|
|
52
|
+
* line, returns { fence, insideFence } where:
|
|
53
|
+
* - `fence` is the next state ({ char, len } while open, else null)
|
|
54
|
+
* - `insideFence` is true when the line's CONTENT is inside a code span
|
|
55
|
+
* (i.e. a fence line, or a line between an open and its close)
|
|
56
|
+
*
|
|
57
|
+
* CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
|
|
58
|
+
* markers of the SAME char with no info string. This is the single source of
|
|
59
|
+
* truth shared by parseMarkdownSections (headings) and extractChecklistItems
|
|
60
|
+
* (checkboxes) so the two anti-spoof layers cannot drift (issue #1025).
|
|
61
|
+
*/
|
|
62
|
+
function stepFence(fence, line) {
|
|
63
|
+
const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
|
|
64
|
+
if (openMatch) {
|
|
65
|
+
const char = openMatch[1][0];
|
|
66
|
+
const len = openMatch[1].length;
|
|
67
|
+
// A closing fence is a bare run of >= N markers of ONLY the opening char
|
|
68
|
+
// (CommonMark: no mixed markers, no info string).
|
|
69
|
+
const isBareRun = new RegExp(`^\\s*${char}+\\s*$`, "u").test(line);
|
|
70
|
+
if (fence === null) {
|
|
71
|
+
return { fence: { char, len }, insideFence: true };
|
|
72
|
+
}
|
|
73
|
+
if (fence.char === char && len >= fence.len && isBareRun) {
|
|
74
|
+
return { fence: null, insideFence: true };
|
|
75
|
+
}
|
|
76
|
+
return { fence, insideFence: true };
|
|
77
|
+
}
|
|
78
|
+
return { fence, insideFence: fence !== null };
|
|
79
|
+
}
|
|
80
|
+
|
|
50
81
|
/**
|
|
51
82
|
* Extract `## ...` heading boundaries from a Markdown body.
|
|
52
83
|
* Returns a sorted array of { level, name, bodyLines } records.
|
|
84
|
+
*
|
|
85
|
+
* Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
|
|
86
|
+
* otherwise a body could spoof the refinement/spec gate with real-looking
|
|
87
|
+
* headings that carry no real spec (gate integrity, issue #1025).
|
|
53
88
|
*/
|
|
54
89
|
export function parseMarkdownSections(body) {
|
|
55
90
|
if (typeof body !== "string" || body.length === 0) {
|
|
@@ -59,8 +94,15 @@ export function parseMarkdownSections(body) {
|
|
|
59
94
|
const lines = body.split(/\r?\n/u);
|
|
60
95
|
const sections = [];
|
|
61
96
|
let current = null;
|
|
97
|
+
let fence = null;
|
|
62
98
|
|
|
63
99
|
for (const line of lines) {
|
|
100
|
+
const step = stepFence(fence, line);
|
|
101
|
+
fence = step.fence;
|
|
102
|
+
if (step.insideFence) {
|
|
103
|
+
if (current) current.bodyLines.push(line);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
64
106
|
const match = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
|
|
65
107
|
if (match) {
|
|
66
108
|
if (current) {
|
|
@@ -97,10 +139,18 @@ function findSectionByPatterns(sections, patterns) {
|
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
/**
|
|
100
|
-
* Extract
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
142
|
+
* Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
|
|
143
|
+
* checklist items and top-level plain `- ` bullets (dash at column 0, so
|
|
144
|
+
* nested/indented sub-bullets are not counted). Empty checkbox placeholders
|
|
145
|
+
* (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a
|
|
146
|
+
* section of only unfilled placeholders reports as unrefined. Returns the
|
|
147
|
+
* trimmed item text for each matching line. The checkbox state (checked vs
|
|
148
|
+
* unchecked) is intentionally not preserved: callers only need the item
|
|
149
|
+
* text to satisfy the refinement-artifact contract.
|
|
150
|
+
*
|
|
151
|
+
* This is only ever called on the body of an already-recognized AC/DoD
|
|
152
|
+
* section (see `detectIssueRefinementArtifact`), so counting plain bullets
|
|
153
|
+
* is scoped to those sections and never affects prose sections.
|
|
104
154
|
*/
|
|
105
155
|
export function extractChecklistItems(sectionBody) {
|
|
106
156
|
if (typeof sectionBody !== "string" || sectionBody.length === 0) {
|
|
@@ -109,11 +159,33 @@ export function extractChecklistItems(sectionBody) {
|
|
|
109
159
|
|
|
110
160
|
const items = [];
|
|
111
161
|
const lines = sectionBody.split(/\r?\n/u);
|
|
162
|
+
let fence = null;
|
|
112
163
|
|
|
113
164
|
for (const line of lines) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
// Checkboxes/bullets inside a fenced code span are non-interactive text, not
|
|
166
|
+
// real items — skip them so a body cannot spoof the AC/DoD gate with
|
|
167
|
+
// code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
|
|
168
|
+
const step = stepFence(fence, line);
|
|
169
|
+
fence = step.fence;
|
|
170
|
+
if (step.insideFence) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// Checklist item: `- [ ]` / `- [x]` (leading indentation tolerated).
|
|
174
|
+
// Consume ANY checkbox-marker line here; push only when it carries text,
|
|
175
|
+
// so empty placeholders (`- [ ]`) are skipped rather than counted.
|
|
176
|
+
const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
|
|
177
|
+
if (checkboxMatch) {
|
|
178
|
+
const text = (checkboxMatch[1] ?? "").trim();
|
|
179
|
+
if (text.length > 0) {
|
|
180
|
+
items.push(text);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Top-level plain bullet: dash at column 0, space required (so `---`
|
|
185
|
+
// horizontal rules and `-x` do not match; indented sub-bullets do not).
|
|
186
|
+
const bulletMatch = /^-\s+(.+?)\s*$/u.exec(line);
|
|
187
|
+
if (bulletMatch) {
|
|
188
|
+
const text = bulletMatch[1].trim();
|
|
117
189
|
if (text.length > 0) {
|
|
118
190
|
items.push(text);
|
|
119
191
|
}
|
|
@@ -248,6 +320,185 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
|
|
|
248
320
|
};
|
|
249
321
|
}
|
|
250
322
|
|
|
323
|
+
/**
|
|
324
|
+
* PR-body-as-spec invariant sections (issue #1025, lightweight path).
|
|
325
|
+
*
|
|
326
|
+
* When a lightweight session uses the PR description itself as the
|
|
327
|
+
* spec-of-record (no committed phase/plan doc), the PR body must still carry
|
|
328
|
+
* the same invariants a durable spec doc would. AC/DoD reuse the checklist
|
|
329
|
+
* patterns above; these are the narrative sections not covered by those.
|
|
330
|
+
* Key order = validation/report order. Each key maps to its distinct
|
|
331
|
+
* `missing_*` code (mirrors `checkBaseSections` in _refine-helpers.mjs).
|
|
332
|
+
*/
|
|
333
|
+
export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
|
|
334
|
+
objective: {
|
|
335
|
+
code: "missing_objective",
|
|
336
|
+
label: "Objective/why",
|
|
337
|
+
patterns: [/^objective\b/iu, /^why\b/iu, /^goals?\b/iu, /^summary\b/iu, /^problem\b/iu],
|
|
338
|
+
},
|
|
339
|
+
in_scope: {
|
|
340
|
+
code: "missing_in_scope",
|
|
341
|
+
label: "In scope",
|
|
342
|
+
patterns: [/^in[- ]?scope\b/iu, /^scope\b/iu],
|
|
343
|
+
},
|
|
344
|
+
non_goals: {
|
|
345
|
+
code: "missing_explicit_non_goals",
|
|
346
|
+
label: "Explicit non-goals",
|
|
347
|
+
patterns: [/^explicit non-?goals\b/iu, /^non-?goals\b/iu, /^out of scope\b/iu],
|
|
348
|
+
},
|
|
349
|
+
open_questions: {
|
|
350
|
+
code: "missing_open_questions",
|
|
351
|
+
label: "Open questions/risks",
|
|
352
|
+
patterns: [/^open questions\b/iu, /^risks?\b/iu, /^questions\b/iu],
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* GitHub's accepted closing-keyword issue references (close/closes/closed,
|
|
358
|
+
* fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
|
|
359
|
+
* `#N` or the cross-repo `owner/repo#N` form. Mirrors the linkage the
|
|
360
|
+
* lightweight path (#1025) requires the PR body to carry (issue #1181: five
|
|
361
|
+
* lightweight PRs merged without this and none auto-closed their issue).
|
|
362
|
+
*/
|
|
363
|
+
const CLOSING_ISSUE_REFERENCE_PATTERN =
|
|
364
|
+
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
|
|
365
|
+
|
|
366
|
+
function extractClosingIssueNumbers(body) {
|
|
367
|
+
// Same fence-skip as sectionHasBody: a `Closes #N` line quoted inside a
|
|
368
|
+
// ```fenced``` example (e.g. a PR-template sample) must not spoof the gate.
|
|
369
|
+
let fence = null;
|
|
370
|
+
const unfenced = [];
|
|
371
|
+
for (const line of body.split("\n")) {
|
|
372
|
+
const step = stepFence(fence, line);
|
|
373
|
+
fence = step.fence;
|
|
374
|
+
if (step.insideFence) continue;
|
|
375
|
+
unfenced.push(line);
|
|
376
|
+
}
|
|
377
|
+
// Inline `code` spans don't auto-close on GitHub either: blank out any
|
|
378
|
+
// backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
|
|
379
|
+
// ponytail: not full CommonMark span matching; an unbalanced stray backtick
|
|
380
|
+
// over-strips toward fail-closed, which is the safe direction for this gate.
|
|
381
|
+
const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const numbers = [];
|
|
384
|
+
for (const match of text.matchAll(CLOSING_ISSUE_REFERENCE_PATTERN)) {
|
|
385
|
+
const n = Number(match[1]);
|
|
386
|
+
if (Number.isInteger(n) && n > 0 && !seen.has(n)) {
|
|
387
|
+
seen.add(n);
|
|
388
|
+
numbers.push(n);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return numbers;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function sectionHasBody(section) {
|
|
395
|
+
// A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
|
|
396
|
+
// a section whose only content is a ```fenced``` block is treated as empty so
|
|
397
|
+
// it cannot spoof the narrative-invariant gate (issue #1025, same stepFence as
|
|
398
|
+
// parseMarkdownSections + extractChecklistItems).
|
|
399
|
+
if (!section) return false;
|
|
400
|
+
let fence = null;
|
|
401
|
+
for (const line of section.bodyLines) {
|
|
402
|
+
const step = stepFence(fence, line);
|
|
403
|
+
fence = step.fence;
|
|
404
|
+
if (step.insideFence) continue;
|
|
405
|
+
if (line.trim().length > 0) return true;
|
|
406
|
+
}
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate that a PR body carries every invariant required to serve as the
|
|
412
|
+
* lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
|
|
413
|
+
* testable Acceptance criteria (>=1 checklist item), Definition of done
|
|
414
|
+
* (>=1 checklist item), Open questions/risks, and — unless explicit
|
|
415
|
+
* issue-less mode is requested — a GitHub closing-keyword issue reference
|
|
416
|
+
* (`Closes #N` and GitHub's other accepted forms — the lightweight path's
|
|
417
|
+
* `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
|
|
418
|
+
* (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
|
|
419
|
+
* there is no parallel validator. Fails closed: every missing invariant is
|
|
420
|
+
* reported under its distinct `missing_*` code. Pure; no side effects.
|
|
421
|
+
*
|
|
422
|
+
* Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
|
|
423
|
+
* stay unconditional, but the closing-issue linkage flips from REQUIRED to
|
|
424
|
+
* FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
|
|
425
|
+
* reference to an issue that doesn't back it. A present reference in this
|
|
426
|
+
* mode fails closed under `unexpected_closing_issue_reference`, distinct
|
|
427
|
+
* from `missing_closing_issue_reference` (tracker-backed mode, the default)
|
|
428
|
+
* so callers can tell "no issue expected" apart from "issue expected but
|
|
429
|
+
* absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
|
|
430
|
+
* pick exactly one mode (tracker-backed, with or without a specific
|
|
431
|
+
* expected issue) or issue-less — never both.
|
|
432
|
+
*
|
|
433
|
+
* @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
|
|
434
|
+
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
435
|
+
*/
|
|
436
|
+
export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
|
|
437
|
+
if (issueLess && Number.isInteger(expectedIssue)) {
|
|
438
|
+
// Fail closed at the library boundary too (not just the CLI): the two modes
|
|
439
|
+
// are contradictory and silently preferring one would hide caller bugs.
|
|
440
|
+
throw new Error("validatePrBodySpec: issueLess and expectedIssue are mutually exclusive; pass exactly one issue-linkage mode");
|
|
441
|
+
}
|
|
442
|
+
const bodyText = typeof body === "string" ? body : "";
|
|
443
|
+
const sections = parseMarkdownSections(bodyText);
|
|
444
|
+
const errors = [];
|
|
445
|
+
|
|
446
|
+
for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
|
|
447
|
+
const section = findSectionByPatterns(sections, patterns);
|
|
448
|
+
if (!sectionHasBody(section)) {
|
|
449
|
+
errors.push({ code, message: `Missing or empty ${label} section.` });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
454
|
+
const acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
|
|
455
|
+
if (acItems.length === 0) {
|
|
456
|
+
errors.push({
|
|
457
|
+
code: "missing_acceptance_criteria",
|
|
458
|
+
message: "Missing testable Acceptance criteria (no checklist items found).",
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
463
|
+
const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
|
|
464
|
+
if (dodItems.length === 0) {
|
|
465
|
+
errors.push({
|
|
466
|
+
code: "missing_definition_of_done",
|
|
467
|
+
message: "Missing Definition of done (no checklist items found).",
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const closesIssues = extractClosingIssueNumbers(bodyText);
|
|
472
|
+
if (issueLess) {
|
|
473
|
+
if (closesIssues.length > 0) {
|
|
474
|
+
errors.push({
|
|
475
|
+
code: "unexpected_closing_issue_reference",
|
|
476
|
+
message: `Issue-less PR body MUST NOT carry a closing reference to an issue that doesn't back it (found ${closesIssues.map((n) => `#${n}`).join(", ")}).`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
} else if (closesIssues.length === 0) {
|
|
480
|
+
errors.push({
|
|
481
|
+
code: "missing_closing_issue_reference",
|
|
482
|
+
message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
|
|
483
|
+
});
|
|
484
|
+
} else if (Number.isInteger(expectedIssue) && !closesIssues.includes(expectedIssue)) {
|
|
485
|
+
errors.push({
|
|
486
|
+
code: "closes_wrong_issue",
|
|
487
|
+
message: `PR body closes ${closesIssues.map((n) => `#${n}`).join(", ")}, not the expected #${expectedIssue}.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
checker: "validate-pr-body-spec",
|
|
493
|
+
ok: errors.length === 0,
|
|
494
|
+
errors,
|
|
495
|
+
sections: sections.map((s) => s.name),
|
|
496
|
+
acItems,
|
|
497
|
+
dodItems,
|
|
498
|
+
closesIssues,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
251
502
|
/**
|
|
252
503
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
253
504
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -133,7 +133,7 @@ export const LIFECYCLE_NEXT_ACTIONS = Object.freeze({
|
|
|
133
133
|
[LIFECYCLE_STATE.PRE_APPROVAL_GATE]:
|
|
134
134
|
"Run pre-approval gate review; verify gate evidence, CI, and unresolved threads.",
|
|
135
135
|
[LIFECYCLE_STATE.MERGE]:
|
|
136
|
-
"Merge is authorized; run the final merge step and
|
|
136
|
+
"Merge is authorized; run the final merge step. The retrospective is advisory and post-merge: it records flagged raw-calls for the conductor, never blocks a transition.",
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
// ---------------------------------------------------------------------------
|