@dev-loops/core 1.0.2-pre.0 → 1.0.2-slim.0

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.
@@ -1,24 +1,10 @@
1
1
  /**
2
2
  * Deterministic mid-flight operator steering contract for active dev loops.
3
3
  *
4
- * This module provides:
5
- * - STEERING_KIND: stable steering kind constants
6
- * - STEERING_RESULT: acknowledgement/result constants
7
- * - SAFE_POINT_CATEGORY: safe-point classification constants
8
- * - normalizeSteeringEvent: validate and canonicalize a raw steering event
9
- * - normalizeSteeringState: load and validate persisted steering state
10
- * - createSteeringState: create a fresh steering state for a new run
11
- * - classifySafePoint: map a copilot loop state to a safe-point category
12
- * - submitSteering: process a steering event against current run state
13
- * - promoteQueuedSteering: apply queued steering when the loop reaches a safe point
14
- * - getEffectiveConstraints: get the current effective steering constraints
15
- * - resolveEffectiveLoopState: get loop interpretation augmented with active steering
16
- * - getSteeringStatus: get full inspection output for a run's steering state
17
- *
18
4
  * The proving target for this first implementation slice is the async Copilot
19
- * review/fix loop (copilot-loop-state.mjs). Safe-point rules are defined for
20
- * that loop's state set and the resolveEffectiveLoopState integration changes
21
- * loop behavior when stop_at_next_safe_gate steering is active.
5
+ * review/fix loop (copilot-loop-state.mjs): safe-point rules are defined for
6
+ * that loop's state set, and resolveEffectiveLoopState changes loop behavior
7
+ * when stop_at_next_safe_gate steering is active.
22
8
  */
23
9
 
24
10
  import { normalizeRepoSlug } from "../github/repo-slug.mjs";
@@ -74,46 +60,30 @@ const VALID_APPLY_MODES = new Set(["immediate", "next_safe_point"]);
74
60
  // ---------------------------------------------------------------------------
75
61
 
76
62
  /**
77
- * Map a copilot loop state to a safe-point category.
78
- *
79
- * Safe-point rules for the async Copilot review/fix loop:
80
- *
81
- * IMMEDIATE — between steps / idle / waiting on external state.
82
- * Steering can be applied right now without risk of splitting a mutation.
83
- * States: pr_ready_no_feedback, waiting_for_copilot_review,
84
- * waiting_for_ci, ready_to_rerequest_review
85
- *
86
- * NEXT_POINT — actively computing or in a non-interruptible mutation.
87
- * Applying steering now could produce a half-applied or inconsistent state.
88
- * Queue the event and promote it when the loop next reaches an IMMEDIATE state.
89
- * States: pr_draft, unresolved_feedback_present, already_fixed_needs_reply_resolve
90
- *
91
- * TERMINAL — run is done, irreversibly failed, or has no active run.
92
- * Steering is rejected; it would have no effect or could mask a real error.
93
- * States: no_pr, done, review_request_unavailable, blocked_needs_user_decision
63
+ * Map a copilot loop state to a safe-point category (async Copilot
64
+ * review/fix loop): IMMEDIATE states can apply steering right now; NEXT_POINT
65
+ * states are mid-mutation and queue the event for the next IMMEDIATE state;
66
+ * TERMINAL states reject steering (no active/recoverable run).
94
67
  *
95
68
  * @param {string} loopState - a copilot loop STATE value
96
69
  * @returns {"immediate"|"next_point"|"terminal"}
97
70
  */
