@dev-loops/core 0.4.0 → 0.5.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 +6 -1
- package/src/config/config.mjs +50 -2
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/plan-file-intake-contract.mjs +56 -0
- package/src/loop/plan-file-promote-contract.mjs +234 -0
- package/src/loop/plan-file-refine-contract.mjs +229 -0
- package/src/loop/pr-gate-coordination.mjs +114 -9
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
38
38
|
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
39
39
|
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
40
|
+
"./loop/plan-file-intake-contract": "./src/loop/plan-file-intake-contract.mjs",
|
|
41
|
+
"./loop/plan-file-promote-contract": "./src/loop/plan-file-promote-contract.mjs",
|
|
42
|
+
"./loop/plan-file-refine-contract": "./src/loop/plan-file-refine-contract.mjs",
|
|
40
43
|
"./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
|
|
41
44
|
"./loop/pr-title-markers": "./src/loop/pr-title-markers.mjs",
|
|
42
45
|
"./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
|
|
@@ -48,6 +51,8 @@
|
|
|
48
51
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
49
52
|
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
50
53
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
54
|
+
"./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
|
|
55
|
+
"./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
|
|
51
56
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
52
57
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
53
58
|
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
package/src/config/config.mjs
CHANGED
|
@@ -53,6 +53,12 @@ const GatesConfig = z.strictObject({
|
|
|
53
53
|
// `requireCi` is only behaviorally configurable for the draft gate.
|
|
54
54
|
// preApproval always requires CI even if config repeats `requireCi`.
|
|
55
55
|
preApproval: GateConfig.optional(),
|
|
56
|
+
// Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
57
|
+
// not production code, so it should not carry the full draft → pre-approval →
|
|
58
|
+
// Copilot production set. Resolved through the same config-merge layering and
|
|
59
|
+
// the same resolveGateConfig path as draft/preApproval — no new strategy→knob
|
|
60
|
+
// resolver. Absent for non-spike work, so production gates are unaffected.
|
|
61
|
+
spike: GateConfig.optional(),
|
|
56
62
|
// Fail-closed enforcement that a gate verdict was produced by the
|
|
57
63
|
// fan-out/fan-in review sub-loop (executionMode === "fanout_fanin" plus a
|
|
58
64
|
// durable findings-log ledger), not an inline single-agent run. Default
|
|
@@ -105,6 +111,11 @@ const WorkflowConfig = z.strictObject({
|
|
|
105
111
|
asyncStartMode: z.enum(["required", "allowed"]).default("required"),
|
|
106
112
|
requireRetrospective: z.boolean(),
|
|
107
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),
|
|
108
119
|
requireDraftFirst: z.boolean(),
|
|
109
120
|
devModeDefault: z.boolean(),
|
|
110
121
|
});
|
|
@@ -140,6 +151,16 @@ const WorktreeConfig = z.strictObject({
|
|
|
140
151
|
linkOnInit: z.array(z.string().trim().min(1)).optional(),
|
|
141
152
|
});
|
|
142
153
|
|
|
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
|
+
|
|
143
164
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
144
165
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
145
166
|
|
|
@@ -159,6 +180,7 @@ const FileGateConfig = GateConfig.partial();
|
|
|
159
180
|
const FileGatesConfig = z.strictObject({
|
|
160
181
|
draft: FileGateConfig.optional(),
|
|
161
182
|
preApproval: FileGateConfig.optional(),
|
|
183
|
+
spike: FileGateConfig.optional(),
|
|
162
184
|
requireFanoutEvidence: z.boolean().optional(),
|
|
163
185
|
maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
|
|
164
186
|
postFindingsComments: z.boolean().optional(),
|
|
@@ -190,6 +212,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
190
212
|
personas: PersonasConfig.optional(),
|
|
191
213
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
192
214
|
worktree: WorktreeConfig.optional(),
|
|
215
|
+
localPlanning: LocalPlanningConfig.optional(),
|
|
193
216
|
});
|
|
194
217
|
|
|
195
218
|
// ============================================================================
|
|
@@ -215,6 +238,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
215
238
|
asyncStartMode: "required",
|
|
216
239
|
requireRetrospective: false,
|
|
217
240
|
requireRetrospectiveGate: false,
|
|
241
|
+
requireRetrospectiveInternalTooling: false,
|
|
218
242
|
requireDraftFirst: false,
|
|
219
243
|
devModeDefault: false,
|
|
220
244
|
}),
|
|
@@ -239,6 +263,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
239
263
|
"^test/",
|
|
240
264
|
]),
|
|
241
265
|
worktree: Object.freeze({ copyOnInit: Object.freeze([]), linkOnInit: Object.freeze([]) }),
|
|
266
|
+
localPlanning: Object.freeze({ plansDir: "docs/phases/" }),
|
|
242
267
|
});
|
|
243
268
|
|
|
244
269
|
// ============================================================================
|
|
@@ -260,6 +285,7 @@ export const FileConfigSchema = z.strictObject({
|
|
|
260
285
|
personas: FilePersonasConfig.optional(),
|
|
261
286
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
262
287
|
worktree: WorktreeConfig.partial().optional(),
|
|
288
|
+
localPlanning: LocalPlanningConfig.partial().optional(),
|
|
263
289
|
});
|
|
264
290
|
|
|
265
291
|
// ============================================================================
|
|
@@ -916,7 +942,7 @@ export function resolveRefinement(config) {
|
|
|
916
942
|
* flags always resolve to stable defaults.
|
|
917
943
|
*
|
|
918
944
|
* @param {DevLoopConfig} config
|
|
919
|
-
* @param {"draft"|"preApproval"} gate
|
|
945
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
920
946
|
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean }}
|
|
921
947
|
*/
|
|
922
948
|
export function resolveGateConfig(config, gate) {
|
|
@@ -1116,7 +1142,7 @@ export async function resolveGateAnglesDynamic(config, gate, { diff } = {}) {
|
|
|
1116
1142
|
* for the requested key.
|
|
1117
1143
|
*
|
|
1118
1144
|
* @param {DevLoopConfig} config
|
|
1119
|
-
* @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireDraftFirst"|"devModeDefault"} key
|
|
1145
|
+
* @param {"asyncStartMode"|"requireRetrospective"|"requireRetrospectiveGate"|"requireRetrospectiveInternalTooling"|"requireDraftFirst"|"devModeDefault"} key
|
|
1120
1146
|
* @returns {string|boolean}
|
|
1121
1147
|
*/
|
|
1122
1148
|
export function resolveWorkflowConfig(config, key) {
|
|
@@ -1132,6 +1158,10 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
1132
1158
|
return config?.workflow?.requireRetrospectiveGate ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveGate;
|
|
1133
1159
|
}
|
|
1134
1160
|
|
|
1161
|
+
if (key === "requireRetrospectiveInternalTooling") {
|
|
1162
|
+
return config?.workflow?.requireRetrospectiveInternalTooling ?? DEFAULT_WORKFLOW_CONFIG.requireRetrospectiveInternalTooling;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1135
1165
|
if (key === "requireDraftFirst") {
|
|
1136
1166
|
return config?.workflow?.requireDraftFirst ?? DEFAULT_WORKFLOW_CONFIG.requireDraftFirst;
|
|
1137
1167
|
}
|
|
@@ -1177,6 +1207,24 @@ export function resolveWorktreeConfig(config) {
|
|
|
1177
1207
|
return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
|
|
1178
1208
|
}
|
|
1179
1209
|
|
|
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
|
+
|
|
1180
1228
|
/**
|
|
1181
1229
|
* Resolve the human-handoff config from the merged dev-loop config (#920).
|
|
1182
1230
|
*
|
|
@@ -49,6 +49,9 @@ gates:
|
|
|
49
49
|
requireCi: true
|
|
50
50
|
mandatoryAngles:
|
|
51
51
|
- pr-description
|
|
52
|
+
# Gate findings comments live ON the PR (the local-first spec-of-record /
|
|
53
|
+
# human-review surface), so they are evidence, not tracker noise — keep them on.
|
|
54
|
+
postFindingsComments: true
|
|
52
55
|
preApproval:
|
|
53
56
|
angles:
|
|
54
57
|
- dry
|
|
@@ -67,11 +70,27 @@ gates:
|
|
|
67
70
|
required: true
|
|
68
71
|
mandatoryAngles:
|
|
69
72
|
- pr-checklist-matrix
|
|
73
|
+
# Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
|
|
74
|
+
# not production code, so it is intentionally lighter than the production
|
|
75
|
+
# draft -> pre-approval -> Copilot set: a small docs-first angle set, not a
|
|
76
|
+
# required gate, and no CI prerequisite. Resolved through the same
|
|
77
|
+
# config-merge layering and resolveGateConfig path as draft/preApproval; only
|
|
78
|
+
# applies to spike-mode work, so production gates are unaffected.
|
|
79
|
+
spike:
|
|
80
|
+
angles:
|
|
81
|
+
- scope
|
|
82
|
+
- docs
|
|
83
|
+
excludeAngles: []
|
|
84
|
+
required: false
|
|
85
|
+
requireCi: false
|
|
86
|
+
mandatoryAngles: []
|
|
70
87
|
|
|
71
88
|
# Autonomy: only merge requires operator confirmation by default.
|
|
72
89
|
autonomy:
|
|
73
90
|
stopAt:
|
|
74
91
|
- merge
|
|
92
|
+
# Local-first never auto-merges; a human always merges.
|
|
93
|
+
humanMergeOnly: true
|
|
75
94
|
|
|
76
95
|
# Workflow enforcement defaults.
|
|
77
96
|
workflow:
|
|
@@ -82,6 +101,11 @@ workflow:
|
|
|
82
101
|
# repo-root .devloops, which takes precedence over these extension defaults.
|
|
83
102
|
requireRetrospective: false
|
|
84
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
|
|
85
109
|
requireDraftFirst: true
|
|
86
110
|
# Dev mode is the dev-loop self-improvement mode — it edits the loop's own skill/agent prompts
|
|
87
111
|
# after a phase, which is only meaningful in the dev-loops repo. Shipped defaults must not force
|
|
@@ -89,6 +113,11 @@ workflow:
|
|
|
89
113
|
# via its own repo-root .devloops (which takes precedence over these extension defaults).
|
|
90
114
|
devModeDefault: false
|
|
91
115
|
|
|
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
|
+
|
|
92
121
|
# Light-mode threshold for small local changes.
|
|
93
122
|
localImplementation:
|
|
94
123
|
lightMode:
|
|
@@ -99,7 +128,9 @@ localImplementation:
|
|
|
99
128
|
# Queue defaults (repo-specific projectNumber/boardTitle omitted by design).
|
|
100
129
|
queue:
|
|
101
130
|
maxParallel: 3
|
|
102
|
-
|
|
131
|
+
# Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
|
|
132
|
+
# near-zero; a low cap keeps tracker noise minimal, especially early.
|
|
133
|
+
maxAutoFiledIssues: 1
|
|
103
134
|
reDispatchMaxRetries: 1
|
|
104
135
|
|
|
105
136
|
# Persona registry used by gate review angle resolution.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-planning plan-file intake state machine.
|
|
3
|
+
*
|
|
4
|
+
* A `--plan-file` startup hands the dev-loop a phase-doc-format plan that lives
|
|
5
|
+
* outside the tracker. Before promotion there is no issue to key a worktree on,
|
|
6
|
+
* so this intake stage classifies how far the plan has progressed through
|
|
7
|
+
* refinement. The classification drives whether the next step is refinement or
|
|
8
|
+
* promotion.
|
|
9
|
+
*
|
|
10
|
+
* This evaluator mirrors the `DEV_LOOP_ISSUE_ASSIGNMENT_SEAM` precedent: a
|
|
11
|
+
* frozen enum plus a pure, deterministic function. It performs no GitHub or
|
|
12
|
+
* filesystem side effects — the caller supplies the section-presence facts it
|
|
13
|
+
* has already read.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const PLAN_FILE_INTAKE_STATE = Object.freeze({
|
|
17
|
+
/** Plan carries only the base authoring sections; refinement has not run. */
|
|
18
|
+
NEW_PLAN_NEEDS_REFINEMENT: "new_plan_needs_refinement",
|
|
19
|
+
/** Plan also carries Acceptance criteria + Definition of done; refinement already ran. */
|
|
20
|
+
PLAN_REFINED_READY_FOR_PROMOTION: "plan_refined_ready_for_promotion",
|
|
21
|
+
/** Inputs are ambiguous, conflicting, or unusable; fail closed. */
|
|
22
|
+
AMBIGUOUS_FAIL_CLOSED: "ambiguous_fail_closed",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Refinement-marker sections a plan gains once refinement has run on top of the
|
|
27
|
+
* base authoring sections.
|
|
28
|
+
*/
|
|
29
|
+
export const PLAN_FILE_REFINEMENT_SECTIONS = Object.freeze(["Acceptance criteria", "Definition of done"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pure intake-state classifier.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} facts
|
|
35
|
+
* @param {boolean} facts.baseSectionsValid whether the plan passes the base-section validator (Status/Objective/In scope/Explicit non-goals)
|
|
36
|
+
* @param {boolean} facts.hasAcceptanceCriteria whether a non-empty Acceptance criteria section is present
|
|
37
|
+
* @param {boolean} facts.hasDefinitionOfDone whether a non-empty Definition of done section is present
|
|
38
|
+
* @returns {{ state: string }} one of PLAN_FILE_INTAKE_STATE values
|
|
39
|
+
*/
|
|
40
|
+
export function evaluatePlanFileIntakeState({ baseSectionsValid, hasAcceptanceCriteria, hasDefinitionOfDone } = {}) {
|
|
41
|
+
// A plan that fails the base contract should already have been rejected by the
|
|
42
|
+
// caller; treating it as intake input here is ambiguous, so fail closed.
|
|
43
|
+
if (baseSectionsValid !== true) {
|
|
44
|
+
return { state: PLAN_FILE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
|
|
45
|
+
}
|
|
46
|
+
// Refinement markers must be present as a pair. A plan carrying exactly one of
|
|
47
|
+
// the two refinement sections is partially refined in an undefined way; fail
|
|
48
|
+
// closed instead of guessing whether to refine or promote.
|
|
49
|
+
if (hasAcceptanceCriteria === true && hasDefinitionOfDone === true) {
|
|
50
|
+
return { state: PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION };
|
|
51
|
+
}
|
|
52
|
+
if (hasAcceptanceCriteria !== true && hasDefinitionOfDone !== true) {
|
|
53
|
+
return { state: PLAN_FILE_INTAKE_STATE.NEW_PLAN_NEEDS_REFINEMENT };
|
|
54
|
+
}
|
|
55
|
+
return { state: PLAN_FILE_INTAKE_STATE.AMBIGUOUS_FAIL_CLOSED };
|
|
56
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-planning plan-file PR-FIRST promotion (P4).
|
|
3
|
+
*
|
|
4
|
+
* This is the P4 promotion step. A plan refined to the
|
|
5
|
+
* `plan_refined_ready_for_promotion` state (P3 refinement produces it; P2
|
|
6
|
+
* defines the intake-state machine) is the spec-of-record. Promotion
|
|
7
|
+
* commits that plan doc and opens EXACTLY ONE draft PR — it never mints a
|
|
8
|
+
* GitHub issue. The PR body links the committed plan doc path and carries the
|
|
9
|
+
* full Acceptance criteria + Definition of done so the PR is a self-contained
|
|
10
|
+
* spec-of-record that can enter the existing PR-followup loop unchanged
|
|
11
|
+
* (`loop startup --pr <n>`), with no new lifecycle state and no issue.
|
|
12
|
+
*
|
|
13
|
+
* This module is pure: it decides promote-eligibility, parses/serializes the
|
|
14
|
+
* plan↔PR link (a minimal YAML front-matter block), and builds the PR body
|
|
15
|
+
* text. It performs no GitHub mutation, no network calls, and no filesystem
|
|
16
|
+
* I/O. The CLI owns ALL I/O (read plan file, git add/commit/branch, call
|
|
17
|
+
* create-pr.mjs, write the PR number back). That keeps the
|
|
18
|
+
* no-issue-mint / zero-pre-promotion-mutation guarantee structural here: there
|
|
19
|
+
* is no gh/network surface to reach from this module.
|
|
20
|
+
*
|
|
21
|
+
* It composes the already-shipped P2 intake-state machine: promotion is only
|
|
22
|
+
* eligible from `plan_refined_ready_for_promotion` (the state P3 refinement
|
|
23
|
+
* produces).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
evaluatePlanFileIntakeState,
|
|
28
|
+
PLAN_FILE_INTAKE_STATE,
|
|
29
|
+
} from "./plan-file-intake-contract.mjs";
|
|
30
|
+
|
|
31
|
+
/** Promotion actions the eligibility decision can return. */
|
|
32
|
+
export const PLAN_FILE_PROMOTE_ACTION = Object.freeze({
|
|
33
|
+
/** Eligible: commit the plan doc and open exactly one draft PR. */
|
|
34
|
+
PROMOTE: "promote",
|
|
35
|
+
/** Already linked to an open PR; idempotent no-op (report the existing PR). */
|
|
36
|
+
ALREADY_PROMOTED: "already_promoted",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Front-matter key that records the opened PR number on the plan doc, forming
|
|
41
|
+
* the plan→PR half of the bidirectional link (the PR body carries the doc path,
|
|
42
|
+
* the PR→plan half).
|
|
43
|
+
*/
|
|
44
|
+
export const PLAN_FILE_PR_FRONT_MATTER_KEY = "prNumber";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal additive front-matter support for plan files (an escalated extension
|
|
48
|
+
* to P1's format): a leading `---\n...\n---\n` block of simple `key: value`
|
|
49
|
+
* lines. Plans without a leading `---` are returned with an empty front-matter
|
|
50
|
+
* object and the full text as the body, so existing front-matter-free plans are
|
|
51
|
+
* never broken.
|
|
52
|
+
*
|
|
53
|
+
* ponytail: a flat scalar `key: value` parser, not a YAML engine. The only
|
|
54
|
+
* front-matter this contract reads/writes is `prNumber:` (an integer); upgrade
|
|
55
|
+
* to the `yaml` dep already in @dev-loops/core if richer front-matter is needed.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} markdownText
|
|
58
|
+
* @returns {{ frontMatter: Record<string, string>, body: string }}
|
|
59
|
+
*/
|
|
60
|
+
export function parsePlanFrontMatter(markdownText) {
|
|
61
|
+
const text = typeof markdownText === "string" ? markdownText : "";
|
|
62
|
+
// The opening fence must be the very first line.
|
|
63
|
+
const fenceMatch = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/u.exec(text);
|
|
64
|
+
if (!fenceMatch || fenceMatch.index !== 0) {
|
|
65
|
+
return { frontMatter: {}, body: text };
|
|
66
|
+
}
|
|
67
|
+
const block = fenceMatch[1];
|
|
68
|
+
const body = text.slice(fenceMatch[0].length);
|
|
69
|
+
const frontMatter = {};
|
|
70
|
+
for (const rawLine of block.split(/\r?\n/u)) {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
if (line.length === 0) continue;
|
|
73
|
+
const sep = line.indexOf(":");
|
|
74
|
+
if (sep === -1) continue;
|
|
75
|
+
const key = line.slice(0, sep).trim();
|
|
76
|
+
if (key.length === 0) continue;
|
|
77
|
+
// Plan content is untrusted: never let prototype-pollution keys through.
|
|
78
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
|
79
|
+
frontMatter[key] = line.slice(sep + 1).trim();
|
|
80
|
+
}
|
|
81
|
+
return { frontMatter, body };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the linked PR number from a plan's front-matter. Returns a positive
|
|
86
|
+
* integer when present and valid, otherwise null.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} markdownText
|
|
89
|
+
* @returns {number | null}
|
|
90
|
+
*/
|
|
91
|
+
export function readLinkedPrNumber(markdownText) {
|
|
92
|
+
const { frontMatter } = parsePlanFrontMatter(markdownText);
|
|
93
|
+
const raw = frontMatter[PLAN_FILE_PR_FRONT_MATTER_KEY];
|
|
94
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
95
|
+
if (!/^\d+$/u.test(raw)) return null;
|
|
96
|
+
const n = Number.parseInt(raw, 10);
|
|
97
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Serialize a plan back to text with the given PR number recorded in
|
|
102
|
+
* front-matter (the plan→PR link). Preserves an existing leading front-matter
|
|
103
|
+
* block's other keys and sets/replaces `prNumber`; adds a fresh block to a plan
|
|
104
|
+
* that had none. Idempotent: re-serializing with the same number reproduces the
|
|
105
|
+
* same text.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} markdownText current plan text (with or without front-matter)
|
|
108
|
+
* @param {number} prNumber positive integer PR number to record
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function writeLinkedPrNumber(markdownText, prNumber) {
|
|
112
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
113
|
+
throw new Error("writeLinkedPrNumber requires a positive integer prNumber");
|
|
114
|
+
}
|
|
115
|
+
const { frontMatter, body } = parsePlanFrontMatter(markdownText);
|
|
116
|
+
const merged = { ...frontMatter, [PLAN_FILE_PR_FRONT_MATTER_KEY]: String(prNumber) };
|
|
117
|
+
const lines = Object.entries(merged).map(([key, value]) => `${key}: ${value}`);
|
|
118
|
+
return `---\n${lines.join("\n")}\n---\n${body}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pure promote-eligibility decision.
|
|
123
|
+
*
|
|
124
|
+
* Fail-closed: promotion is eligible ONLY from the
|
|
125
|
+
* `plan_refined_ready_for_promotion` state (produced by P3 refinement). Any other intake state returns
|
|
126
|
+
* `ok: false` with a reason and no action — the caller must make zero GitHub
|
|
127
|
+
* mutation on that path. When the plan already carries a linked PR number in
|
|
128
|
+
* front-matter, the decision is `already_promoted` (idempotent: open nothing,
|
|
129
|
+
* report the existing PR).
|
|
130
|
+
*
|
|
131
|
+
* @param {object} facts
|
|
132
|
+
* @param {boolean} facts.baseSectionsValid whether the plan passes the base-section validator
|
|
133
|
+
* @param {boolean} facts.hasAcceptanceCriteria whether a non-empty Acceptance criteria section is present
|
|
134
|
+
* @param {boolean} facts.hasDefinitionOfDone whether a non-empty Definition of done section is present
|
|
135
|
+
* @param {number|null} [facts.existingPrNumber] PR number already recorded in the plan's front-matter, if any
|
|
136
|
+
* @returns {{ ok: boolean, action?: string, reason?: string, planFileIntakeState?: string, existingPrNumber?: number }}
|
|
137
|
+
*/
|
|
138
|
+
export function evaluatePromoteEligibility({
|
|
139
|
+
baseSectionsValid,
|
|
140
|
+
hasAcceptanceCriteria,
|
|
141
|
+
hasDefinitionOfDone,
|
|
142
|
+
existingPrNumber = null,
|
|
143
|
+
} = {}) {
|
|
144
|
+
const state = evaluatePlanFileIntakeState({
|
|
145
|
+
baseSectionsValid,
|
|
146
|
+
hasAcceptanceCriteria,
|
|
147
|
+
hasDefinitionOfDone,
|
|
148
|
+
}).state;
|
|
149
|
+
|
|
150
|
+
// The ready gate: promotion only acts on a fully-refined plan. A plan that
|
|
151
|
+
// still needs refinement, is ambiguous, or fails the base contract is not
|
|
152
|
+
// promotable here; fail closed so the CLI makes no GitHub mutation.
|
|
153
|
+
if (state !== PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION) {
|
|
154
|
+
return { ok: false, reason: "not_ready_for_promotion", planFileIntakeState: state };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Idempotency: a plan already linked to a PR has been promoted. Report the
|
|
158
|
+
// existing PR and open nothing.
|
|
159
|
+
if (Number.isInteger(existingPrNumber) && existingPrNumber > 0) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
action: PLAN_FILE_PROMOTE_ACTION.ALREADY_PROMOTED,
|
|
163
|
+
planFileIntakeState: state,
|
|
164
|
+
existingPrNumber,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { ok: true, action: PLAN_FILE_PROMOTE_ACTION.PROMOTE, planFileIntakeState: state };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Neutralize GitHub issue-closing keywords (`closes #12`, `fixes #3`,
|
|
173
|
+
* `resolved #7`, …) by wrapping the keyword+reference in inline code, which
|
|
174
|
+
* GitHub does not parse as a closing reference. The AC/DoD section bodies are
|
|
175
|
+
* untrusted plan content; an accidental `Closes #123` in a plan would otherwise
|
|
176
|
+
* flow into the PR body and auto-close an unrelated issue on merge — breaking
|
|
177
|
+
* the PR-FIRST guarantee that promotion never closes a tracker artifact.
|
|
178
|
+
*
|
|
179
|
+
* ponytail: handles the realistic `#<n>` form; cross-repo (`owner/repo#n`) and
|
|
180
|
+
* full-URL closing refs are not neutralized — out of scope for local plans.
|
|
181
|
+
*/
|
|
182
|
+
function neutralizeIssueCloseKeywords(text) {
|
|
183
|
+
return String(text).replace(
|
|
184
|
+
/\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)(\s+)(#\d+)\b/giu,
|
|
185
|
+
"`$1$2$3`",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the draft-PR body for a promoted plan. The body is the self-contained
|
|
191
|
+
* spec-of-record: it references the committed plan doc path (the PR→plan link)
|
|
192
|
+
* and carries the FULL Acceptance criteria + Definition of done extracted from
|
|
193
|
+
* the refined plan, so the PR alone fully specifies the work.
|
|
194
|
+
*
|
|
195
|
+
* Deliberately no `Closes #N` / issue reference: PR-FIRST promotion never mints
|
|
196
|
+
* an issue, and the committed plan doc — not a tracker artifact — is the
|
|
197
|
+
* authority. Issue-closing keywords inside the embedded AC/DoD are neutralized
|
|
198
|
+
* so untrusted plan content cannot smuggle one in.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} params
|
|
201
|
+
* @param {string} params.planDocPath repo-relative path of the committed plan doc
|
|
202
|
+
* @param {string} params.acceptanceCriteria full Acceptance criteria section body
|
|
203
|
+
* @param {string} params.definitionOfDone full Definition of done section body
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
export function buildPromotionPrBody({ planDocPath, acceptanceCriteria, definitionOfDone } = {}) {
|
|
207
|
+
const docPath = String(planDocPath ?? "").trim();
|
|
208
|
+
const ac = String(acceptanceCriteria ?? "").trim();
|
|
209
|
+
const dod = String(definitionOfDone ?? "").trim();
|
|
210
|
+
if (docPath.length === 0) {
|
|
211
|
+
throw new Error("buildPromotionPrBody requires a planDocPath");
|
|
212
|
+
}
|
|
213
|
+
if (ac.length === 0) {
|
|
214
|
+
throw new Error("buildPromotionPrBody requires acceptanceCriteria");
|
|
215
|
+
}
|
|
216
|
+
if (dod.length === 0) {
|
|
217
|
+
throw new Error("buildPromotionPrBody requires definitionOfDone");
|
|
218
|
+
}
|
|
219
|
+
const safeAc = neutralizeIssueCloseKeywords(ac);
|
|
220
|
+
const safeDod = neutralizeIssueCloseKeywords(dod);
|
|
221
|
+
return [
|
|
222
|
+
`Spec-of-record: the committed plan doc \`${docPath}\` is the authority for this work.`,
|
|
223
|
+
"This PR was opened by PR-FIRST promotion; no tracker issue exists.",
|
|
224
|
+
"",
|
|
225
|
+
"## Acceptance criteria",
|
|
226
|
+
"",
|
|
227
|
+
safeAc,
|
|
228
|
+
"",
|
|
229
|
+
"## Definition of done",
|
|
230
|
+
"",
|
|
231
|
+
safeDod,
|
|
232
|
+
"",
|
|
233
|
+
].join("\n");
|
|
234
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-planning plan-file refine + human-review checkpoint (off-tracker).
|
|
3
|
+
*
|
|
4
|
+
* This is the P3 core noise-reduction step. It generalizes the proposal-first
|
|
5
|
+
* intake pattern (emit a local artifact, human-gate it, make zero tracker
|
|
6
|
+
* mutation, stop and ask) to plan files: the refined plan is written in-place to
|
|
7
|
+
* the single canonical plan file, the autonomous docs-grill runs as a step
|
|
8
|
+
* within refinement, and the loop stops at a local human-review checkpoint with
|
|
9
|
+
* the intake state advanced to `plan_refined_ready_for_promotion`.
|
|
10
|
+
*
|
|
11
|
+
* This module is pure: it transforms markdown text and reports a disposition. It
|
|
12
|
+
* performs no GitHub mutation, no network calls, and no filesystem I/O. The
|
|
13
|
+
* caller supplies the section-presence facts it already read (mirroring the
|
|
14
|
+
* `evaluatePlanFileIntakeState` precedent) and the refiner-produced payload, and
|
|
15
|
+
* writes the returned markdown back. That keeps the zero-tracker-mutation
|
|
16
|
+
* guarantee structural: there is no gh/network surface to reach from here.
|
|
17
|
+
*
|
|
18
|
+
* It composes the already-shipped contracts: P2 `evaluatePlanFileIntakeState` +
|
|
19
|
+
* `PLAN_FILE_REFINEMENT_SECTIONS` (this step acts on `new_plan_needs_refinement`
|
|
20
|
+
* and drives the transition to `plan_refined_ready_for_promotion`) and #948's
|
|
21
|
+
* docs-grill: the caller classifies each finding with `classifyDocsGrillFinding`
|
|
22
|
+
* and passes the dispositions in, and this module validates and records them.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
evaluatePlanFileIntakeState,
|
|
27
|
+
PLAN_FILE_INTAKE_STATE,
|
|
28
|
+
PLAN_FILE_REFINEMENT_SECTIONS,
|
|
29
|
+
} from "./plan-file-intake-contract.mjs";
|
|
30
|
+
|
|
31
|
+
/** The local human-review checkpoint surface the loop stops at on success. */
|
|
32
|
+
export const PLAN_FILE_REFINE_STOP = Object.freeze({
|
|
33
|
+
/** Refinement wrote the plan in-place; stop for local human review before any promotion. */
|
|
34
|
+
LOCAL_HUMAN_REVIEW: "local_human_review",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** The heading the recorded docs-grill findings live under in the plan file. */
|
|
38
|
+
export const DOCS_GRILL_FINDINGS_HEADING = "Docs-grill findings";
|
|
39
|
+
|
|
40
|
+
/** The heading the refiner coverage matrix lives under in the plan file. */
|
|
41
|
+
export const COVERAGE_MATRIX_HEADING = "Coverage matrix";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove an existing `## <heading>` section (heading + body up to the next H2)
|
|
45
|
+
* from markdown so a refine re-run replaces it rather than appending a duplicate.
|
|
46
|
+
* Returns the markdown unchanged when the heading is absent.
|
|
47
|
+
*/
|
|
48
|
+
function stripSection(markdownText, headingText) {
|
|
49
|
+
const escapedHeading = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
50
|
+
const headingPattern = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "imu");
|
|
51
|
+
const match = headingPattern.exec(markdownText);
|
|
52
|
+
if (!match || match.index === undefined) return markdownText;
|
|
53
|
+
const start = match.index;
|
|
54
|
+
const afterHeading = start + match[0].length;
|
|
55
|
+
const remaining = markdownText.slice(afterHeading);
|
|
56
|
+
const nextHeadingMatch = /^##\s+/imu.exec(remaining);
|
|
57
|
+
const end = nextHeadingMatch && nextHeadingMatch.index !== undefined
|
|
58
|
+
? afterHeading + nextHeadingMatch.index
|
|
59
|
+
: markdownText.length;
|
|
60
|
+
// Drop the section and collapse the blank-line gap it leaves behind.
|
|
61
|
+
return `${markdownText.slice(0, start)}${markdownText.slice(end)}`.replace(/\n{3,}/gu, "\n\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether markdown carries a `## <heading>` section marker. Used to re-derive
|
|
66
|
+
* the section-presence facts from the freshly-written text so the end-state
|
|
67
|
+
* check verifies the append actually happened (rather than re-asserting the
|
|
68
|
+
* inputs). ponytail: local copy of the section-detect regex; the shared section
|
|
69
|
+
* helper belongs in core once extractSection is lifted out of scripts/.
|
|
70
|
+
*/
|
|
71
|
+
function hasSection(markdownText, headingText) {
|
|
72
|
+
const escaped = headingText.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
73
|
+
return new RegExp(`^##\\s+${escaped}\\s*$`, "imu").test(markdownText);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Append a `## <heading>` section with the given body to markdown. */
|
|
77
|
+
function appendSection(markdownText, headingText, body) {
|
|
78
|
+
const trimmed = markdownText.replace(/\s+$/u, "");
|
|
79
|
+
return `${trimmed}\n\n## ${headingText}\n\n${String(body).trim()}\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render recorded docs-grill findings as a markdown body. Each finding lists the
|
|
84
|
+
* disposition `classifyDocsGrillFinding` assigned it. When the grill produced no
|
|
85
|
+
* findings, an explicit "none recorded" line keeps the section idempotent and
|
|
86
|
+
* non-empty so the plan file always carries the grill evidence.
|
|
87
|
+
*/
|
|
88
|
+
function renderGrillFindings(classified) {
|
|
89
|
+
if (classified.length === 0) {
|
|
90
|
+
return "- None recorded; the docs-grill step ran and surfaced no findings.";
|
|
91
|
+
}
|
|
92
|
+
return classified
|
|
93
|
+
.map((entry) => {
|
|
94
|
+
const summary = String(entry.summary ?? "").trim() || "(no summary)";
|
|
95
|
+
return `- [${entry.disposition}] (${entry.kind}) ${summary}`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Refine a plan file in-place and advance its intake state, then stop at the
|
|
102
|
+
* local human-review checkpoint.
|
|
103
|
+
*
|
|
104
|
+
* The caller reads the plan file and supplies the section-presence facts plus
|
|
105
|
+
* the refiner payload. On success it writes `refinedMarkdown` back to the same
|
|
106
|
+
* path (the single canonical artifact) and stops; it never promotes here.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} params
|
|
109
|
+
* @param {string} params.markdownText current plan-file markdown
|
|
110
|
+
* @param {boolean} params.baseSectionsValid whether the plan passes the base-section validator
|
|
111
|
+
* @param {boolean} params.hasAcceptanceCriteria whether the plan already carries a non-empty Acceptance criteria section marker (per the intake contract)
|
|
112
|
+
* @param {boolean} params.hasDefinitionOfDone whether the plan already carries a non-empty Definition of done section marker (per the intake contract)
|
|
113
|
+
* @param {object} params.payload refiner-produced refinement output
|
|
114
|
+
* @param {string} params.payload.acceptanceCriteria Acceptance criteria section body
|
|
115
|
+
* @param {string} params.payload.definitionOfDone Definition of done section body
|
|
116
|
+
* @param {string} params.payload.coverageMatrix AC/DoD/Non-goal coverage matrix (markdown table)
|
|
117
|
+
* @param {object[]} [params.payload.grillDispositions] docs-grill dispositions the caller pre-classified via #948's `classifyDocsGrillFinding`; each entry is `{ kind, summary, disposition }` and a null/invalid `disposition` fails the grill closed
|
|
118
|
+
* @returns {{
|
|
119
|
+
* ok: boolean,
|
|
120
|
+
* reason?: string,
|
|
121
|
+
* planFileIntakeState?: string,
|
|
122
|
+
* refinedMarkdown?: string,
|
|
123
|
+
* grillDispositions?: object[],
|
|
124
|
+
* stop?: { kind: string },
|
|
125
|
+
* }}
|
|
126
|
+
*/
|
|
127
|
+
export function refinePlanFileInPlace({
|
|
128
|
+
markdownText,
|
|
129
|
+
baseSectionsValid,
|
|
130
|
+
hasAcceptanceCriteria,
|
|
131
|
+
hasDefinitionOfDone,
|
|
132
|
+
payload,
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (typeof markdownText !== "string" || markdownText.length === 0) {
|
|
135
|
+
return { ok: false, reason: "missing_plan_markdown" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Gate on the starting intake state: this step only acts on a base-valid plan
|
|
139
|
+
// that has not yet been refined. A plan already carrying refinement markers, a
|
|
140
|
+
// partially-refined plan, or one failing the base contract is ambiguous here;
|
|
141
|
+
// fail closed without writing or advancing.
|
|
142
|
+
const startState = evaluatePlanFileIntakeState({
|
|
143
|
+
baseSectionsValid,
|
|
144
|
+
hasAcceptanceCriteria,
|
|
145
|
+
hasDefinitionOfDone,
|
|
146
|
+
}).state;
|
|
147
|
+
if (startState !== PLAN_FILE_INTAKE_STATE.NEW_PLAN_NEEDS_REFINEMENT) {
|
|
148
|
+
return { ok: false, reason: "not_in_new_plan_needs_refinement", planFileIntakeState: startState };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The refiner payload must carry the refinement contract: AC, DoD, and the
|
|
152
|
+
// coverage matrix. A missing/empty piece is a failed refine; fail closed.
|
|
153
|
+
if (!payload || typeof payload !== "object") {
|
|
154
|
+
return { ok: false, reason: "missing_refinement_payload", planFileIntakeState: startState };
|
|
155
|
+
}
|
|
156
|
+
const acceptanceCriteria = String(payload.acceptanceCriteria ?? "").trim();
|
|
157
|
+
const definitionOfDone = String(payload.definitionOfDone ?? "").trim();
|
|
158
|
+
const coverageMatrix = String(payload.coverageMatrix ?? "").trim();
|
|
159
|
+
if (!acceptanceCriteria) {
|
|
160
|
+
return { ok: false, reason: "missing_acceptance_criteria", planFileIntakeState: startState };
|
|
161
|
+
}
|
|
162
|
+
if (!definitionOfDone) {
|
|
163
|
+
return { ok: false, reason: "missing_definition_of_done", planFileIntakeState: startState };
|
|
164
|
+
}
|
|
165
|
+
if (!coverageMatrix) {
|
|
166
|
+
return { ok: false, reason: "missing_coverage_matrix", planFileIntakeState: startState };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The docs-grill runs as a step of refinement. The caller (the CLI, which owns
|
|
170
|
+
// I/O and the scripts/ boundary) classifies each finding with #948's
|
|
171
|
+
// `classifyDocsGrillFinding` and passes the dispositions in. This core module
|
|
172
|
+
// validates and renders them but does NOT import the classifier — keeping it
|
|
173
|
+
// free of any scripts/ import so the published @dev-loops/core package does not
|
|
174
|
+
// break for consumers. A missing or invalid disposition fails the grill closed
|
|
175
|
+
// so a malformed grill cannot advance the state.
|
|
176
|
+
const grillDispositions = Array.isArray(payload.grillDispositions) ? payload.grillDispositions : [];
|
|
177
|
+
for (const d of grillDispositions) {
|
|
178
|
+
if (!d || typeof d.disposition !== "string" || d.disposition.length === 0) {
|
|
179
|
+
return { ok: false, reason: "docs_grill_failed", planFileIntakeState: startState };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A managed section body must not contain a top-level `## ` heading: stripSection
|
|
184
|
+
// finds a section's end by scanning to the next `## `, so an embedded H2 in a body
|
|
185
|
+
// would break the strip-then-append idempotency on a re-run (the inner heading and
|
|
186
|
+
// its text would orphan into the document body). Fail closed on such a payload.
|
|
187
|
+
const grillBody = renderGrillFindings(grillDispositions);
|
|
188
|
+
if ([acceptanceCriteria, definitionOfDone, coverageMatrix, grillBody].some((b) => /^##\s/mu.test(String(b)))) {
|
|
189
|
+
return { ok: false, reason: "section_body_contains_heading", planFileIntakeState: startState };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Write the refinement sections in-place. Strip any prior copy first so a
|
|
193
|
+
// re-run replaces rather than duplicates (idempotency), then append the fresh
|
|
194
|
+
// sections in a stable order.
|
|
195
|
+
const [acHeading, dodHeading] = PLAN_FILE_REFINEMENT_SECTIONS;
|
|
196
|
+
let refinedMarkdown = markdownText;
|
|
197
|
+
for (const heading of [acHeading, dodHeading, COVERAGE_MATRIX_HEADING, DOCS_GRILL_FINDINGS_HEADING]) {
|
|
198
|
+
refinedMarkdown = stripSection(refinedMarkdown, heading);
|
|
199
|
+
}
|
|
200
|
+
refinedMarkdown = appendSection(refinedMarkdown, acHeading, acceptanceCriteria);
|
|
201
|
+
refinedMarkdown = appendSection(refinedMarkdown, dodHeading, definitionOfDone);
|
|
202
|
+
refinedMarkdown = appendSection(refinedMarkdown, COVERAGE_MATRIX_HEADING, coverageMatrix);
|
|
203
|
+
refinedMarkdown = appendSection(refinedMarkdown, DOCS_GRILL_FINDINGS_HEADING, grillBody);
|
|
204
|
+
|
|
205
|
+
// Re-derive the section-presence facts from the text the write just produced
|
|
206
|
+
// (not from the inputs) so this check actually verifies the append landed: a
|
|
207
|
+
// correct refine carries the base sections forward and adds both refinement
|
|
208
|
+
// markers, flipping the intake state to ready. A buggy rewrite that dropped a
|
|
209
|
+
// section is caught here and fails closed rather than advancing the state.
|
|
210
|
+
const endState = evaluatePlanFileIntakeState({
|
|
211
|
+
baseSectionsValid,
|
|
212
|
+
hasAcceptanceCriteria: hasSection(refinedMarkdown, acHeading),
|
|
213
|
+
hasDefinitionOfDone: hasSection(refinedMarkdown, dodHeading),
|
|
214
|
+
}).state;
|
|
215
|
+
if (endState !== PLAN_FILE_INTAKE_STATE.PLAN_REFINED_READY_FOR_PROMOTION) {
|
|
216
|
+
return { ok: false, reason: "refine_did_not_reach_ready", planFileIntakeState: endState };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
planFileIntakeState: endState,
|
|
222
|
+
refinedMarkdown,
|
|
223
|
+
grillDispositions,
|
|
224
|
+
// Generalized proposal-first stop: the refined plan is the local artifact,
|
|
225
|
+
// it is written in-place, and the loop stops here for human review before
|
|
226
|
+
// any promotion. No tracker artifact is created or mutated.
|
|
227
|
+
stop: { kind: PLAN_FILE_REFINE_STOP.LOCAL_HUMAN_REVIEW },
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -119,6 +119,19 @@ function normalizeMergeStateStatus(value) {
|
|
|
119
119
|
return value.trim().toUpperCase();
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function normalizeMergeable(value) {
|
|
123
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const upper = value.trim().toUpperCase();
|
|
128
|
+
if (upper === "MERGEABLE" || upper === "CONFLICTING" || upper === "UNKNOWN") {
|
|
129
|
+
return upper;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
122
135
|
function normalizeConflictFiles(value) {
|
|
123
136
|
if (!Array.isArray(value)) {
|
|
124
137
|
return [];
|
|
@@ -142,14 +155,18 @@ function hasBlockedMergeStatus(mergeStateStatus) {
|
|
|
142
155
|
return mergeStateStatus !== null && BLOCKED_MERGE_STATE_STATUSES.has(mergeStateStatus);
|
|
143
156
|
}
|
|
144
157
|
|
|
145
|
-
function formatBlockedMergeReason(mergeStateStatus, conflictFiles) {
|
|
158
|
+
function formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable = null) {
|
|
146
159
|
if (mergeStateStatus === "BEHIND") {
|
|
147
160
|
let reason = "Branch must be updated from base before entering any gate.";
|
|
148
161
|
reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
|
|
149
162
|
return reason;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
|
-
let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch, rerun validation, rerun gate detection, and only then resume the normal gate path.";
|
|
165
|
+
let reason = "The current branch conflicts with the base branch, so resolve the conflict locally on the PR branch (run `node scripts/loop/resolve-pr-conflicts.mjs --push` for the safe additive-CHANGELOG case), rerun validation, rerun gate detection, and only then resume the normal gate path.";
|
|
166
|
+
|
|
167
|
+
if (mergeable === "CONFLICTING") {
|
|
168
|
+
reason += " GitHub mergeable: CONFLICTING.";
|
|
169
|
+
}
|
|
153
170
|
|
|
154
171
|
if (mergeStateStatus !== null) {
|
|
155
172
|
reason += ` GitHub mergeStateStatus: ${mergeStateStatus}.`;
|
|
@@ -218,7 +235,23 @@ function buildRoundExhaustionGateEvidenceNote({ copilotReviewRoundCount, maxCopi
|
|
|
218
235
|
return `Copilot review rounds exhausted (${copilotReviewRoundCount}/${maxCopilotRounds}); current head has zero unresolved threads and green or credibly green CI, so pre_approval_gate fallback is allowed without another Copilot re-request.`;
|
|
219
236
|
}
|
|
220
237
|
|
|
221
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Render the (user-authored) rawCallViolations array into a bounded, single-line
|
|
240
|
+
* fragment for the gate failure reason. Collapses whitespace/newlines per entry,
|
|
241
|
+
* caps per-entry length, and caps the number of entries shown so a large or
|
|
242
|
+
* garbled checkpoint cannot bloat or break gate output. Still fails closed —
|
|
243
|
+
* this only formats the reason; the violation count above does the gating.
|
|
244
|
+
*/
|
|
245
|
+
function summarizeRawCallViolations(violations, { maxEntries = 10, maxEntryLen = 200 } = {}) {
|
|
246
|
+
const shown = violations.slice(0, maxEntries).map((v) => {
|
|
247
|
+
const flat = String(v).replace(/\s+/g, " ").trim();
|
|
248
|
+
return flat.length > maxEntryLen ? `${flat.slice(0, maxEntryLen)}…` : flat;
|
|
249
|
+
});
|
|
250
|
+
const more = violations.length - shown.length;
|
|
251
|
+
return more > 0 ? `${shown.join("; ")}; …(+${more} more)` : shown.join("; ");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function evaluateRetrospectiveMergeApproval(checkpoint, { developerMode = false } = {}) {
|
|
222
255
|
if (!checkpoint || typeof checkpoint !== "object") {
|
|
223
256
|
return { approved: false, reason: "No retrospective checkpoint was found." };
|
|
224
257
|
}
|
|
@@ -278,6 +311,37 @@ function evaluateRetrospectiveMergeApproval(checkpoint) {
|
|
|
278
311
|
return { approved: false, reason: "Retrospective is missing explicit `mergeRecommendation`." };
|
|
279
312
|
}
|
|
280
313
|
|
|
314
|
+
// internalToolingOnly: the loop's own execution must have used internal dev-loops
|
|
315
|
+
// tooling only — no agent-level raw `gh`/`python`/`python3`/`node -e` (issue #982).
|
|
316
|
+
// This is a DEVELOPER-MODE retro step: it enforces the dev-loops maintainers'
|
|
317
|
+
// own dogfooding discipline and is opt-in via `workflow.requireRetrospectiveInternalTooling`
|
|
318
|
+
// (default OFF). CONSUMERS of the extension are never blocked by it — they may
|
|
319
|
+
// legitimately use raw gh/python/node -e in their own workflow — so when the flag
|
|
320
|
+
// is OFF these fields are neither required nor enforced (a complete checkpoint
|
|
321
|
+
// without them passes exactly as it did before #982). When ON it fails closed:
|
|
322
|
+
// a complete checkpoint must explicitly attest a clean tooling record, and an OLD
|
|
323
|
+
// checkpoint missing `internalToolingOnly` fails (not a silent pass). Re-record the
|
|
324
|
+
// retrospective with the new fields to clear it.
|
|
325
|
+
if (developerMode) {
|
|
326
|
+
const internalToolingOnly = br !== null ? br.internalToolingOnly : checkpoint.internalToolingOnly;
|
|
327
|
+
if (internalToolingOnly !== true) {
|
|
328
|
+
return {
|
|
329
|
+
approved: false,
|
|
330
|
+
reason: "Retrospective does not attest internal-tooling-only execution (`internalToolingOnly: true` is required in developer mode; agent-level raw gh/python/node -e is a violation). — re-record the retrospective with internalToolingOnly + rawCallViolations.",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const rawCallViolations = br !== null ? br.rawCallViolations : checkpoint.rawCallViolations;
|
|
334
|
+
if (!Array.isArray(rawCallViolations)) {
|
|
335
|
+
return { approved: false, reason: "Retrospective is missing `rawCallViolations` (array; empty when clean). — re-record the retrospective with internalToolingOnly + rawCallViolations." };
|
|
336
|
+
}
|
|
337
|
+
if (rawCallViolations.length > 0) {
|
|
338
|
+
return {
|
|
339
|
+
approved: false,
|
|
340
|
+
reason: `Retrospective records ${rawCallViolations.length} raw-call violation(s) (agent-level gh/python/node -e): ${summarizeRawCallViolations(rawCallViolations)}.`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
281
345
|
return { approved: true, reason: null };
|
|
282
346
|
}
|
|
283
347
|
|
|
@@ -610,6 +674,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
610
674
|
? "internal_only"
|
|
611
675
|
: (typeof input.reviewMode === "string" ? input.reviewMode.trim().toLowerCase() : null);
|
|
612
676
|
const mergeStateStatus = normalizeMergeStateStatus(input.mergeStateStatus);
|
|
677
|
+
const mergeable = normalizeMergeable(input.mergeable);
|
|
613
678
|
const conflictFiles = normalizeConflictFiles(input.conflictFiles);
|
|
614
679
|
const ciStatus = normalizeCiStatus(input.ciStatus);
|
|
615
680
|
const draftGateRequireCi = input.draftGateRequireCi !== false;
|
|
@@ -617,6 +682,9 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
617
682
|
const maxCopilotRounds = normalizePositiveInteger(input.maxCopilotRounds);
|
|
618
683
|
const roundCapReached = maxCopilotRounds !== null && copilotReviewRoundCount >= maxCopilotRounds;
|
|
619
684
|
const requireRetrospectiveGate = input.requireRetrospectiveGate === true;
|
|
685
|
+
// Developer-mode flag (#982): only the dev-loops repo dogfooding itself enforces the
|
|
686
|
+
// internal-tooling-only retro discipline. Default OFF so consumer state changes pass.
|
|
687
|
+
const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
|
|
620
688
|
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
621
689
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
622
690
|
const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
|
|
@@ -694,7 +762,44 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
694
762
|
});
|
|
695
763
|
}
|
|
696
764
|
|
|
697
|
-
|
|
765
|
+
// Mergeability is a required precondition at every gate (issue #980). GitHub
|
|
766
|
+
// computes `mergeable` asynchronously, so an unsettled UNKNOWN must fail closed
|
|
767
|
+
// to a recheck — never a pass. The detect layer already re-polls a bounded
|
|
768
|
+
// number of times; if it still reads UNKNOWN here, hold gate progression and
|
|
769
|
+
// recheck rather than guess.
|
|
770
|
+
if (mergeable === "UNKNOWN") {
|
|
771
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.WAIT_FOR_CI]);
|
|
772
|
+
pushUnique(forbiddenActions, [
|
|
773
|
+
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
774
|
+
PR_CHECKPOINT_ACTION.RECONCILE_DRAFT_GATE,
|
|
775
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
776
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
777
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
778
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
779
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
780
|
+
]);
|
|
781
|
+
return buildResult({
|
|
782
|
+
repo: input.repo ?? null,
|
|
783
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
784
|
+
currentHeadSha,
|
|
785
|
+
lifecycleState: effectiveLifecycleState,
|
|
786
|
+
loopDisposition: DISPOSITION.PENDING,
|
|
787
|
+
gateBoundary: PR_CHECKPOINT.CONFLICT_RESOLUTION,
|
|
788
|
+
draftGateAlreadySatisfied,
|
|
789
|
+
draftGate,
|
|
790
|
+
preApprovalGate,
|
|
791
|
+
allowedNextActions,
|
|
792
|
+
forbiddenActions,
|
|
793
|
+
nextAction: PR_CHECKPOINT_ACTION.WAIT_FOR_CI,
|
|
794
|
+
reason: "GitHub has not yet computed mergeability (mergeable=UNKNOWN), so gate progression is held: recheck before proceeding rather than treating an unsettled merge state as clean.",
|
|
795
|
+
mergeStateStatus,
|
|
796
|
+
conflictFiles,
|
|
797
|
+
refinementArtifact,
|
|
798
|
+
copilotReviewRoundCount,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (hasBlockedMergeStatus(mergeStateStatus) || mergeable === "CONFLICTING" || conflictFiles.length > 0) {
|
|
698
803
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS]);
|
|
699
804
|
pushUnique(forbiddenActions, [
|
|
700
805
|
PR_CHECKPOINT_ACTION.RUN_DRAFT_GATE,
|
|
@@ -723,7 +828,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
723
828
|
allowedNextActions,
|
|
724
829
|
forbiddenActions,
|
|
725
830
|
nextAction: PR_CHECKPOINT_ACTION.RESOLVE_MERGE_CONFLICTS,
|
|
726
|
-
reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles),
|
|
831
|
+
reason: formatBlockedMergeReason(mergeStateStatus, conflictFiles, mergeable),
|
|
727
832
|
mergeStateStatus,
|
|
728
833
|
conflictFiles,
|
|
729
834
|
refinementArtifact,
|
|
@@ -911,7 +1016,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
911
1016
|
});
|
|
912
1017
|
}
|
|
913
1018
|
if (requireRetrospectiveGate) {
|
|
914
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1019
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
915
1020
|
if (!retrospectiveGate.approved) {
|
|
916
1021
|
return buildRetrospectiveGatePendingResult({
|
|
917
1022
|
input,
|
|
@@ -1183,7 +1288,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1183
1288
|
});
|
|
1184
1289
|
}
|
|
1185
1290
|
if (requireRetrospectiveGate) {
|
|
1186
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1291
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1187
1292
|
if (!retrospectiveGate.approved) {
|
|
1188
1293
|
return buildRetrospectiveGatePendingResult({
|
|
1189
1294
|
input,
|
|
@@ -1349,7 +1454,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1349
1454
|
});
|
|
1350
1455
|
}
|
|
1351
1456
|
if (requireRetrospectiveGate) {
|
|
1352
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1457
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1353
1458
|
if (!retrospectiveGate.approved) {
|
|
1354
1459
|
return buildRetrospectiveGatePendingResult({
|
|
1355
1460
|
input,
|
|
@@ -1509,7 +1614,7 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
1509
1614
|
});
|
|
1510
1615
|
}
|
|
1511
1616
|
if (requireRetrospectiveGate) {
|
|
1512
|
-
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint);
|
|
1617
|
+
const retrospectiveGate = evaluateRetrospectiveMergeApproval(retrospectiveCheckpoint, { developerMode: requireRetrospectiveInternalTooling });
|
|
1513
1618
|
if (!retrospectiveGate.approved) {
|
|
1514
1619
|
return buildRetrospectiveGatePendingResult({
|
|
1515
1620
|
input,
|
|
@@ -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
|
+
}
|