@dev-loops/core 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -56,6 +56,7 @@
56
56
  "./loop/steering": "./src/loop/steering.mjs",
57
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
58
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
59
+ "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
59
60
  "./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
60
61
  "./harness": "./src/harness/index.mjs",
61
62
  "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
@@ -169,6 +169,32 @@ export function transformAgent({ source, raw, version = "latest" }) {
169
169
  return `${lines.join("\n")}\n${body}`;
170
170
  }
171
171
 
172
+ /**
173
+ * Transform a canonical `commands/<name>.command.md` into a Claude `.claude/commands/<name>.md`
174
+ * slash command (#972). Commands are thin wrappers over the public dev-loop contract: the body
175
+ * is a prompt (with `$ARGUMENTS`) that invokes the existing entrypoint, so there is NO routing
176
+ * logic here. Frontmatter keeps Claude's command fields (`description`, `argument-hint`); the body
177
+ * is passed through `stripPiOnlyBlocks` + `rewriteCliInvocation` like agents/skills.
178
+ * @param {{ source: string, raw: string, version?: string }} input
179
+ * @returns {string} Full generated file content.
180
+ */
181
+ export function transformCommand({ source, raw, version = "latest" }) {
182
+ const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
183
+ const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
184
+
185
+ const lines = ["---"];
186
+ if (frontmatter.description != null) {
187
+ lines.push(`description: ${JSON.stringify(String(frontmatter.description))}`);
188
+ }
189
+ if (frontmatter["argument-hint"] != null) {
190
+ lines.push(`argument-hint: ${JSON.stringify(String(frontmatter["argument-hint"]))}`);
191
+ }
192
+ lines.push("---");
193
+ lines.push(GENERATED_NOTE(source));
194
+ lines.push("");
195
+ return `${lines.join("\n")}\n${body}`;
196
+ }
197
+
172
198
  /**
173
199
  * Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
174
200
  * @param {{ source: string, raw: string, version?: string }} input
@@ -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
  };
@@ -1,11 +1,13 @@
1
1
  import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
2
2
  import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
3
+ import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
3
4
 
4
5
  export const PR_CHECKPOINT = Object.freeze({
5
6
  DRAFT_REVIEW: "draft_review",
6
7
  POST_DRAFT_EXTERNAL_REVIEW: "post_draft_external_review",
7
8
  FEEDBACK_RESOLUTION: "feedback_resolution",
8
9
  CONFLICT_RESOLUTION: "conflict_resolution",
10
+ UI_E2E_SCOPING: "ui_e2e_scoping",
9
11
  PRE_APPROVAL_GATE_WINDOW: "pre_approval_gate_window",
10
12
  FINAL_APPROVAL_READY: "final_approval_ready",
11
13
  PRE_APPROVAL_GATE_NEEDED: "pre_approval_gate_needed",
@@ -48,6 +50,7 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
48
50
  RECONCILE_DRAFT_GATE: "reconcile_draft_gate",
49
51
  REPORT_BLOCKED: "report_blocked",
50
52
  REPORT_DONE: "report_done",
53
+ RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
51
54
  });
52
55
 
53
56
  function normalizeGateComment(summary = null) {
@@ -687,6 +690,10 @@ function evaluatePrGateCoordinationCore(input = {}) {
687
690
  const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
688
691
  const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
689
692
  const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
693
+ // UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
694
+ // e2e suite passed for this head. Inclusion is path-triggered, never annotated.
695
+ const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
696
+ const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
690
697
  const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
691
698
  ? input.refinementArtifact
692
699
  : null;
@@ -836,6 +843,45 @@ function evaluatePrGateCoordinationCore(input = {}) {
836
843
  });
837
844
  }
838
845
 
846
+ // UI e2e auto-scoping precondition (#976). Path-triggered + fail-closed:
847
+ // if the PR's changed files touch a rendered artifact (a deck under
848
+ // docs/articles|presentations, or the inspect-run viewer source), it MUST be
849
+ // registered in the shared UI e2e suite AND that suite must have passed for
850
+ // this head. A rendered-artifact change with no registered/passing coverage
851
+ // blocks here with a reason naming the artifact. Distinct seam from the
852
+ // mergeability (#980) and retrospective (#982) preconditions to minimize
853
+ // merge-time conflict. Non-UI changes pass through untouched (required=false).
854
+ const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
855
+ if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
856
+ pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]);
857
+ pushUnique(forbiddenActions, [
858
+ PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
859
+ PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
860
+ PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
861
+ PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
862
+ PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
863
+ ]);
864
+ return buildResult({
865
+ repo: input.repo ?? null,
866
+ pr: Number.isInteger(input.pr) ? input.pr : null,
867
+ currentHeadSha,
868
+ lifecycleState: effectiveLifecycleState,
869
+ loopDisposition: DISPOSITION.ACTION_REQUIRED,
870
+ gateBoundary: PR_CHECKPOINT.UI_E2E_SCOPING,
871
+ draftGateAlreadySatisfied,
872
+ draftGate,
873
+ preApprovalGate,
874
+ allowedNextActions,
875
+ forbiddenActions,
876
+ nextAction: PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE,
877
+ reason: uiE2eScoping.reason,
878
+ mergeStateStatus,
879
+ conflictFiles,
880
+ refinementArtifact,
881
+ copilotReviewRoundCount,
882
+ });
883
+ }
884
+
839
885
  if (prDraft || effectiveLifecycleState === STATE.PR_DRAFT) {
840
886
  if (refinementArtifactStatus === REFINEMENT_ARTIFACT_STATUS.MISSING) {
841
887
  pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
@@ -4,8 +4,14 @@
4
4
  * The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
5
5
  * identify an inspectable per-subagent run (runner ownership, async-start enforcement,
6
6
  * human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
7
- * Code) that inject no native per-subagent run id. The harness sets `DEVLOOPS_RUN_ID` when
8
- * dispatching an async subagent.
7
+ * Code) that inject no native per-subagent run id. For those harnesses dev-loops itself mints
8
+ * and sets `DEVLOOPS_RUN_ID` when dispatching an async subagent.
9
+ *
10
+ * Other harnesses may already inject their own run-id var: the Pi runtime injects
11
+ * `PI_SUBAGENT_RUN_ID` (not `DEVLOOPS_RUN_ID`) into each async subagent's child env, so that
12
+ * name is honored as a recognized run-id alias (precedence after the neutral primary). It is
13
+ * an externally-injected Pi-runtime contract var, not a dev-loops-owned var — dev-loops still
14
+ * mints/propagates only the neutral `DEVLOOPS_RUN_ID`.
9
15
  *
10
16
  * This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
11
17
  * which take an injectable `fs` and `root` for testability.
@@ -17,9 +23,10 @@ import path from "node:path";
17
23
 
18
24
  /**
19
25
  * Env var names that carry the async-context run id, in resolution precedence order.
20
- * The neutral `DEVLOOPS_RUN_ID` is the sole marker.
26
+ * The neutral `DEVLOOPS_RUN_ID` is primary; `PI_SUBAGENT_RUN_ID` is the alias the Pi
27
+ * runtime injects into async-subagent child envs (the only run-id marker present under Pi).
21
28
  */
