@dev-loops/core 0.4.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Spike-mode exit (P2 of the spike-mode track #965).
3
+ *
4
+ * Phase 1 (#964) shipped the spike intake state machine
5
+ * (`evaluateSpikeIntakeState` → SPIKE_INTAKE_STATE). A spike becomes exitable
6
+ * only once it carries a Recommendation (`spike_ready_for_exit`). This module is
7
+ * the exit decision: from that ready state, the operator picks a disposition —
8
+ * - DISCARD — the recommendation is "don't pursue"; drop the spike with ZERO
9
+ * tracker artifacts (the findings doc is the whole record).
10
+ * - GRADUATE — promote the exploration into a #947-consumable plan file
11
+ * (Status/Objective/In scope/Explicit non-goals) built from the
12
+ * spike's Question/Approach/Findings/Recommendation, which then
13
+ * enters the existing local-first plan→PR promotion path (#952).
14
+ *
15
+ * Pure: no fs/network/gh, no `scripts/` import (mirrors spike-intake-contract
16
+ * and plan-file-promote-contract). The CLI (`scripts/refine/exit-spike.mjs`)
17
+ * owns all I/O. Fail-closed on an unknown disposition or a non-ready state so
18
+ * the CLI makes zero mutation on those paths.
19
+ */
20
+
21
+ import { SPIKE_INTAKE_STATE } from "./spike-intake-contract.mjs";
22
+
23
+ /** Dispositions the operator can choose at a ready spike's exit. */
24
+ export const SPIKE_EXIT_DISPOSITION = Object.freeze({
25
+ /** Drop the spike with no tracker artifact (recommendation: don't pursue). */
26
+ DISCARD: "discard",
27
+ /** Promote into a plan file consumable by the #947 local-first flow. */
28
+ GRADUATE: "graduate",
29
+ });
30
+
31
+ /** Actions the exit decision can return (1:1 with the eligible dispositions). */
32
+ export const SPIKE_EXIT_ACTION = Object.freeze({
33
+ DISCARD: "discard",
34
+ GRADUATE: "graduate",
35
+ });
36
+
37
+ const DISPOSITION_TO_ACTION = Object.freeze({
38
+ [SPIKE_EXIT_DISPOSITION.DISCARD]: SPIKE_EXIT_ACTION.DISCARD,
39
+ [SPIKE_EXIT_DISPOSITION.GRADUATE]: SPIKE_EXIT_ACTION.GRADUATE,
40
+ });
41
+
42
+ /**
43
+ * Pure exit-eligibility decision.
44
+ *
45
+ * Eligible ONLY from `spike_ready_for_exit` (a Recommendation has been reached).
46
+ * Any other state — in-progress or ambiguous — fails closed with
47
+ * `not_ready_for_exit` and no action; the CLI must make zero tracker mutation.
48
+ * An unrecognized disposition fails closed with `unknown_disposition`.
49
+ *
50
+ * @param {object} facts
51
+ * @param {string} facts.spikeIntakeState one of SPIKE_INTAKE_STATE values (from evaluateSpikeIntakeState)
52
+ * @param {string} facts.disposition one of SPIKE_EXIT_DISPOSITION values
53
+ * @returns {{ ok: boolean, action?: string, reason?: string, spikeIntakeState?: string | null }}
54
+ */
55
+ export function evaluateSpikeExit({ spikeIntakeState, disposition } = {}) {
56
+ // The ready gate: an exit decision is only meaningful once a recommendation
57
+ // exists. Fail closed otherwise — never guess an exit for an in-progress or
58
+ // malformed spike.
59
+ if (spikeIntakeState !== SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT) {
60
+ return { ok: false, reason: "not_ready_for_exit", spikeIntakeState: spikeIntakeState ?? null };
61
+ }
62
+
63
+ // Own-property check: a bare index lookup would also match inherited
64
+ // Object.prototype keys (`toString`, `__proto__`, `constructor`), letting an
65
+ // unknown disposition resolve to a truthy value and bypass the fail-closed
66
+ // contract. Require a string that is an own key of the map.
67
+ if (typeof disposition !== "string" || !Object.hasOwn(DISPOSITION_TO_ACTION, disposition)) {
68
+ return { ok: false, reason: "unknown_disposition", spikeIntakeState };
69
+ }
70
+ const action = DISPOSITION_TO_ACTION[disposition];
71
+
72
+ return { ok: true, action, spikeIntakeState };
73
+ }
74
+
75
+ /**
76
+ * Build a #947-consumable plan-file body from a ready spike's sections.
77
+ *
78
+ * The emitted body carries the four base authoring sections the plan-file
79
+ * format requires (Status / Objective / In scope / Explicit non-goals — see
80
+ * scripts/refine/validate-plan-file.mjs), so it passes `validatePlanFile` and
81
+ * enters the existing local-first plan→PR promotion path (#952) unchanged.
82
+ *
83
+ * The spike sections map onto the plan as: the Question + Approach become the
84
+ * Objective's context, the Recommendation becomes the In-scope work, the
85
+ * Findings record the evidence, and a fixed non-goal keeps the plan from
86
+ * re-opening the (now-concluded) exploration. Status starts as Draft.
87
+ *
88
+ * Idempotent and pure: same input → identical output, no side effects. Fails
89
+ * closed (throws) on an empty required section so a graduate exit cannot emit a
90
+ * plan that the validator would reject.
91
+ *
92
+ * @param {object} sections
93
+ * @param {string} sections.question
94
+ * @param {string} sections.approach
95
+ * @param {string} sections.findings
96
+ * @param {string} sections.recommendation
97
+ * @returns {string} markdown plan-file body
98
+ */
99
+ export function buildGraduatedPlanBody({ question, approach, findings, recommendation } = {}) {
100
+ const q = String(question ?? "").trim();
101
+ const a = String(approach ?? "").trim();
102
+ const f = String(findings ?? "").trim();
103
+ const r = String(recommendation ?? "").trim();
104
+ if (q.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty question");
105
+ if (a.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty approach");
106
+ if (f.length === 0) throw new Error("buildGraduatedPlanBody requires non-empty findings");
107
+ if (r.length === 0) throw new Error("buildGraduatedPlanBody requires a non-empty recommendation");
108
+
109
+ return [
110
+ "# Graduated spike plan",
111
+ "",
112
+ "## Status",
113
+ "",
114
+ "Draft (graduated from a spike). Needs refinement before promotion.",
115
+ "",
116
+ "## Objective",
117
+ "",
118
+ `Act on the spike's recommendation. The spike asked: ${q}`,
119
+ "",
120
+ "Approach explored:",
121
+ "",
122
+ a,
123
+ "",
124
+ "## In scope",
125
+ "",
126
+ r,
127
+ "",
128
+ "Supporting findings from the spike:",
129
+ "",
130
+ f,
131
+ "",
132
+ "## Explicit non-goals",
133
+ "",
134
+ "- Re-running the spike's exploration; that question is concluded.",
135
+ "- Work beyond the recommendation above.",
136
+ "",
137
+ ].join("\n");
138
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Spike-mode intake state machine.
3
+ *
4
+ * A `--spike` startup hands the dev-loop a time-boxed, exploratory artifact that
5
+ * lives outside the tracker — startable from a local question with no GitHub
6
+ * issue. A spike is NOT a plan-needing-refinement, so its states are distinct
7
+ * from PLAN_FILE_INTAKE_STATE: it classifies how far the exploration has
8
+ * progressed toward an exit decision, not how far a plan has progressed toward
9
+ * promotion.
10
+ *
11
+ * This evaluator mirrors `evaluatePlanFileIntakeState`: a frozen enum plus a
12
+ * pure, deterministic function with no GitHub or filesystem side effects — the
13
+ * caller supplies the section-presence facts it has already read.
14
+ *
15
+ * The two non-ambiguous states are the seam phase 2 (#965) consumes for its
16
+ * discard/graduate exits:
17
+ * - SPIKE_IN_PROGRESS — exploration ongoing (no recommendation yet); a
18
+ * discard exit can drop it with no artifact.
19
+ * - SPIKE_READY_FOR_EXIT — a recommendation has been reached; phase 2 routes
20
+ * this to graduate (promote into a plan/PR) or
21
+ * discard (recommendation is "don't pursue").
22
+ */
23
+
24
+ export const SPIKE_INTAKE_STATE = Object.freeze({
25
+ /** Valid spike artifact; the Recommendation is not yet reached. */
26
+ SPIKE_IN_PROGRESS: "spike_in_progress",
27
+ /** Valid spike artifact carrying a Recommendation; an exit decision can be made. */
28
+ SPIKE_READY_FOR_EXIT: "spike_ready_for_exit",
29
+ /** Inputs are malformed or unusable; fail closed. */
30
+ AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
31
+ });
32
+
33
+ /**
34
+ * Pure intake-state classifier.
35
+ *
36
+ * @param {object} facts
37
+ * @param {boolean} facts.baseSectionsValid whether the spike's exploration scaffold (Question/Approach/Findings) is present and non-empty. Recommendation is NOT part of this fact — it is the separate exit-marker carried by `hasRecommendation`, so that a scaffold-valid spike without a Recommendation classifies as in-progress rather than failing closed.
38
+ * @param {boolean} facts.hasRecommendation whether a non-empty Recommendation section is present (the exit-marker that flips in-progress → ready-for-exit)
39
+ * @returns {{ state: string }} one of SPIKE_INTAKE_STATE values
40
+ */
41
+ export function evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation } = {}) {
42
+ // A malformed spike artifact (missing/empty base sections) is unusable intake
43
+ // input; fail closed instead of guessing an exit.
44
+ if (baseSectionsValid !== true) {
45
+ return { state: SPIKE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
46
+ }
47
+ return {
48
+ state: hasRecommendation === true
49
+ ? SPIKE_INTAKE_STATE.SPIKE_READY_FOR_EXIT
50
+ : SPIKE_INTAKE_STATE.SPIKE_IN_PROGRESS,
51
+ };
52
+ }
@@ -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
+ }