@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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.
Files changed (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -21,6 +21,8 @@
21
21
  "./debt/shape": "./src/debt/shape.mjs",
22
22
  "./debt/signal": "./src/debt/debt-signal.mjs",
23
23
  "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
24
+ "./github/issue-ops": "./src/github/issue-ops.mjs",
25
+ "./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
24
26
  "./github/repo-slug": "./src/github/repo-slug.mjs",
25
27
  "./github/review-threads": "./src/github/review-threads.mjs",
26
28
  "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
@@ -43,8 +45,10 @@
43
45
  "./loop/pr-lifecycle": "./src/loop/pr-lifecycle.mjs",
44
46
  "./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
45
47
  "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
48
+ "./loop/public-dev-loop-routing-contract": "./src/loop/public-dev-loop-routing-contract.mjs",
46
49
  "./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
47
50
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
51
+ "./loop/default-branch-guard": "./src/loop/default-branch-guard.mjs",
48
52
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
49
53
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
50
54
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
@@ -68,7 +72,9 @@
68
72
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
69
73
  "./projects/resolve-project": "./src/projects/resolve-project.mjs",
70
74
  "./harness": "./src/harness/index.mjs",
75
+ "./tracker": "./src/tracker/index.mjs",
71
76
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
77
+ "./loop/main-checkout-ff": "./src/loop/main-checkout-ff.mjs",
72
78
  "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
73
79
  },