22
- export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
29
+ export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
23
30
 
24
31
  /** Neutral env var name used when minting/propagating a run id. */
25
32
  export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
@@ -0,0 +1,162 @@
1
+ // UI e2e auto-scoping (issue #976).
2
+ //
3
+ // Deterministic, path-triggered criterion: a PR that adds or modifies a
4
+ // *rendered* HTML artifact (a presentation deck, an article page, or the
5
+ // inspect-run viewer's served page/component) MUST run the shared UI e2e
6
+ // assertions (mobile + desktop) AND have that artifact registered in the e2e
7
+ // suite (DECK_REGISTRY / ARTICLE_REGISTRY / VIEWER_REGISTRY). Inclusion is
8
+ // triggered by the changed-file set, never by a human annotating the PR.
9
+ //
10
+ // This module is the testable core of that criterion: classify changed paths
11
+ // → rendered-artifact set → check each is registered → fail closed if a
12
+ // rendered artifact changed with no registered/passing coverage.
13
+
14
+ // Explicit path globs for rendered artifacts. Kept conservative and explicit
15
+ // (issue #976 scope discipline): only artifacts that render to a page/component.
16
+ export const RENDERED_ARTIFACT_GLOBS = Object.freeze([
17
+ "docs/articles/*.html",
18
+ "docs/presentations/*.html",
19
+ ]);
20
+
21
+ // The inspect-run viewer is served from a component, not a static .html file,
22
+ // so its trigger is the served-page source (matches the existing
23
+ // inspect-run-viewer-ci-changes.mjs trigger seam).
24
+ export const VIEWER_SOURCE_PATHS = Object.freeze([
25
+ "scripts/loop/inspect-run-viewer.mjs",
26
+ ]);
27
+
28
+ // Registered artifacts — keyed by FULL repo-relative path (not basename), so
29
+ // docs/articles/X.html and docs/presentations/X.html (which share basenames,
30
+ // e.g. introducing-dev-loops.html) are DISTINCT and can never alias onto each
31
+ // other. Mirrors the registries' actual on-disk locations:
32
+ // decks → DECK_REGISTRY served from docs/presentations/<deck>
33
+ // articles→ ARTICLE_REGISTRY served from docs/articles/<file>
34
+ // Note: kept as an explicit list here rather than importing the harness
35
+ // (which pulls @playwright/test into core); the ui-e2e-scoping.test.mjs sync
36
+ // test fails if a registry entry is added without updating this list, so it
37
+ // can't silently drift.
38
+ export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
39
+ "docs/presentations/introducing-dev-loops.html",
40
+ "docs/presentations/dev-loops-deep-dive.html",
41
+ "docs/articles/introducing-dev-loops.html",
42
+ "docs/articles/dev-loops-deep-dive.html",
43
+ ]);
44
+
45
+ export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
46
+
47
+ // CI check names that constitute the shared UI e2e coverage. The detect layer
48
+ // reads these from the statusCheckRollup to set uiE2ePassed. Note: a plain
49
+ // name match against the rollup is enough; the gate only needs to know whether
50
+ // the suite passed for this head. Each rendered-artifact family has a stable CI
51
+ // job whose name appears here; an absent check is unknown → fails closed.
52
+ export const UI_E2E_CHECK_NAMES = Object.freeze(["viewer-smoke", "deck-smoke", "article-smoke"]);
53
+
54
+ function normalizePath(filePath) {
55
+ return String(filePath ?? "").trim().replace(/^\.\/+/u, "");
56
+ }
57
+
58
+ // Match a single explicit "dir/*.ext" glob (one path segment, no recursion).
59
+ function matchesGlob(normalizedPath, glob) {
60
+ const [dir, file] = [glob.slice(0, glob.lastIndexOf("/")), glob.slice(glob.lastIndexOf("/") + 1)];
61
+ if (!file.startsWith("*.")) return normalizedPath === glob;
62
+ const ext = file.slice(1); // ".html"
63
+ if (!normalizedPath.startsWith(`${dir}/`)) return false;
64
+ const rest = normalizedPath.slice(dir.length + 1);
65
+ return rest.length > 0 && !rest.includes("/") && rest.endsWith(ext);
66
+ }
67
+
68
+ // Classify one changed path into a rendered-artifact descriptor, or null.
69
+ // A descriptor carries the path, a stable `id` (the deck filename or the
70
+ // viewer id) and whether that id is registered in the e2e suite.
71
+ export function classifyRenderedArtifactPath(filePath) {
72
+ const normalized = normalizePath(filePath);
73
+ if (normalized.length === 0) return null;
74
+
75
+ if (VIEWER_SOURCE_PATHS.includes(normalized)) {
76
+ return { path: normalized, kind: "viewer", id: VIEWER_ARTIFACT_ID, registered: true };
77
+ }
78
+
79
+ for (const glob of RENDERED_ARTIFACT_GLOBS) {
80
+ if (matchesGlob(normalized, glob)) {
81
+ // Key registration on the FULL repo-relative path so an article and a
82
+ // deck that share a basename are distinct artifacts. id is the full path
83
+ // too, so the fail-closed reason names the exact file to register.
84
+ return {
85
+ path: normalized,
86
+ kind: normalized.startsWith("docs/articles/") ? "article" : "deck",
87
+ id: normalized,
88
+ registered: REGISTERED_ARTIFACT_PATHS.includes(normalized),
89
+ };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Deterministic UI e2e scoping check.
97
+ *
98
+ * @param {string[]} changedPaths - PR changed-file paths.
99
+ * @param {{ uiE2ePassed?: boolean|null }} [coverage]
100
+ * uiE2ePassed: whether the shared UI e2e suite passed for this head.
101
+ * null/undefined means "not run / unknown" → fails closed.
102
+ * @returns {{
103
+ * required: boolean,
104
+ * artifacts: Array<{path,kind,id,registered}>,
105
+ * unregistered: string[],
106
+ * satisfied: boolean,
107
+ * reason: string|null,
108
+ * }}
109
+ */
110
+ export function evaluateUiE2eScoping(changedPaths = [], { uiE2ePassed = null } = {}) {
111
+ const artifacts = [];
112
+ const seen = new Set();
113
+ for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
114
+ const descriptor = classifyRenderedArtifactPath(p);
115
+ if (descriptor && !seen.has(descriptor.path)) {
116
+ seen.add(descriptor.path);
117
+ artifacts.push(descriptor);
118
+ }
119
+ }
120
+
121
+ const required = artifacts.length > 0;
122
+ if (!required) {
123
+ return { required: false, artifacts, unregistered: [], satisfied: true, reason: null };
124
+ }
125
+
126
+ // Fail closed: any touched rendered artifact that is not registered blocks
127
+ // and names itself so the fix is unambiguous (register it in the suite).
128
+ const unregistered = artifacts.filter((a) => !a.registered).map((a) => a.id);
129
+ if (unregistered.length > 0) {
130
+ return {
131
+ required: true,
132
+ artifacts,
133
+ unregistered,
134
+ satisfied: false,
135
+ reason:
136
+ `UI e2e coverage is required: this PR changes rendered artifact(s) ` +
137
+ `${unregistered.join(", ")} that are not registered in the shared UI e2e suite ` +
138
+ `(DECK_REGISTRY or ARTICLE_REGISTRY in test/playwright/harness/deck-fit-harness.mjs, ` +
139
+ `or VIEWER_REGISTRY in test/playwright/harness/inspect-run-viewer-harness.mjs). ` +
140
+ `Register the artifact and add a spec that runs ` +
141
+ `the mobile + desktop assertions before this gate can pass.`,
142
+ };
143
+ }
144
+
145
+ // All touched artifacts are registered — coverage must have actually passed.
146
+ if (uiE2ePassed !== true) {
147
+ const touched = artifacts.map((a) => a.id).join(", ");
148
+ return {
149
+ required: true,
150
+ artifacts,
151
+ unregistered: [],
152
+ satisfied: false,
153
+ reason:
154
+ `UI e2e coverage is required: this PR changes rendered artifact(s) ${touched}, ` +
155
+ `but the shared UI e2e suite (mobile + desktop) has not passed for this head ` +
156
+ `(uiE2ePassed=${String(uiE2ePassed)}). Run the UI/mobile e2e loop and let it pass ` +
157
+ `before this gate can proceed.`,
158
+ };
159
+ }
160
+
161
+ return { required: true, artifacts, unregistered: [], satisfied: true, reason: null };
162
+ }