@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.
- package/package.json +7 -1
- package/src/claude/asset-generation.mjs +26 -0
- package/src/config/config.mjs +50 -2
- package/src/config/extension-defaults.yaml +32 -1
- package/src/loop/async-start-contract.mjs +9 -2
- 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 +160 -9
- package/src/loop/run-context.mjs +11 -4
- package/src/loop/spike-exit-contract.mjs +138 -0
- package/src/loop/spike-intake-contract.mjs +52 -0
- package/src/loop/ui-e2e-scoping.mjs +162 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,9 +51,12 @@
|
|
|
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",
|
|
59
|
+
"./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
|
|
54
60
|
"./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
|
|
55
61
|
"./harness": "./src/harness/index.mjs",
|
|
56
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
|
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.
|
|
@@ -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
|
-
|
|
176
|
+
markerHint +
|
|
170
177
|
"Repository-maintained workflow policy controls any exceptions.",
|
|
171
178
|
detectedMarker: null,
|
|
172
179
|
};
|
|
@@ -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
|
+
}
|