74
80
  "bin": {
@@ -13,13 +13,21 @@
13
13
  // T0: File-level analysis
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
+ // Extensionless dotfile configs: static allowlist, no content sniffing. Matched
17
+ // against the basename only, never a prefix/suffix guess. Deliberately just
18
+ // .devloops (the reported consumer shape): runtime-version files like .nvmrc
19
+ // were considered and REJECTED — classifying them config would let ci-guard/
20
+ // determinism carry stale clean verdicts across a runtime bump; unknown
21
+ // fails closed toward a full re-review, which is what a version bump needs.
22
+ const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
23
+
16
24
  /**
17
25
  * @typedef {object} T0Result
18
26
  * @property {string[]} files — flat file paths
19
27
  * @property {string[]} extensions — unique file extensions (lowercase, with dot)
20
28
  * @property {string[]} directories — unique top-level path segments
21
29
  * @property {boolean} renameOnly — true when all entries are renames (no adds/deletes/modifies)
22
- * @property {boolean} allDocs — true when all files are under docs/ or are .md
30
+ * @property {boolean} allDocs — true when all files are under docs/ or have a docs extension (.md/.markdown)
23
31
  */
24
32
 
25
33
  /**
@@ -106,6 +114,9 @@ export function classifyFile(filePath) {
106
114
  ) {
107
115
  return "config";
108
116
  }
117
+ if (DOTFILE_CONFIG_BASENAMES.has(fp.split("/").pop())) {
118
+ return "config";
119
+ }
109
120
  if (fp.includes(".test.") || fp.startsWith("test/")) {
110
121
  return "test";
111
122
  }
@@ -115,7 +126,10 @@ export function classifyFile(filePath) {
115
126
  ) {
116
127
  return "code";
117
128
  }
118
- if (fp.startsWith("docs/") || fp.endsWith(".md") || fp === "README.md") {
129
+ if (
130
+ fp.startsWith("docs/") || fp.endsWith(".md") || fp.endsWith(".markdown") ||
131
+ fp === "README.md"
132
+ ) {
119
133
  return "docs";
120
134
  }
121
135
  return "unknown";
@@ -242,7 +256,9 @@ export function diffHasSecuritySeam(diffOutput) {
242
256
  *
243
257
  * Detects:
244
258
  * - COMMENT_ONLY: only comment lines changed
245
- * - DOCS_ONLY: only .md files changed (from extensions)
259
+ * - DOCS_ONLY: emitted when docs files are PRESENT in the diff (docs extensions
260
+ * .md/.markdown, or prose under docs/) — presence-based via
261
+ * t0PresentSurfaceCategories, not exclusivity
246
262
  * - CONFIG_ONLY: only config files changed
247
263
  * - TEST_ONLY: only test files changed
248
264
  * - RENAME_ONLY: all renames, no content changes
@@ -265,6 +281,16 @@ export function analyzeT1(diffOutput, t0) {
265
281
  let allChangedLinesAreNonLogic = true;
266
282
 
267
283
  for (const line of lines) {
284
+ // A new file's header block (diff --git, index, --- a/..., +++ b/...) ends
285
+ // the previous file's hunk run. Resetting here is what lets the counting
286
+ // below treat EVERY +/- line inside a hunk as content: a removed line whose
287
+ // content itself starts with "--" (a CLI flag, a YAML document separator)
288
+ // renders as "---…" in the diff, and a prefix-based header exclusion would
289
+ // silently drop it from the counts.
290
+ if (line.startsWith("diff --git ")) {
291
+ inHunk = false;
292
+ continue;
293
+ }
268
294
  // Track hunk headers
269
295
  if (line.startsWith("@@")) {
270
296
  hunkCount++;
@@ -275,7 +301,7 @@ export function analyzeT1(diffOutput, t0) {
275
301
  if (!inHunk) continue;
276
302
 
277
303
  // Track line stats and classify
278
- if (line.startsWith("+") && !line.startsWith("+++")) {
304
+ if (line.startsWith("+")) {
279
305
  added++;
280
306
  hasAnyChangedLine = true;
281
307
  const content = line.slice(1).trim();
@@ -283,7 +309,7 @@ export function analyzeT1(diffOutput, t0) {
283
309
  hasLogicChange = true;
284
310
  allChangedLinesAreNonLogic = false;
285
311
  }
286
- } else if (line.startsWith("-") && !line.startsWith("---")) {
312
+ } else if (line.startsWith("-")) {
287
313
  deleted++;
288
314
  hasAnyChangedLine = true;
289
315
  const content = line.slice(1).trim();
@@ -21,6 +21,7 @@ import {
21
21
  extractRepoFlagsFromGhPrCreateSegments,
22
22
  commandContainsRawExternalWrite,
23
23
  extractRepoFlagsFromExternalWriteSegments,
24
+ commandContainsGitStash,
24
25
  TARGET_REPO_SLUG,
25
26
  } from "../loop/bash-command-classify.mjs";
26
27
 
@@ -84,6 +85,19 @@ export function decideBashGate({ command, repoSlug = null, gatePassed = false, g
84
85
  if (typeof command !== "string") {
85
86
  return ALLOW;
86
87
  }
88
+ // `git stash` writes to `refs/stash`, one ref shared by every worktree over this repo's single
89
+ // `.git` directory — a stash from one worktree can pop into another's. Block it outright on the
90
+ // target repo; see skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout for the
91
+ // stash-free alternative (git diff / a patch file / a scratch checkout).
92
+ if (commandContainsGitStash(command) && (repoSlug ?? "").toLowerCase() === TARGET_REPO_SLUG.toLowerCase()) {
93
+ return {
94
+ decision: "deny",
95
+ reason:
96
+ "git stash blocked: refs/stash is shared across every worktree over this repo's one .git directory, " +
97
+ "so a stash can pop into a different worktree. Use `git diff` (or `git diff --staged`), save changes " +
98
+ "to a patch file, or use a separate scratch worktree instead of stashing.",
99
+ };
100
+ }
87
101
  // Subagent-scoped external-write guard: block ad-hoc `gh issue create`/`gh issue comment`/
88
102
  // `gh issue edit`/`gh pr comment` on the target repo from a subagent, so external writes flow through the
89
103
  // sanctioned node wrappers. The main-agent/operator path (agentType null) is unaffected (#1051).
@@ -126,13 +126,21 @@ export function parseIssueNumber(value, parseError = null) {
126
126
  return parsePositiveInteger(value, "--issue", parseError);
127
127
  }
128
128
 
129
- export function runChild(command, args, env = process.env) {
129
+ // `stdinText` is optional and additive: omit it and stdin stays closed exactly
130
+ // as before. Supply it (a `gh api ... --input -` payload) and it is piped in,
131
+ // so a caller that needs stdin no longer has to reach for a second, separately
132
+ // injected runner — every gh call in a script can route through the ONE
133
+ // runChild its tests already stub.
134
+ export function runChild(command, args, env = process.env, stdinText = undefined) {
130
135
  return new Promise((resolve, reject) => {
131
- const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
136
+ const child = spawn(command, args, { env, stdio: [stdinText === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
132
137
  let stdout = "";
133
138
  let stderr = "";
134
139
  child.stdout.on("data", (chunk) => { stdout += String(chunk); });
135
140
  child.stderr.on("data", (chunk) => { stderr += String(chunk); });
141
+ if (stdinText !== undefined) {
142
+ child.stdin.end(stdinText);
143
+ }
136
144
  child.on("error", reject);
137
145
  child.on("close", (code) => { resolve({ code, stdout, stderr }); });
138
146
  });
@@ -8,10 +8,14 @@
8
8
  * Only retries on usage/flag errors — NOT on network/auth/data errors.
9
9
  *
10
10
  * Detection contract:
11
- * A usage/flag error is stderr JSON with { ok: false, usage: "..." }
12
- * OR stderr text matching known argument-error patterns.
11
+ * A usage/flag error is stderr JSON with either the legacy
12
+ * { ok: false, usage: "..." } shape or the current short-error envelope
13
+ * { ok: false, hint: "..." } (formatCliError's `usage` presence check
14
+ * renders as `hint`, never the full usage text — see
15
+ * packages/core/src/github/review-threads.mjs's formatCliError), OR stderr
16
+ * text matching known argument-error patterns.
13
17
  *
14
- * Non-retryable: runtime errors ({ ok: false } without usage field),
18
+ * Non-retryable: runtime errors ({ ok: false } with neither usage nor hint),
15
19
  * network errors, auth errors, data errors, signal exits.
16
20
  *
17
21
  * Issue: #483
@@ -36,11 +40,15 @@ export function isUsageError(stderr) {
36
40
 
37
41
  const trimmed = stderr.trim();
38
42
 
39
- // Try JSON parse first: { ok: false, usage: "..." }
43
+ // Try JSON parse first: legacy { ok: false, usage: "..." }, or the current
44
+ // short-error envelope { ok: false, hint: "..." } (formatCliError never
45
+ // emits `usage` text anymore — a non-empty `hint` is its replacement
46
+ // presence signal that a usage string exists, fetchable via --help).
40
47
  try {
41
48
  const parsed = JSON.parse(trimmed);
42
- if (parsed && parsed.ok === false && typeof parsed.usage === 'string' && parsed.usage.length > 0) {
43
- return true;
49
+ if (parsed && parsed.ok === false) {
50
+ if (typeof parsed.usage === 'string' && parsed.usage.length > 0) return true;
51
+ if (typeof parsed.hint === 'string' && parsed.hint.length > 0) return true;
44
52
  }
45
53
  } catch {
46
54
  // Not JSON — fall through to pattern matching