@dev-loops/core 0.2.1 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
6
  "exports": {
@@ -8,7 +8,6 @@
8
8
  "./cli/helpers": "./src/cli/helpers.mjs",
9
9
  "./cli/primitives": "./src/cli/primitives.mjs",
10
10
  "./cli/retry-wrapper": "./src/cli/retry-wrapper.mjs",
11
- "./cli/subcommand-runner": "./src/cli/subcommand-runner.mjs",
12
11
  "./claude/asset-generation": "./src/claude/asset-generation.mjs",
13
12
  "./claude/headless-entry": "./src/claude/headless-entry.mjs",
14
13
  "./claude/hook-decisions": "./src/claude/hook-decisions.mjs",
@@ -28,7 +28,7 @@ const ModelsConfig = z.strictObject({
28
28
  const RefinementConfig = z.strictObject({
29
29
  fanOut: z.number().int().min(1).max(10),
30
30
  mode: z.enum(["parallel", "sequential"]),
31
- maxCopilotRounds: z.number().int().positive().default(5),
31
+ maxCopilotRounds: z.number().int().nonnegative().default(5),
32
32
  stopOnLowSignal: z.boolean().default(false),
33
33
  lowSignalRoundThreshold: z.number().int().nonnegative().default(3),
34
34
  lowSignalMaxComments: z.number().int().nonnegative().default(2),
@@ -23,7 +23,7 @@
23
23
  * This module is intentionally pure and side-effect free.
24
24
  */
25
25
 
26
- import { RUN_ID_MARKERS } from "./run-context.mjs";
26
+ import { RUN_ID_MARKERS, isClaudeHarness } from "./run-context.mjs";
27
27
 
28
28
  // ---------------------------------------------------------------------------
29
29
  // Constants
@@ -54,6 +54,39 @@ export const ASYNC_START_STATUS = Object.freeze({
54
54
  REJECTED: "rejected",
55
55
  });
56
56
 
57
+ /**
58
+ * Resolve the effective async-start mode for the current harness.
59
+ *
60
+ * The async-start contract is configurable via `workflow.asyncStartMode`
61
+ * (`required` | `allowed`) — see defaults.yaml. It exists to stop the Pi
62
+ * harness from running the loop as a detached, uninspectable background
63
+ * process. Claude Code's Agent tool has no detached-process variant (each
64
+ * subagent run is visible and inspectable), so under the Claude harness a
65
+ * *recognized* mode is relaxed to `allowed` at runtime. An unrecognized
66
+ * (e.g. typo'd) `configuredMode` is returned verbatim — not relaxed — so
67
+ * `validateAsyncStartContext` still rejects it and the config error surfaces
68
+ * even under Claude. Pi behavior is unchanged: outside Claude the configured
69
+ * mode is always returned verbatim.
70
+ *
71
+ * @param {string} configuredMode - Mode from workflow config; normally
72
+ * `"required"` | `"allowed"`, but any value is accepted and an unrecognized
73
+ * one is passed through unchanged for downstream validation.
74
+ * @param {Record<string, string|undefined>} [env]
75
+ * @returns {string} The effective mode (`"allowed"` when a recognized mode is
76
+ * relaxed under Claude; otherwise `configuredMode` verbatim).
77
+ */
78
+ export function resolveEffectiveAsyncStartMode(configuredMode, env = process.env) {
79
+ // Only relax a recognized mode. An unrecognized configuredMode must pass through
80
+ // verbatim so validateAsyncStartContext still rejects it (surfacing the config
81
+ // error) rather than having the Claude relaxation silently mask a typo'd value.
82
+ const isRecognizedMode =
83
+ configuredMode === ASYNC_START_MODE.REQUIRED || configuredMode === ASYNC_START_MODE.ALLOWED;
84
+ if (isRecognizedMode && isClaudeHarness(env)) {
85
+ return ASYNC_START_MODE.ALLOWED;
86
+ }
87
+ return configuredMode;
88
+ }
89
+
57
90
  // ---------------------------------------------------------------------------
58
91
  // Validation
59
92
  // ---------------------------------------------------------------------------
@@ -447,6 +447,12 @@ export function shouldGuardCopilotReviewRequest({
447
447
  if (!gateBoundariesRequiringCopilotFormalRequest.has(gateBoundary)) {
448
448
  return false;
449
449
  }
450
+ // Copilot review disabled for the repo (maxCopilotRounds: 0): never force a
451
+ // formal request — the loop runs draft_gate → pre_approval with the local
452
+ // harness only. See evaluatePrGateCoordination (internal_only routing).
453
+ if (maxCopilotRounds === 0) {
454
+ return false;
455
+ }
450
456
  if (copilotReviewRequestStatus !== "none") {
451
457
  return false;
452
458
  }
@@ -477,9 +483,14 @@ export function evaluatePrGateCoordination(input = {}) {
477
483
  const prClosed = input.prClosed === true;
478
484
  const prMerged = input.prMerged === true;
479
485
  const sameHeadCleanConverged = input.sameHeadCleanConverged === true;
480
- const reviewMode = typeof input.reviewMode === "string"
481
- ? input.reviewMode.trim().toLowerCase()
482
- : null;
486
+ // maxCopilotRounds: 0 disables the external Copilot review gate entirely
487
+ // (for repos without Copilot / local-harness-only review). It reuses the
488
+ // existing internal_only routing — skip the Copilot cycle, go straight to
489
+ // pre_approval — so no separate skip path is needed.
490
+ const copilotReviewDisabled = input.maxCopilotRounds === 0;
491
+ const reviewMode = copilotReviewDisabled
492
+ ? "internal_only"
493
+ : (typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null);
483
494
  const mergeStateStatus = normalizeMergeStateStatus(input.mergeStateStatus);
484
495
  const conflictFiles = normalizeConflictFiles(input.conflictFiles);
485
496
  const ciStatus = normalizeCiStatus(input.ciStatus);
@@ -813,7 +824,9 @@ export function evaluatePrGateCoordination(input = {}) {
813
824
  allowedNextActions,
814
825
  forbiddenActions,
815
826
  nextAction: PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
816
- reason: "This is an explicitly internal-only PR with clean draft_gate evidence and current-head clean pre_approval_gate, so it is ready for final human approval.",
827
+ reason: copilotReviewDisabled
828
+ ? "Copilot review is disabled for this repo (maxCopilotRounds: 0); with clean draft_gate evidence and current-head clean pre_approval_gate, the PR is ready for final human approval."
829
+ : "This is an explicitly internal-only PR with clean draft_gate evidence and current-head clean pre_approval_gate, so it is ready for final human approval.",
817
830
  mergeStateStatus,
818
831
  conflictFiles,
819
832
  refinementArtifact,
@@ -835,7 +848,9 @@ export function evaluatePrGateCoordination(input = {}) {
835
848
  allowedNextActions,
836
849
  forbiddenActions,
837
850
  nextAction: PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
838
- reason: "This is an explicitly internal-only PR, so `pre_approval_gate` is the next legal boundary instead of an external Copilot review cycle.",
851
+ reason: copilotReviewDisabled
852
+ ? "Copilot review is disabled for this repo (maxCopilotRounds: 0), so `pre_approval_gate` is the next legal boundary instead of an external Copilot review cycle."
853
+ : "This is an explicitly internal-only PR, so `pre_approval_gate` is the next legal boundary instead of an external Copilot review cycle.",
839
854
  mergeStateStatus,
840
855
  conflictFiles,
841
856
  refinementArtifact,
@@ -34,6 +34,27 @@ export const PI_RUN_ID_ALIAS_VAR = "PI_SUBAGENT_RUN_ID";
34
34
  /** State-file name (under `.pi/`, consistent with existing dev-loop checkpoint files). */
35
35
  export const RUN_CONTEXT_FILENAME = "dev-loop-run-context.json";
36
36
 
37
+ /**
38
+ * Env var Claude Code sets in every tool/subagent shell it spawns.
39
+ * Used as the harness signal — see `isClaudeHarness`.
40
+ */
41
+ export const CLAUDE_HARNESS_MARKER = "CLAUDECODE";
42
+
43
+ /**
44
+ * True when running under the Claude Code harness.
45
+ *
46
+ * Claude Code sets `CLAUDECODE=1` in the environment of every Bash tool and
47
+ * subagent it spawns. This is the harness-detection seam used to relax
48
+ * Pi-specific runtime contracts (e.g. the async-start contract) that do not
49
+ * apply to Claude's execution model.
50
+ *
51
+ * @param {Record<string, string|undefined>} [env]
52
+ * @returns {boolean}
53
+ */
54
+ export function isClaudeHarness(env = process.env) {
55
+ return env?.[CLAUDE_HARNESS_MARKER] === "1";
56
+ }
57
+
37
58
  /**
38
59
  * Resolve the active run id from the environment, neutral marker first.
39
60
  *
@@ -1,246 +0,0 @@
1
- /**
2
- * Shared subcommand runner for standardizing CLI script boilerplate.
3
- * Extracted per issue #548 Phase 3.
4
- *
5
- * Replaces per-script: USAGE string, parseError, arg-parsing loop,
6
- * removed-flags handling, and direct-invocation boilerplate.
7
- */
8
-
9
- import { buildParseError, isDirectCliRun } from "./helpers.mjs";
10
- import { requireOptionValue, parsePrNumber, parseIssueNumber,
11
- parsePositiveInteger, parseNonNegativeInteger } from "./primitives.mjs";
12
-
13
- /**
14
- * Option descriptor for defineSubcommand.
15
- *
16
- * @typedef {Object} CliOption
17
- * @property {string} flag - e.g. "--repo"
18
- * @property {string} [key] - override output key name; defaults to dashed→camelCase (e.g. "--head-sha" → "headSha")
19
- * @property {string} [valueName] - human-readable value name for usage (ignored for boolean flags)
20
- * @property {string} [description] - help text
21
- * @property {"string"|"number"|"boolean"|"pr"|"issue"|"positiveInt"|"nonNegativeInt"} [type] - default "string"
22
- * @property {boolean} [required] - default false
23
- * @property {*} [default] - default value
24
- * @property {string[]} [choices] - allowed values
25
- * @property {string[]} [removedAliases] - flags that should be rejected with a message
26
- */
27
-
28
- /** Convert a dashed flag name (e.g. "--head-sha") to camelCase (e.g. "headSha"). */
29
- function dashedToCamel(flag) {
30
- const stem = flag.replace(/^--/, "");
31
- return stem.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
32
- }
33
-
34
- /** Return the parsed output key for an option. Respects opt.key override. */
35
- function optionKey(opt) {
36
- if (opt.key) return opt.key;
37
- return dashedToCamel(opt.flag);
38
- }
39
-
40
- /** Render a single option's usage fragment for the generated help text. */
41
- function optionUsageFragment(opt) {
42
- if (opt.type === "boolean") return opt.flag;
43
- const vn = opt.valueName || opt.flag.replace(/^--/, "").replace(/-/g, "_").toUpperCase();
44
- return `${opt.flag} <${vn}>`;
45
- }
46
-
47
- /**
48
- * Define a subcommand with auto-generated usage, arg parsing, and help.
49
- *
50
- * @param {Object} def
51
- * @param {string} def.name - subcommand name for usage
52
- * @param {string} def.description - one-line description
53
- * @param {string} [def.longDescription] - extended help text
54
- * @param {CliOption[]} def.options - option descriptors
55
- * @param {Function} def.run - async (parsed, { args, stdout, stderr }) => exitCode
56
- * @param {Object} [def.extraUsage] - extra usage lines
57
- * @param {Object} [def.outputSchema] - stdout JSON schema description
58
- * @returns {{ parseArgs, runAsScript, usage, parseError }}
59
- */
60
- export function defineSubcommand(def) {
61
- const {
62
- name,
63
- description,
64
- longDescription = "",
65
- options = [],
66
- run,
67
- extraUsage = {},
68
- outputSchema = null,
69
- } = def;
70
-
71
- // Auto-build usage string
72
- const requiredOpts = options.filter((o) => o.required);
73
- const optionalOpts = options.filter((o) => !o.required);
74
-
75
- const usageLines = [`Usage: dev-loops ${name}`];
76
- for (const opt of requiredOpts) {
77
- usageLines.push(` ${optionUsageFragment(opt)}`);
78
- }
79
- if (optionalOpts.length > 0) {
80
- const optStrs = optionalOpts.map((o) => optionUsageFragment(o));
81
- usageLines.push(` [${optStrs.join("] [")}]`);
82
- }
83
-
84
- if (description) usageLines.push("", description);
85
- if (longDescription) usageLines.push("", longDescription);
86
-
87
- if (requiredOpts.length > 0) {
88
- usageLines.push("", "Required:");
89
- for (const opt of requiredOpts) {
90
- usageLines.push(` ${optionUsageFragment(opt)}${opt.description ? ` ${opt.description}` : ""}`);
91
- }
92
- }
93
-
94
- if (optionalOpts.length > 0) {
95
- usageLines.push("", "Optional:");
96
- for (const opt of optionalOpts) {
97
- usageLines.push(` ${optionUsageFragment(opt)}${opt.description ? ` ${opt.description}` : ""}`);
98
- }
99
- }
100
-
101
- if (extraUsage.before) usageLines.splice(1, 0, ...extraUsage.before);
102
- if (extraUsage.after) usageLines.push(...extraUsage.after);
103
-
104
- if (outputSchema) {
105
- usageLines.push("", "Output (stdout, JSON):", JSON.stringify(outputSchema, null, 2));
106
- }
107
-
108
- const usage = usageLines.join("\n");
109
- const parseError = buildParseError(usage);
110
-
111
- // Build removed-flags set
112
- const removedFlags = new Set();
113
- for (const opt of options) {
114
- if (opt.removedAliases) {
115
- for (const alias of opt.removedAliases) removedFlags.add(alias);
116
- }
117
- }
118
-
119
- function parseValue(raw, opt) {
120
- if (raw === undefined) return opt.default;
121
- switch (opt.type) {
122
- case "number": case "positiveInt": case "nonNegativeInt": {
123
- if (opt.type === "positiveInt") return parsePositiveInteger(raw, opt.flag, parseError);
124
- if (opt.type === "nonNegativeInt") return parseNonNegativeInteger(raw, opt.flag, parseError);
125
- const n = Number(raw);
126
- if (isNaN(n)) throw parseError(`${opt.flag} must be a number`);
127
- return n;
128
- }
129
- case "pr": return parsePrNumber(raw, parseError);
130
- case "issue": return parseIssueNumber(raw, parseError);
131
- case "string":
132
- default: {
133
- const v = raw.trim();
134
- if (opt.choices && !opt.choices.includes(v)) {
135
- throw parseError(`${opt.flag} must be one of: ${opt.choices.join(", ")}`);
136
- }
137
- return v;
138
- }
139
- }
140
- }
141
-
142
- function parseArgs(argv) {
143
- const args = [...argv];
144
- const parsed = {};
145
- // Initialize defaults for all options
146
- for (const opt of options) {
147
- if (opt.default !== undefined) {
148
- parsed[optionKey(opt)] = opt.default;
149
- }
150
- }
151
-
152
- while (args.length > 0) {
153
- const token = args.shift();
154
-
155
- if (token === "--help" || token === "-h") {
156
- return { help: true };
157
- }
158
-
159
- if (removedFlags.has(token)) {
160
- throw parseError(
161
- `${token} has been removed. Omit the flag.`,
162
- );
163
- }
164
-
165
- const opt = options.find((o) => o.flag === token);
166
- if (opt) {
167
- if (opt.type === "boolean") {
168
- // Boolean flags are presence-only: --flag sets true, no value required.
169
- parsed[optionKey(opt)] = true;
170
- } else {
171
- const raw = requireOptionValue(args, opt.flag, parseError);
172
- parsed[optionKey(opt)] = parseValue(raw, opt);
173
- }
174
- continue;
175
- }
176
-
177
- throw parseError(`Unknown argument: ${token}`);
178
- }
179
-
180
- // Validate option definitions
181
- for (const opt of options) {
182
- if (opt.required && opt.default !== undefined) {
183
- throw new Error(`Option ${opt.flag}: 'required' and 'default' conflict`);
184
- }
185
- }
186
-
187
- // Check required
188
- for (const opt of requiredOpts) {
189
- const key = optionKey(opt);
190
- if (parsed[key] === undefined) {
191
- throw parseError(`Missing required option: ${opt.flag}`);
192
- }
193
- }
194
-
195
- return { parsed };
196
- }
197
-
198
- async function runAsScript(scriptArgv = process.argv.slice(2)) {
199
- try {
200
- const result = parseArgs(scriptArgv);
201
- if (result.help) {
202
- process.stdout.write(`${usage}\n`);
203
- process.exitCode = 0;
204
- return;
205
- }
206
- const code = await run(result.parsed, { args: scriptArgv, usage });
207
- process.exitCode = typeof code === "number" ? code : 0;
208
- } catch (error) {
209
- const msg = error instanceof Error ? error.message : String(error);
210
- if (error instanceof Error && typeof error.usage === "string") {
211
- process.stderr.write(JSON.stringify({ ok: false, error: msg, usage: error.usage }) + "\n");
212
- } else {
213
- process.stderr.write(JSON.stringify({ ok: false, error: msg }) + "\n");
214
- }
215
- process.exitCode = 1;
216
- }
217
- }
218
-
219
- return { parseArgs, runAsScript, usage, parseError };
220
- }
221
-
222
- /**
223
- * Run a CLI function as the main entrypoint when the module is invoked directly.
224
- * Handles process.exitCode and error formatting.
225
- *
226
- * Usage: replace `if (isDirectCliRun(import.meta.url)) { ... }` with:
227
- * if (isDirectCliRun(import.meta.url)) { runAsMain(runCli); }
228
- *
229
- * @param {Function} fn - async function returning exit code or void
230
- * @param {Object} [opts]
231
- * @param {Function} [opts.formatError] - error formatter (default: JSON.stringify)
232
- */
233
- export function runAsMain(fn, { formatError } = {}) {
234
- Promise.resolve(fn()).then(
235
- (code) => { process.exitCode = typeof code === "number" ? code : 0; },
236
- (error) => {
237
- const msg = formatError
238
- ? formatError(error)
239
- : JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) });
240
- process.stderr.write(`${msg}\n`);
241
- process.exitCode = 1;
242
- },
243
- );
244
- }
245
-
246
- export { isDirectCliRun };