98
71
  export function classifySafePoint(loopState) {
99
72
  switch (loopState) {
100
- // Between steps / idle
73
+ // Idle / waiting on external state.
101
74
  case STATE.PR_READY_NO_FEEDBACK:
102
75
  case STATE.READY_TO_REREQUEST_REVIEW:
103
- // Waiting on external state
104
76
  case STATE.WAITING_FOR_COPILOT_REVIEW:
105
77
  case STATE.WAITING_FOR_CI:
106
78
  return SAFE_POINT_CATEGORY.IMMEDIATE;
107
79
 
108
- // In a pre-ready state (not yet at a mutation gate)
80
+ // Actively computing / non-interruptible mutation.
109
81
  case STATE.PR_DRAFT:
110
- // Actively computing — about to apply fixes to resolve feedback
111
82
  case STATE.UNRESOLVED_FEEDBACK_PRESENT:
112
- // In the middle of a non-interruptible mutation (reply/resolve review threads)
113
83
  case STATE.ALREADY_FIXED_NEEDS_REPLY_RESOLVE:
114
84
  return SAFE_POINT_CATEGORY.NEXT_POINT;
115
85
 
116
- // Terminal states: run is done, error, or has no active run
86
+ // Terminal: run done, errored, or no active run.
117
87
  case STATE.NO_PR:
118
88
  case STATE.DONE:
119
89
  case STATE.REVIEW_REQUEST_UNAVAILABLE:
@@ -418,20 +388,9 @@ function hasStopAtNextSafeGate(events) {
418
388
 
419
389
  /**
420
390
  * Process a steering event against current run state and produce an
421
- * acknowledgement/result plus updated steering state.
422
- *
423
- * This is the main entry point for operators submitting mid-flight corrections.
424
- *
425
- * Result semantics:
426
- * - applied_now: event is immediately effective; effectiveStack is updated.
427
- * - queued_for_safe_point: loop is in a non-safe state; event is queued and
428
- * will be promoted by promoteQueuedSteering when the loop reaches a safe point.
429
- * - rejected_unsafe_now: terminal loop state (done/unavailable/no_pr); steering
430
- * would have no effect or could mask a real issue.
431
- * - rejected_invalid_or_conflicting: event is malformed, has an out-of-order seq,
432
- * or exactly duplicates an existing hard_constraint.
433
- * - needs_human_decision: loop is in blocked_needs_user_decision; human must act
434
- * before automated steering can be safely applied.
391
+ * acknowledgement/result plus updated steering state. Main entry point for
392
+ * operators submitting mid-flight corrections; see {@link STEERING_RESULT}
393
+ * for the possible outcomes.
435
394
  *
436
395
  * @param {object} event - normalized steering event (from normalizeSteeringEvent)
437
396
  * @param {object} steeringState - current steering state (from normalizeSteeringState)
@@ -719,7 +678,7 @@ export function getEffectiveConstraints(steeringState) {
719
678
  * @param {object} snapshot - raw or normalized loop snapshot
720
679
  * @param {object} steeringState - current steering state for this run
721
680
  * @param {object} [refinementConfig] - interpreter refinement config; pass a config-derived
722
- * `resolveRefinement(config)` so the base interpretation honors gates.preApproval.requireCi:false (#1337).
681
+ * `resolveRefinement(config)` so the base interpretation honors gates.preApproval.requireCi:false.
723
682
  * @returns {{ state: string, allowedTransitions: string[], nextAction: string, steeringApplied: boolean, pendingStopAtNextSafeGate: boolean, terminalStopAtNextSafeGate: boolean, effectiveConstraints: object }}
724
683
  */
725
684
  export function resolveEffectiveLoopState(snapshot, steeringState, refinementConfig) {
@@ -760,19 +719,8 @@ export function resolveEffectiveLoopState(snapshot, steeringState, refinementCon
760
719
  // ---------------------------------------------------------------------------
761
720
 
762
721
  /**
763
- * Get full inspection output for a run's steering state.
764
- *
765
- * Returns:
766
- * - runId: the target run
767
- * - schemaVersion: for migration awareness
768
- * - eventCount: total events submitted
769
- * - queuedCount: events waiting for the next safe point
770
- * - effectiveStackCount: events currently in effect
771
- * - effectiveConstraints: structured view over the effective stack
772
- * - latestResult: most recent acknowledgement/result
773
- * - resultHistory: all historical acknowledgements
774
- * - history: all submitted events
775
- * - nextSeq: next expected sequence number
722
+ * Get full inspection output for a run's steering state (event counts,
723
+ * effective constraints, result history, and next expected seq).
776
724
  *
777
725
  * @param {object} steeringState
778
726
  * @returns {object}
@@ -1,77 +1,14 @@
1
1
  import { runChild as _runChild } from "../cli/primitives.mjs";
2
2
  import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
3
- import { ghGraphql, resolveOwner } from "../github/gh.mjs";
3
+ import { resolveOwner } from "../github/gh.mjs";
4
+ import { validateProjectsRepo, discoverProjects, listProjectFields, paginateNodes, extractStatus } from "./projects-access.mjs";
4
5
 
5
6
  // ── Validation ───────────────────────────────────────────────────────────
6
7
 
7
- const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
8
- const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
9
-
10
- function validateRepo(repo) {
11
- if (!repo || typeof repo !== "string") {
12
- throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
13
- }
14
- const trimmed = repo.trim();
15
- if (trimmed !== repo) {
16
- throw Object.assign(
17
- new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`),
18
- { code: "INVALID_REPO" },
19
- );
20
- }
21
- const slashIdx = repo.indexOf("/");
22
- if (slashIdx === -1) {
23
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
24
- }
25
- const owner = repo.slice(0, slashIdx);
26
- const name = repo.slice(slashIdx + 1);
27
- if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
28
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
29
- }
30
- return repo;
31
- }
8
+ const validateRepo = validateProjectsRepo;
32
9
 
33
10
  // ── GraphQL fragments ────────────────────────────────────────────────────
34
11
 
35
- const LIST_USER_PROJECTS = [
36
- "query($login:String!, $after:String) {",
37
- " user(login:$login) {",
38
- " projectsV2(first:50, after:$after) {",
39
- " pageInfo { hasNextPage endCursor }",
40
- " nodes { id number title url }",
41
- " }",
42
- " }",
43
- "}"
44
- ].join("\n");
45
-
46
- const LIST_ORG_PROJECTS = [
47
- "query($login:String!, $after:String) {",
48
- " organization(login:$login) {",
49
- " projectsV2(first:50, after:$after) {",
50
- " pageInfo { hasNextPage endCursor }",
51
- " nodes { id number title url }",
52
- " }",
53
- " }",
54
- "}"
55
- ].join("\n");
56
-
57
- const GET_PROJECT_FIELDS = [
58
- "query($projectId:ID!, $after:String) {",
59
- " node(id:$projectId) {",
60
- " ... on ProjectV2 {",
61
- " fields(first:50, after:$after) {",
62
- " pageInfo { hasNextPage endCursor }",
63
- " nodes {",
64
- " ... on ProjectV2SingleSelectField {",
65
- " id name",
66
- " options { id name }",
67
- " }",
68
- " }",
69
- " }",
70
- " }",
71
- " }",
72
- "}"
73
- ].join("\n");
74
-
75
12
  const GET_PROJECT_ITEMS = [
76
13
  "query($projectId:ID!, $after:String) {",
77
14
  " node(id:$projectId) {",
@@ -99,82 +36,22 @@ const GET_PROJECT_ITEMS = [
99
36
  "}"
100
37
  ].join("\n");
101
38
 
102
- // ── Paginated project listing ────────────────────────────────────────────
103
-
104
- async function listAllProjects(login, kind, env, runChild) {
105
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
106
- const projects = [];
107
- let after = null;
108
- while (true) {
109
- const vars = { login };
110
- if (after) vars.after = after;
111
- const payload = await ghGraphql(query, vars, env, runChild);
112
- const connection = kind === "org"
113
- ? payload?.data?.organization?.projectsV2
114
- : payload?.data?.user?.projectsV2;
115
- const nodes = connection?.nodes ?? [];
116
- projects.push(...nodes);
117
- const pageInfo = connection?.pageInfo ?? {};
118
- if (!pageInfo.hasNextPage) break;
119
- if (!pageInfo.endCursor) {
120
- throw Object.assign(
121
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
122
- { code: "GH_API_ERROR" },
123
- );
124
- }
125
- after = pageInfo.endCursor;
126
- }
127
- return projects;
128
- }
129
-
130
- // ── Paginated field listing ──────────────────────────────────────────────
39
+ // ── Paginated project + field listing (shared via projects-access) ────────
131
40
 
132
- async function listAllFields(projectId, env, runChild) {
133
- const fields = [];
134
- let after = null;
135
- while (true) {
136
- const vars = { projectId };
137
- if (after) vars.after = after;
138
- const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
139
- const connection = payload?.data?.node?.fields;
140
- const nodes = connection?.nodes ?? [];
141
- fields.push(...nodes);
142
- const pageInfo = connection?.pageInfo ?? {};
143
- if (!pageInfo.hasNextPage) break;
144
- if (!pageInfo.endCursor) {
145
- throw Object.assign(
146
- new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
147
- { code: "GH_API_ERROR" },
148
- );
149
- }
150
- after = pageInfo.endCursor;
151
- }
152
- return fields;
153
- }
41
+ const listAllProjects = discoverProjects;
42
+ const listAllFields = listProjectFields;
154
43
 
155
44
  // ── Paginated item listing ───────────────────────────────────────────────
156
45
 
157
- async function listAllItems(projectId, env, runChild) {
158
- const items = [];
159
- let after = null;
160
- while (true) {
161
- const vars = { projectId };
162
- if (after) vars.after = after;
163
- const payload = await ghGraphql(GET_PROJECT_ITEMS, vars, env, runChild);
164
- const connection = payload?.data?.node?.items;
165
- const nodes = connection?.nodes ?? [];
166
- items.push(...nodes);
167
- const pageInfo = connection?.pageInfo ?? {};
168
- if (!pageInfo.hasNextPage) break;
169
- if (!pageInfo.endCursor) {
170
- throw Object.assign(
171
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
172
- { code: "GH_API_ERROR" },
173
- );
174
- }
175
- after = pageInfo.endCursor;
176
- }
177
- return items;
46
+ function listAllItems(projectId, env, runChild) {
47
+ return paginateNodes({
48
+ query: GET_PROJECT_ITEMS,
49
+ variables: { projectId },
50
+ selectConnection: (payload) => payload?.data?.node?.items,
51
+ env,
52
+ runChild,
53
+ entity: "items",
54
+ });
178
55
  }
179
56
 
180
57
  // ── Exit code classification ────────────────────────────────────────────
@@ -260,14 +137,7 @@ async function main(args, { env = process.env, runChild } = {}) {
260
137
  if (!content) continue;
261
138
 
262
139
  // Determine status from field values
263
- let status = null;
264
- const fieldValues = item.fieldValues?.nodes ?? [];
265
- for (const fv of fieldValues) {
266
- if (fv && fv.field && fv.field.name === "Status") {
267
- status = fv.name;
268
- break;
269
- }
270
- }
140
+ const status = extractStatus(item);
271
141
 
272
142
  // Filter by column
273
143
  if (args.column && status !== args.column) continue;
@@ -3,74 +3,14 @@ import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
3
3
  import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
4
4
  import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
5
5
  import { ghGraphql, resolveOwner } from "../github/gh.mjs";
6
+ import { validateProjectsRepo, discoverProjects, listProjectFields, paginateNodes, extractStatus } from "./projects-access.mjs";
6
7
 
7
8
  // ── Validation ───────────────────────────────────────────────────────────
8
9
 
9
- const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
10
- const REPO_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
11
-
12
- function validateRepo(repo) {
13
- if (!repo || typeof repo !== "string") {
14
- throw Object.assign(new Error("--repo is required"), { code: "INVALID_REPO" });
15
- }
16
- const trimmed = repo.trim();
17
- if (trimmed !== repo) {
18
- throw Object.assign(new Error(`--repo must not have leading/trailing whitespace, got "${repo}"`), { code: "INVALID_REPO" });
19
- }
20
- const slashIdx = repo.indexOf("/");
21
- if (slashIdx === -1) {
22
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
23
- }
24
- const owner = repo.slice(0, slashIdx);
25
- const name = repo.slice(slashIdx + 1);
26
- if (!owner || !name || !OWNER_RE.test(owner) || !REPO_NAME_RE.test(name)) {
27
- throw Object.assign(new Error(`--repo must be exactly owner/name, got "${repo}"`), { code: "INVALID_REPO" });
28
- }
29
- return repo;
30
- }
10
+ const validateRepo = validateProjectsRepo;
31
11
 
32
12
  // ── GraphQL fragments ────────────────────────────────────────────────────
33
13
 
34
- const LIST_USER_PROJECTS = [
35
- "query($login:String!, $after:String) {",
36
- " user(login:$login) {",
37
- " projectsV2(first:50, after:$after) {",
38
- " pageInfo { hasNextPage endCursor }",
39
- " nodes { id number title url }",
40
- " }",
41
- " }",
42
- "}"
43
- ].join("\n");
44
-
45
- const LIST_ORG_PROJECTS = [
46
- "query($login:String!, $after:String) {",
47
- " organization(login:$login) {",
48
- " projectsV2(first:50, after:$after) {",
49
- " pageInfo { hasNextPage endCursor }",
50
- " nodes { id number title url }",
51
- " }",
52
- " }",
53
- "}"
54
- ].join("\n");
55
-
56
- const GET_PROJECT_FIELDS = [
57
- "query($projectId:ID!, $after:String) {",
58
- " node(id:$projectId) {",
59
- " ... on ProjectV2 {",
60
- " fields(first:50, after:$after) {",
61
- " pageInfo { hasNextPage endCursor }",
62
- " nodes {",
63
- " ... on ProjectV2SingleSelectField {",
64
- " id name",
65
- " options { id name }",
66
- " }",
67
- " }",
68
- " }",
69
- " }",
70
- " }",
71
- "}"
72
- ].join("\n");
73
-
74
14
  const GET_PROJECT_ITEMS_BY_CONTENT = [
75
15
  "query($projectId:ID!, $after:String) {",
76
16
  " node(id:$projectId) {",
@@ -108,91 +48,25 @@ const UPDATE_ITEM_FIELD = [
108
48
  "}"
109
49
  ].join("\n");
110
50
 
111
- // ── Paginated project listing ────────────────────────────────────────────
112
-
113
- async function listAllProjects(login, kind, env, runChild) {
114
- const query = kind === "org" ? LIST_ORG_PROJECTS : LIST_USER_PROJECTS;
115
- const projects = [];
116
- let after = null;
117
- while (true) {
118
- const vars = { login };
119
- if (after) vars.after = after;
120
- const payload = await ghGraphql(query, vars, env, runChild);
121
- const connection = kind === "org"
122
- ? payload?.data?.organization?.projectsV2
123
- : payload?.data?.user?.projectsV2;
124
- const nodes = connection?.nodes ?? [];
125
- projects.push(...nodes);
126
- const pageInfo = connection?.pageInfo ?? {};
127
- if (!pageInfo.hasNextPage) break;
128
- if (!pageInfo.endCursor) {
129
- throw Object.assign(
130
- new Error("Invalid projects list payload: hasNextPage is true but endCursor is missing"),
131
- { code: "GH_API_ERROR" },
132
- );
133
- }
134
- after = pageInfo.endCursor;
135
- }
136
- return projects;
137
- }
51
+ // ── Paginated project + field listing (shared via projects-access) ────────
138
52
 
139
- // ── Paginated field listing ──────────────────────────────────────────────
140
-
141
- async function listAllFields(projectId, env, runChild) {
142
- const fields = [];
143
- let after = null;
144
- while (true) {
145
- const vars = { projectId };
146
- if (after) vars.after = after;
147
- const payload = await ghGraphql(GET_PROJECT_FIELDS, vars, env, runChild);
148
- const connection = payload?.data?.node?.fields;
149
- const nodes = connection?.nodes ?? [];
150
- fields.push(...nodes);
151
- const pageInfo = connection?.pageInfo ?? {};
152
- if (!pageInfo.hasNextPage) break;
153
- if (!pageInfo.endCursor) {
154
- throw Object.assign(
155
- new Error("Invalid fields payload: hasNextPage is true but endCursor is missing"),
156
- { code: "GH_API_ERROR" },
157
- );
158
- }
159
- after = pageInfo.endCursor;
160
- }
161
- return fields;
162
- }
53
+ const listAllProjects = discoverProjects;
54
+ const listAllFields = listProjectFields;
163
55
 
164
56
  // ── Paginated item listing (position order) ──────────────────────────────
165
57
 
166
- async function fetchAllItems(projectId, env, runChild) {
167
- const items = [];
168
- let after = null;
169
- while (true) {
170
- const vars = { projectId };
171
- if (after) vars.after = after;
172
- const payload = await ghGraphql(GET_PROJECT_ITEMS_BY_CONTENT, vars, env, runChild);
173
- const connection = payload?.data?.node?.items;
174
- const nodes = connection?.nodes ?? [];
175
- items.push(...nodes);
176
- const pageInfo = connection?.pageInfo ?? {};
177
- if (!pageInfo.hasNextPage) break;
178
- if (!pageInfo.endCursor) {
179
- throw Object.assign(
180
- new Error("Invalid items payload: hasNextPage is true but endCursor is missing"),
181
- { code: "GH_API_ERROR" },
182
- );
183
- }
184
- after = pageInfo.endCursor;
185
- }
186
- return items;
58
+ function fetchAllItems(projectId, env, runChild) {
59
+ return paginateNodes({
60
+ query: GET_PROJECT_ITEMS_BY_CONTENT,
61
+ variables: { projectId },
62
+ selectConnection: (payload) => payload?.data?.node?.items,
63
+ env,
64
+ runChild,
65
+ entity: "items",
66
+ });
187
67
  }
188
68
 
189
- function statusOf(node) {
190
- const fvs = node?.fieldValues?.nodes ?? [];
191
- for (const fv of fvs) {
192
- if (fv && fv.field && fv.field.name === "Status") return fv.name;
193
- }
194
- return null;
195
- }
69
+ const statusOf = extractStatus;
196
70
 
197
71
  // ── Exit code classification ────────────────────────────────────────────
198
72