@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.
@@ -46,6 +46,13 @@ const GateConfig = z.strictObject({
46
46
  .min(1)
47
47
  .default(["must-fix"]),
48
48
  dynamicAngles: z.boolean().default(false),
49
+ // Additive counterpart to the subtractive dynamicAngles path (#1048): when
50
+ // true, the context-builder may also ADD catalog angles — from
51
+ // resolveAnglePool() (gates.anglePool, or else the union of the persona
52
+ // registry and this config's own configured angles) — that change-category
53
+ // heuristics recommend but that are not already in this gate's configured
54
+ // pool. Default false preserves today's subtractive-only behavior exactly.
55
+ additiveAngles: z.boolean().default(false),
49
56
  });
50
57
 
51
58
  const GatesConfig = z.strictObject({
@@ -65,6 +72,16 @@ const GatesConfig = z.strictObject({
65
72
  // true (opt-out): a clean gate verdict requires fan-out/fan-in evidence
66
73
  // unless explicitly disabled. See docs/gate-review-sub-loop-contract.md.
67
74
  requireFanoutEvidence: z.boolean().default(true),
75
+ // Fail-closed enforcement that a fanout_fanin gate verdict carries recorded,
76
+ // internally-consistent fan-out *provenance* (distinct reviewer count +
77
+ // per-angle dispatch). This RAISES THE BAR against a single agent self-producing
78
+ // every artifact but does NOT prove independence — provenance is self-reported,
79
+ // so it remains forgeable; un-forgeable recording is the Pi-harness bridge (see
80
+ // the honest caveat in docs/gate-review-sub-loop-contract.md). Layered ON TOP of
81
+ // requireFanoutEvidence — only takes effect when fan-out evidence enforcement
82
+ // is active. Default false (opt-in): closing this loophole is additive and
83
+ // does not change behavior for existing ledgers that carry no provenance.
84
+ requireFanoutProvenance: z.boolean().default(false),
68
85
  // Cap on how many scoped `review` reviewers the gate fan-out spawns in
69
86
  // parallel. When the resolved angle set exceeds this cap, the overflow runs
70
87
  // in sequential batches and the degradation is recorded in the gate evidence.
@@ -75,6 +92,12 @@ const GatesConfig = z.strictObject({
75
92
  // suppresses the PR comment when explicitly false. See
76
93
  // docs/gate-review-sub-loop-contract.md.
77
94
  postFindingsComments: z.boolean().default(true),
95
+ // Explicit global lens catalog override for additive angle selection
96
+ // (gates.<gate>.additiveAngles, #1048). When absent, resolveAnglePool()
97
+ // falls back to the union of the built-in persona registry's angle names
98
+ // and every angle configured across this config's own draft/preApproval/
99
+ // spike gates (angles + mandatoryAngles).
100
+ anglePool: z.array(z.string().trim().min(1)).optional(),
78
101
  });
79
102
 
80
103
  const AutonomyConfig = z.strictObject({
@@ -90,7 +113,7 @@ const AutonomyConfig = z.strictObject({
90
113
 
91
114
  /**
92
115
  * Human-handoff config (#920, Request B of #910): at the pre-approval /
93
- * merge-handoff boundary, OFFER to assign the PR to a named human
116
+ * merge-handoff boundary, OFFER to assign the PR to a contributor
94
117
  * reviewer/assignee. Opt-in (default off). Pairs with autonomy.humanMergeOnly.
95
118
  * `candidatesFrom` selects which sources the resolver queries; `assignees` is a
96
119
  * static highest-priority candidate list. Absent/empty = disabled no-op.
@@ -110,12 +133,6 @@ const ApprovalConfig = z.strictObject({
110
133
  const WorkflowConfig = z.strictObject({
111
134
  asyncStartMode: z.enum(["required", "allowed"]).default("required"),
112
135
  requireRetrospective: z.boolean(),
113
- requireRetrospectiveGate: z.boolean().default(false),
114
- // Developer-mode retro step (#982): enforce internal-tooling-only execution
115
- // (no agent-level raw gh/python/node -e) in the retrospective gate. This is the
116
- // dev-loops maintainers' own dogfooding discipline — opt-in, default OFF so
117
- // consumers of the extension are never blocked by it.
118
- requireRetrospectiveInternalTooling: z.boolean().default(false),
119
136
  requireDraftFirst: z.boolean(),
120
137
  devModeDefault: z.boolean(),
121
138
  });
@@ -151,16 +168,6 @@ const WorktreeConfig = z.strictObject({
151
168
  linkOnInit: z.array(z.string().trim().min(1)).optional(),
152
169
  });
153
170
 
154
- /**
155
- * Local-planning config (#949): where persisted markdown plan files (phase-doc
156
- * format) live when work originates from a plan file rather than a tracker
157
- * issue. `plansDir` is a repo-relative directory; defaults to the existing
158
- * phase-docs directory. See skills/docs/plan-file-contract.md.
159
- */
160
- const LocalPlanningConfig = z.strictObject({
161
- plansDir: z.string().trim().min(1).optional(),
162
- });
163
-
164
171
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
165
172
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
166
173
 
@@ -182,8 +189,10 @@ const FileGatesConfig = z.strictObject({
182
189
  preApproval: FileGateConfig.optional(),
183
190
  spike: FileGateConfig.optional(),
184
191
  requireFanoutEvidence: z.boolean().optional(),
192
+ requireFanoutProvenance: z.boolean().optional(),
185
193
  maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
186
194
  postFindingsComments: z.boolean().optional(),
195
+ anglePool: z.array(z.string().trim().min(1)).optional(),
187
196
  });
188
197
 
189
198
  // Partial persona entries for file-level config (allows omitting fields)
@@ -212,7 +221,9 @@ export const DevLoopConfigSchema = z.strictObject({
212
221
  personas: PersonasConfig.optional(),
213
222
  internalPathPatterns: InternalPatternsConfig.optional(),
214
223
  worktree: WorktreeConfig.optional(),
215
- localPlanning: LocalPlanningConfig.optional(),
224
+ // Deprecated (removed in #1088): tolerated so consumer .devloops files that
225
+ // still carry a localPlanning block keep parsing. Accepted, never read.
226
+ localPlanning: z.unknown().optional(),
216
227
  });
217
228
 
218
229
  // ============================================================================
@@ -221,7 +232,7 @@ export const DevLoopConfigSchema = z.strictObject({
221
232
 
222
233
  export const BUILT_IN_DEFAULTS = Object.freeze({
223
234
  version: 1,
224
- strategy: Object.freeze({ default: "github-first" }),
235
+ strategy: Object.freeze({ default: "local-first" }),
225
236
  inputSource: Object.freeze({ default: "tracker" }),
226
237
  models: Object.freeze({}),
227
238
  refinement: Object.freeze({ fanOut: 3, mode: "parallel", maxCopilotRounds: 5, stopOnLowSignal: false, lowSignalRoundThreshold: 3, lowSignalMaxComments: 2 }),
@@ -237,8 +248,6 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
237
248
  workflow: Object.freeze({
238
249
  asyncStartMode: "required",
239
250
  requireRetrospective: false,
240
- requireRetrospectiveGate: false,
241
- requireRetrospectiveInternalTooling: false,
242
251
  requireDraftFirst: false,
243
252
  devModeDefault: false,
244
253
  }),
@@ -263,7 +272,6 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
263
272
  "^test/",
264
273
  ]),
265
274
  worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
266
- localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
267
275
  });
268
276
 
269
277
  // ============================================================================
@@ -285,7 +293,9 @@ export const FileConfigSchema = z.strictObject({
285
293
  personas: FilePersonasConfig.optional(),
286
294
  internalPathPatterns: InternalPatternsConfig.optional(),
287
295
  worktree: WorktreeConfig.partial().optional(),
288
- localPlanning: LocalPlanningConfig.partial().optional(),
296
+ // Deprecated (removed in #1088): tolerated so consumer .devloops files that
297
+ // still carry a localPlanning block keep parsing. Accepted, never read.
298
+ localPlanning: z.unknown().optional(),
289
299
  });
290
300
 
291
301
  // ============================================================================
@@ -943,7 +953,7 @@ export function resolveRefinement(config) {
943
953
  *
944
954
  * @param {DevLoopConfig} config
945
955
  * @param {"draft"|"preApproval"|"spike"} gate
946
- * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
956
+ * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean }}
947
957
  */
948
958
  export function resolveGateConfig(config, gate) {
949
959
  const gateConfig = config?.gates?.[gate];
@@ -960,6 +970,7 @@ export function resolveGateConfig(config, gate) {
960
970
  required: gateConfig?.required ?? true,
961
971
  requireCi: gateConfig?.requireCi ?? true,
962
972
  dynamicAngles: gateConfig?.dynamicAngles ?? false,
973
+ additiveAngles: gateConfig?.additiveAngles ?? false,
963
974
  blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
964
975
  ? [...gateConfig.blockCleanOnFindingSeverities]
965
976
  : ["must-fix"],
@@ -984,28 +995,30 @@ export function resolveRequireFanoutEvidence(config) {
984
995
  return config?.gates?.requireFanoutEvidence !== false;
985
996
  }
986
997
 
987
- /** Default parallel fan-out reviewer cap (mirrors GatesConfig.maxFanoutReviewers). */
988
- export const DEFAULT_MAX_FANOUT_REVIEWERS = 8;
998
+ /**
999
+ * Minimum distinct reviewer count for a fanout_fanin ledger to satisfy
1000
+ * requireFanoutProvenance. A floor of 2 is the smallest count that is not a
1001
+ * single agent; it raises the bar but does not prove independence (provenance
1002
+ * is self-reported — see the honest caveat in
1003
+ * docs/gate-review-sub-loop-contract.md).
1004
+ */
1005
+ export const FANOUT_PROVENANCE_MIN_REVIEWERS = 2;
989
1006
 
990
1007
  /**
991
- * Resolve the parallel fan-out reviewer cap for the gate sub-loop.
1008
+ * Resolve whether fan-out *provenance* is required for a fanout_fanin gate
1009
+ * verdict (distinct reviewer count + per-angle dispatch recorded in the ledger).
992
1010
  *
993
- * Returns the configured `gates.maxFanoutReviewers` when it is an integer in
994
- * the schema-bounded range 1..64; otherwise the built-in default (8). Clamping
995
- * here (not just the Zod schema) keeps programmatically-constructed config
996
- * objects that bypass schema validation within the same bound. The fan-out
997
- * spawns at most this many scoped `review` reviewers in parallel; overflow runs
998
- * sequentially.
1011
+ * Default-OFF (opt-in): unlike resolveRequireFanoutEvidence, this uses a strict
1012
+ * `=== true` test so behavior is byte-identical to today unless a repo
1013
+ * explicitly opts in via `gates.requireFanoutProvenance: true`. Layered on top
1014
+ * of fan-out evidence enforcement (see buildFanoutEnforcement). See
1015
+ * docs/gate-review-sub-loop-contract.md.
999
1016
  *
1000
1017
  * @param {DevLoopConfig} config
1001
- * @returns {number}
1018
+ * @returns {boolean}
1002
1019
  */
1003
- export function resolveMaxFanoutReviewers(config) {
1004
- const raw = config?.gates?.maxFanoutReviewers;
1005
- if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 64) {
1006
- return raw;
1007
- }
1008
- return DEFAULT_MAX_FANOUT_REVIEWERS;
1020
+ export function resolveRequireFanoutProvenance(config) {
1021
+ return config?.gates?.requireFanoutProvenance === true;
1009
1022
  }
1010
1023
 
1011
1024
  /**
@@ -1048,6 +1061,60 @@ export function resolveLightMode(config) {
1048
1061
  };
1049
1062
  }
1050
1063
 
1064
+ /** Label that forces full fan-out regardless of change size. */
1065
+ export const GATE_FULL_LABEL = "gate:full";
1066
+
1067
+ /**
1068
+ * Decide whether a gate should run as a single-agent inline check or the full
1069
+ * fan-out, from light-mode config + authoritative PR facts.
1070
+ *
1071
+ * Precedence (first match wins):
1072
+ * 1. `gate:full` label present → full_fanout (label override)
1073
+ * 2. light mode disabled / no threshold → full_fanout (light mode off)
1074
+ * 3. scope over threshold (files OR lines) → full_fanout (over threshold)
1075
+ * 4. inline check produced a finding whose severity is in the gate's
1076
+ * blockCleanOnFindingSeverities set → full_fanout (escalated)
1077
+ * 5. otherwise → inline
1078
+ *
1079
+ * Two call phases share this one function:
1080
+ * - pre-check: omit `inlineFindingSeverities` (undefined) → decides whether to
1081
+ * run the inline pass at all.
1082
+ * - escalation: pass the inline pass's finding severities → auto-escalates when
1083
+ * the inline check surfaced anything worth fixing.
1084
+ *
1085
+ * Absent or partial `facts.scope` fails safe to full_fanout (missing
1086
+ * filesChanged/linesChanged are treated as `Infinity` → over threshold).
1087
+ *
1088
+ * @param {DevLoopConfig} config
1089
+ * @param {"draft"|"preApproval"} gate
1090
+ * @param {object} facts
1091
+ * @param {{ filesChanged?: number, linesChanged?: number }} [facts.scope] PR scope; absent/partial fields fail safe to full_fanout
1092
+ * @param {boolean} [facts.hasFullLabel] `gate:full` label present on the PR
1093
+ * @param {string[]} [facts.inlineFindingSeverities] severities from the inline pass (escalation phase)
1094
+ * @returns {{ mode: "inline"|"full_fanout", reason: string, threshold: {maxFiles:number,maxLines:number}|null }}
1095
+ */
1096
+ export function resolveGateDispatchMode(config, gate, { scope, hasFullLabel = false, inlineFindingSeverities } = {}) {
1097
+ if (hasFullLabel) {
1098
+ return { mode: "full_fanout", reason: "gate_full_label", threshold: null };
1099
+ }
1100
+ const threshold = resolveLightMode(config);
1101
+ if (!threshold) {
1102
+ return { mode: "full_fanout", reason: "light_mode_disabled", threshold: null };
1103
+ }
1104
+ const filesChanged = Number(scope?.filesChanged ?? Infinity);
1105
+ const linesChanged = Number(scope?.linesChanged ?? Infinity);
1106
+ if (filesChanged > threshold.maxFiles || linesChanged > threshold.maxLines) {
1107
+ return { mode: "full_fanout", reason: "over_threshold", threshold };
1108
+ }
1109
+ if (Array.isArray(inlineFindingSeverities) && inlineFindingSeverities.length > 0) {
1110
+ const blocking = new Set(resolveGateConfig(config, gate).blockCleanOnFindingSeverities);
1111
+ if (inlineFindingSeverities.some((s) => blocking.has(s))) {
1112
+ return { mode: "full_fanout", reason: "escalated", threshold };
1113
+ }
1114
+ }
1115
+ return { mode: "inline", reason: "under_threshold", threshold };
1116
+ }
1117
+
1051
1118
  /**
1052
1119
  * Resolve review angles for a specific gate from the merged dev-loop config.
1053
1120
  *
@@ -1068,6 +1135,32 @@ export function resolveGateAngles(config, gate) {
1068
1135
  return merged.filter(a => !excluded.has(a));
1069
1136
  }
1070
1137
 
1138
+ /**
1139
+ * Resolve the global lens catalog available for additive angle selection.
1140
+ *
1141
+ * Returns the explicit `gates.anglePool` override when configured (non-empty
1142
+ * array of trimmed strings). Otherwise falls back to the union of all known
1143
+ * review angles: the built-in persona registry's angle names, plus every
1144
+ * angle actually configured across this config's own draft/preApproval/spike
1145
+ * gates (angles + mandatoryAngles). The persona registry alone omits angles
1146
+ * that ship in extension-defaults.yaml gate pools but have no dedicated
1147
+ * persona (e.g. ci-guard, link-check) — see #1048.
1148
+ *
1149
+ * @param {DevLoopConfig} config
1150
+ * @returns {string[]}
1151
+ */
1152
+ export function resolveAnglePool(config) {
1153
+ const explicit = config?.gates?.anglePool;
1154
+ if (Array.isArray(explicit) && explicit.length > 0) {
1155
+ return [...new Set(explicit.map(a => (typeof a === "string" ? a.trim() : "")).filter(a => a.length > 0))];
1156
+ }
1157
+ const configured = ["draft", "preApproval", "spike"].flatMap((gate) => {
1158
+ const gateConfig = resolveGateConfig(config, gate);
1159
+ return [...(gateConfig.angles ?? []), ...gateConfig.mandatoryAngles];
1160
+ });
1161
+ return [...new Set([...Object.keys(BUILTIN_PERSONAS), ...configured])];
1162
+ }
1163
+
1071
1164
  /**
1072
1165
  * Resolve gate angles dynamically when `dynamicAngles` is enabled in config.
1073
1166
  *
@@ -1077,17 +1170,23 @@ export function resolveGateAngles(config, gate) {
1077
1170
  * When `dynamicAngles` is disabled (default), returns the full configured
1078
1171
  * angle list (same as `resolveGateAngles`).
1079
1172
  *
1173
+ * When `additiveAngles` is also enabled (default off, see #1048), catalog
1174
+ * angles from `resolveAnglePool()` (`gates.anglePool`, or else the union of
1175
+ * the persona registry and this config's own configured angles) recommended
1176
+ * by change-category heuristics but absent from the gate's configured pool
1177
+ * may also be added; `excludeAngles` remains a hard ceiling on additions.
1178
+ *
1080
1179
  * @param {import("./types.js").DevLoopConfig} config
1081
1180
  * @param {"draft"|"preApproval"} gate
1082
1181
  * @param {object} [options]
1083
1182
  * @param {{ nameStatusOutput: string, diffOutput?: string }} [options.diff]
1084
- * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean }}
1183
+ * @returns {{ recommendedAngles: string[] | null, skippedAngles: string[], reasons: Record<string,string>, fallbackToAll: boolean, dynamicAnglesActive: boolean, addedAngles: string[], addedReasons: Record<string,string> }}
1085
1184
  */
1086
1185
  export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1087
1186
  const gateConfig = resolveGateConfig(config, gate);
1088
1187
  const staticAngles = resolveGateAngles(config, gate);
1089
1188
  if (staticAngles === null) {
1090
- return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false };
1189
+ return { recommendedAngles: null, skippedAngles: [], reasons: {}, fallbackToAll: false, dynamicAnglesActive: false, addedAngles: [], addedReasons: {} };
1091
1190
  }
1092
1191
 
1093
1192
  if (!gateConfig.dynamicAngles || !diff) {
@@ -1097,6 +1196,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1097
1196
  reasons: {},
1098
1197
  fallbackToAll: false,
1099
1198
  dynamicAnglesActive: false,
1199
+ addedAngles: [],
1200
+ addedReasons: {},
1100
1201
  };
1101
1202
  }
1102
1203
 
@@ -1114,17 +1215,35 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1114
1215
 
1115
1216
  const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
1116
1217
 
1218
+ // excludeAngles is a hard ceiling: computed once and reused both to cap the
1219
+ // additive anglePool and to filter mandatoryAngles below.
1220
+ const excluded = new Set(gateConfig.excludeAngles);
1221
+ const anglePool = gateConfig.additiveAngles
1222
+ ? resolveAnglePool(config).filter(a => !excluded.has(a))
1223
+ : undefined;
1224
+
1117
1225
  const { resolveDynamicAngles: resolve } = await import("../analysis/change-classifier.mjs");
1118
1226
  const dynamicResult = resolve({
1119
1227
  configuredAngles: candidatePool,
1120
1228
  changeCategories: categories,
1121
1229
  ambiguous: analysis.ambiguous,
1230
+ anglePool,
1122
1231
  });
1123
1232
 
1124
- // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected candidates
1125
- const excluded = new Set(gateConfig.excludeAngles);
1233
+ // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
1234
+ // candidates + additively-selected catalog angles (#1048)
1126
1235
  const filteredMandatory = gateConfig.mandatoryAngles.filter(a => !excluded.has(a));
1127
- const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles])];
1236
+
1237
+ // An angle that is both mandatory AND additively recommended must stay
1238
+ // attributed to the mandatory floor, not be reported as "added" — the
1239
+ // resolver has no concept of "mandatory", so the caller (this function,
1240
+ // which already owns the mandatory Set) filters its output.
1241
+ const addedAngles = (dynamicResult.addedAngles ?? []).filter(a => !mandatory.has(a));
1242
+ const addedReasons = Object.fromEntries(
1243
+ Object.entries(dynamicResult.addedReasons ?? {}).filter(([a]) => !mandatory.has(a))
1244
+ );
1245
+
1246
+ const recommendedAngles = [...new Set([...filteredMandatory, ...dynamicResult.recommendedAngles, ...addedAngles])];
1128
1247
 
1129
1248
  return {
1130
1249
  recommendedAngles,
@@ -1132,6 +1251,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1132
1251
  reasons: dynamicResult.reasons,
1133
1252
  fallbackToAll: dynamicResult.fallbackToAll,
1134
1253
  dynamicAnglesActive: true,
1254
+ addedAngles,
1255
+ addedReasons,
1135
1256
  };
1136
1257
  }
1137
1258
 
@@ -1142,7 +1263,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
1142
1263
  * for the requested key.
1143
1264
  *
1144
1265
  * @param {DevLoopConfig} config
1145
- * @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireRetrospectiveInternalTooling"|"requireDraftFirst"|"devModeDefault"} key
1266
+ * @param {"asyncStartMode"|"requireRetrospective"|"requireDraftFirst"|"devModeDefault"} key
1146
1267
  * @returns {string|boolean}
1147
1268
  */
1148
1269
  export function resolveWorkflowConfig(config, key) {
@@ -1154,14 +1275,6 @@ export function resolveWorkflowConfig(config, key) {
1154
1275
  return config?.workflow?.requireRetrospective ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospective;
1155
1276
  }
1156
1277
 
1157
- if (key === "requireRetrospectiveGate") {
1158
- return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
1159
- }
1160
-
1161
- if (key === "requireRetrospectiveInternalTooling") {
1162
- return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
1163
- }
1164
-
1165
1278
  if (key === "requireDraftFirst") {
1166
1279
  return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
1167
1280
  }
@@ -1173,20 +1286,6 @@ export function resolveWorkflowConfig(config, key) {
1173
1286
  throw new Error(`Unknown workflow config key: ${key}`);
1174
1287
  }
1175
1288
 
1176
- const DEFAULT_INTERNAL_PATH_PATTERNS = BUILT_IN_DEFAULTS.internalPathPatterns;
1177
-
1178
- /**
1179
- * Resolve the internal path patterns from the merged dev-loop config.
1180
- *
1181
- * Returns an array of regex pattern strings used by detect-internal-only-pr.mjs
1182
- * to classify files as internal tooling (vs consumer-facing). When the config
1183
- * omits this section, returns the built-in shipped defaults.
1184
- *
1185
- * Consumers can override these in .devloops at repo root.
1186
- *
1187
- * @param {DevLoopConfig} config
1188
- * @returns {string[]}
1189
- */
1190
1289
  /**
1191
1290
  * Resolve the worktree lifecycle config from the merged dev-loop config.
1192
1291
  *
@@ -1207,24 +1306,6 @@ export function resolveWorktreeConfig(config) {
1207
1306
  return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1208
1307
  }
1209
1308
 
1210
- /**
1211
- * Resolve the local-planning plans directory from the merged dev-loop config.
1212
- *
1213
- * Returns the configured `localPlanning.plansDir` (trimmed) when present and
1214
- * non-empty, otherwise the built-in default (`docs/phases/`) — the existing
1215
- * phase-docs directory. See skills/docs/plan-file-contract.md.
1216
- *
1217
- * @param {DevLoopConfig} config
1218
- * @returns {string}
1219
- */
1220
- export function resolvePlansDir(config) {
1221
- const raw = config?.localPlanning?.plansDir;
1222
- if (typeof raw === "string" && raw.trim().length > 0) {
1223
- return raw.trim();
1224
- }
1225
- return BUILT_IN_DEFAULTS.localPlanning.plansDir;
1226
- }
1227
-
1228
1309
  /**
1229
1310
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1230
1311
  *
@@ -1259,14 +1340,3 @@ export function resolveHumanHandoffConfig(config) {
1259
1340
  assignees: enabled ? assignees : [],
1260
1341
  };
1261
1342
  }
1262
-
1263
- export function resolveInternalPathPatterns(config) {
1264
- if (
1265
- config?.internalPathPatterns &&
1266
- Array.isArray(config.internalPathPatterns) &&
1267
- config.internalPathPatterns.length > 0
1268
- ) {
1269
- return [...config.internalPathPatterns];
1270
- }
1271
- return [...DEFAULT_INTERNAL_PATH_PATTERNS];
1272
- }
@@ -100,12 +100,6 @@ workflow:
100
100
  # default (DEFAULT_WORKFLOW_CONFIG) and the contract. The dev-loops repo opts in via its own
101
101
  # repo-root .devloops, which takes precedence over these extension defaults.
102
102
  requireRetrospective: false
103
- requireRetrospectiveGate: false
104
- # Internal-tooling-only retro check (#982) is a DEVELOPER-MODE step — the dev-loops
105
- # maintainers' own dogfooding discipline. It must never block a consumer's state
106
- # changes (consumers may legitimately use raw gh/python/node -e), so it ships OFF.
107
- # The dev-loops repo opts in via its own repo-root .devloops (takes precedence here).
108
- requireRetrospectiveInternalTooling: false
109
103
  requireDraftFirst: true
110
104
  # Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
111
105
  # after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
@@ -113,11 +107,6 @@ workflow:
113
107
  # via its own repo-root .devloops (which takes precedence over these extension defaults).
114
108
  devModeDefault: false
115
109
 
116
- # Local-planning: where persisted markdown plan files (phase-doc format) live
117
- # when work originates from a plan file rather than a tracker issue (#949).
118
- localPlanning:
119
- plansDir: docs/phases/
120
-
121
110
  # Light-mode threshold for small local changes.
122
111
  localImplementation:
123
112
  lightMode:
@@ -30,6 +30,7 @@
30
30
  * @property {string} cwd - Working directory for the current invocation.
31
31
  * @property {boolean} hasUI - Whether an interactive UI surface is attached.
32
32
  * @property {HarnessUi} ui - UI operations for this invocation.
33
+ * @property {((message: string, options?: Record<string, unknown>) => unknown) | undefined} sendUserMessage - Optional: send a user-turn message into the harness (Pi extension only; absent in other harnesses).
33
34
  *
34
35
  * @typedef {'session_start'|'tool_result'|'user_bash'|'agent_end'} HarnessLifecycleEvent
35
36
  *
@@ -2,4 +2,3 @@ export { createHarnessAdapter, isHarnessAdapter } from "./adapter.mjs";
2
2
  export { createPiAdapter } from "./pi-adapter.mjs";
3
3
  export { createNoopAdapter } from "./noop-adapter.mjs";
4
4
  export { createExtensionHarnessAdapter } from "./extension-adapter.mjs";
5
- export { createClaudeExtensionAdapter } from "./claude-extension-adapter.mjs";
@@ -13,6 +13,7 @@
13
13
  * Async context marker (required when workflow.asyncStartMode is `required`)
14
14
  * — see `@dev-loops/core/loop/run-context`:
15
15
  * - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
16
+ * - PI_SUBAGENT_RUN_ID env var (the alias the Pi runtime injects)
16
17
  *
17
18
  * Allowed modes:
18
19
  * - workflow.asyncStartMode: required | allowed
@@ -159,14 +160,20 @@ export function validateAsyncStartContext({
159
160
  };
160
161
  }
161
162
 
162
- // No marker found — fail closed
163
+ // No marker found — fail closed.
164
+ // Derive the marker hint from ASYNC_CONTEXT_MARKERS (primary first, aliases after)
165
+ // so the message never drifts from the recognized-marker list.
166
+ const [primaryMarker, ...aliasMarkers] = ASYNC_CONTEXT_MARKERS;
167
+ const markerHint = aliasMarkers.length
168
+ ? `Set ${primaryMarker} (or the ${aliasMarkers.join("/")} alias) to proceed. `
169
+ : `Set ${primaryMarker} to proceed. `;
163
170
  return {
164
171
  status: ASYNC_START_STATUS.REJECTED,
165
172
  reason:
166
173
  "No async context detected. " +
167
174
  "The dev-loop must run within a visible async subagent session, " +
168
175
  "not as a detached local process. " +
169
- `Set ${ASYNC_CONTEXT_MARKERS[0]} to proceed. ` +
176
+ markerHint +
170
177
  "Repository-maintained workflow policy controls any exceptions.",
171
178
  detectedMarker: null,
172
179
  };