@brainervirus/workit-core 0.8.1 → 0.8.3
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 +1 -1
- package/src/core/detector.ts +65 -44
- package/src/core/docs-layout.ts +38 -10
- package/src/core/flow-state.ts +1133 -132
- package/src/core/handoff-context.ts +8 -0
- package/src/core/handoff-tools.ts +21 -1
- package/src/core/menu.ts +68 -0
- package/src/core/pr-create.ts +34 -3
- package/src/core/reminder.ts +31 -1
- package/src/core/sdd.ts +57 -0
- package/src/core/vcs-config.ts +10 -3
- package/templates/execution-contract.md +11 -0
- package/templates/superpowers-doc-contract.md +2 -0
|
@@ -124,5 +124,13 @@ export const buildHandoffContract = ({
|
|
|
124
124
|
.replace(/<SDD_DIR>/g, sddDir)
|
|
125
125
|
.replace(/<TASK_LIST>/g, taskList);
|
|
126
126
|
|
|
127
|
+
// Every generated destination contract carries the marker on its OWN LINE
|
|
128
|
+
// (CA-07): a canonical template that loses the sentinel — or embeds it
|
|
129
|
+
// mid-line — is a broken contract, not a handoff prompt. Anchored to the
|
|
130
|
+
// line start/end so a substring mid-line cannot satisfy the guard.
|
|
131
|
+
if (!/^<workflow-handoff-destination>true<\/workflow-handoff-destination>$/m.test(contract)) {
|
|
132
|
+
return { error: "handoff destination contract missing its destination marker" };
|
|
133
|
+
}
|
|
134
|
+
|
|
127
135
|
return { prompt: contract };
|
|
128
136
|
};
|
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { fail, ok, type Result } from "../core";
|
|
3
3
|
import { resolveWorkflowPaths, buildHandoffContract } from "./handoff-context";
|
|
4
4
|
import { assetRoot } from "./package-root";
|
|
5
|
+
import type { FlowGateResult } from "./flow-state";
|
|
5
6
|
|
|
6
7
|
type ApiResponse<T> = { data?: T; error?: unknown };
|
|
7
8
|
type ApiResult<T> = Promise<ApiResponse<T>>;
|
|
@@ -33,13 +34,21 @@ export type HandoffRequest = {
|
|
|
33
34
|
title: string;
|
|
34
35
|
prompt: string;
|
|
35
36
|
stay: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Destination marking hook (CA-07): invoked only after `promptAsync` succeeds
|
|
39
|
+
* and before any optional selection. The adapter supplies the core
|
|
40
|
+
* `markHandoffDestination` binding so destination state is marked atomically
|
|
41
|
+
* after the child session is seeded — never before creation/seed succeed and
|
|
42
|
+
* never undone by a later selection failure.
|
|
43
|
+
*/
|
|
44
|
+
afterSeed?: () => FlowGateResult | Promise<FlowGateResult>;
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
type HandoffData = {
|
|
39
48
|
sessionID?: string;
|
|
40
49
|
seeded?: boolean;
|
|
41
50
|
selected?: boolean;
|
|
42
|
-
stage?: "create" | "seed" | "select";
|
|
51
|
+
stage?: "create" | "seed" | "mark" | "select";
|
|
43
52
|
};
|
|
44
53
|
|
|
45
54
|
export const message = (error: unknown) =>
|
|
@@ -78,6 +87,17 @@ export async function handoffSession(
|
|
|
78
87
|
return fail(message(error), { sessionID, seeded: false, selected: false, stage: "seed" });
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
if (request.afterSeed) {
|
|
91
|
+
try {
|
|
92
|
+
const marked = await request.afterSeed();
|
|
93
|
+
if (!marked.ok) {
|
|
94
|
+
return fail(marked.error, { sessionID, seeded: true, selected: false, stage: "mark" });
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return fail(message(error), { sessionID, seeded: true, selected: false, stage: "mark" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
if (request.stay) return ok({ sessionID, seeded: true, selected: false });
|
|
82
102
|
|
|
83
103
|
try {
|
package/src/core/menu.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Host-neutral post-plan menu wording and the handoff destination marker
|
|
6
|
+
* (CA-07, CA-08). Import-light by design: session-start hooks and reminder
|
|
7
|
+
* selection consume these constants WITHOUT dragging in the full flow-state
|
|
8
|
+
* graph (approval digests, verification, repo-context), so a read-only hook
|
|
9
|
+
* stays network-free by source inspection (RL-09/CA-25).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Display labels the source menu presents (CA-08). */
|
|
13
|
+
export const SOURCE_MENU_LABELS = [
|
|
14
|
+
"Subagent-driven",
|
|
15
|
+
"Inline",
|
|
16
|
+
"Handoff",
|
|
17
|
+
"Review spec first",
|
|
18
|
+
"Review plan first",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** Display labels a marked destination presents — exactly four, no Handoff (CA-08). */
|
|
22
|
+
export const DESTINATION_MENU_LABELS = [
|
|
23
|
+
"Subagent-driven",
|
|
24
|
+
"Inline",
|
|
25
|
+
"Review spec first",
|
|
26
|
+
"Review plan first",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The exact sentinel every generated destination contract carries on its own
|
|
31
|
+
* line (CA-07). Host-neutral by design (CA-10): OpenCode seeded sessions,
|
|
32
|
+
* Cursor copy/paste prompts, and CLI output all detect a destination by this
|
|
33
|
+
* marker alone — no session IDs, parent IDs, or host metadata.
|
|
34
|
+
*/
|
|
35
|
+
export const HANDOFF_DESTINATION_MARKER =
|
|
36
|
+
"<workflow-handoff-destination>true</workflow-handoff-destination>";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Marked handoff destinations in a workspace (CA-07, CA-08): the persisted
|
|
40
|
+
* `handoff_destination: true` flag that `markHandoffDestination` sets atomically
|
|
41
|
+
* AFTER a genuine generated destination prompt succeeds. Hosts use this at
|
|
42
|
+
* session start to select the destination reminder (four choices, no Handoff)
|
|
43
|
+
* without session or parent IDs. The flag is cleared by approval-drift resets
|
|
44
|
+
* AND by completion (a completed flow is never a destination); only a new-flow
|
|
45
|
+
* `prepareFlowState` initializes it (false), and `markHandoffDestination` is
|
|
46
|
+
* the sole true-setter. Raw read only: a session-start hook must never
|
|
47
|
+
* reconcile or rewrite flow state, so a stale flag may read true until the
|
|
48
|
+
* next effective read resets it — safe bias toward destination wording.
|
|
49
|
+
* ponytail: raw flag scan, not readEffectiveFlowState — a hook is read-only;
|
|
50
|
+
* reconcile-on-drift is the mutation path's job.
|
|
51
|
+
*/
|
|
52
|
+
export const findMarkedDestinations = (root: string): string[] => {
|
|
53
|
+
const docsDir = path.join(root, "docs");
|
|
54
|
+
if (!existsSync(docsDir)) return [];
|
|
55
|
+
const slugs: string[] = [];
|
|
56
|
+
for (const slug of readdirSync(docsDir)) {
|
|
57
|
+
const file = path.join(docsDir, slug, "sdd", "flow.json");
|
|
58
|
+
if (!existsSync(file)) continue;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as { handoff_destination?: unknown };
|
|
61
|
+
if (parsed.handoff_destination === true) slugs.push(slug);
|
|
62
|
+
} catch {
|
|
63
|
+
// a malformed flow.json excludes the entry without touching it
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return slugs;
|
|
68
|
+
};
|
package/src/core/pr-create.ts
CHANGED
|
@@ -147,9 +147,15 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
147
147
|
// target is validated against the resolved branch policy so a PR can never
|
|
148
148
|
// be aimed at a protected or disallowed branch.
|
|
149
149
|
const targetOverride = env.WF_PR_TARGET;
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
const resolvedDefault = String(
|
|
151
|
+
cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop",
|
|
152
|
+
);
|
|
153
|
+
const target = targetOverride || resolvedDefault;
|
|
154
|
+
// CA-06: an explicit override equal to the resolved default (e.g. WF_PR_TARGET
|
|
155
|
+
// "main" under github-flow) is authoritative — the same value flows
|
|
156
|
+
// unvalidated from config, so it must not be rejected as a protected
|
|
157
|
+
// override. Genuine differing overrides keep the strict validation.
|
|
158
|
+
if (targetOverride && targetOverride !== resolvedDefault) {
|
|
153
159
|
const { allowed, protected: protectedTargets } = policy;
|
|
154
160
|
if (protectedTargets.has(targetOverride.toLowerCase()))
|
|
155
161
|
return {
|
|
@@ -283,6 +289,31 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
|
|
|
283
289
|
// uv_spawn fails with ENOENT even though the CLI is on PATH.
|
|
284
290
|
cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
|
|
285
291
|
} else {
|
|
292
|
+
// T2: GitHub has no --push flag — push the branch first so `gh pr create`
|
|
293
|
+
// never runs against an unpushed branch when pushBranch is enabled.
|
|
294
|
+
if (push) {
|
|
295
|
+
if (!branch) {
|
|
296
|
+
return {
|
|
297
|
+
error: "push failed",
|
|
298
|
+
provider,
|
|
299
|
+
mode: "push",
|
|
300
|
+
targetBranch: target,
|
|
301
|
+
stderr: "empty current branch (detached HEAD or unborn HEAD)",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const pushRes = spawnSync("git", ["push", "-u", "origin", branch], {
|
|
305
|
+
cwd: root,
|
|
306
|
+
encoding: "utf8",
|
|
307
|
+
});
|
|
308
|
+
if (pushRes.status !== 0) {
|
|
309
|
+
return {
|
|
310
|
+
error: "push failed",
|
|
311
|
+
provider,
|
|
312
|
+
targetBranch: target,
|
|
313
|
+
stderr: (pushRes.stderr ?? "").slice(0, 800),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
286
317
|
cmd = ["gh", "pr", "create", "--title", title, "--base", target];
|
|
287
318
|
if (finalBody) cmd.push("--body", finalBody);
|
|
288
319
|
if (draft) cmd.push("--draft");
|
package/src/core/reminder.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
|
+
import { DESTINATION_MENU_LABELS, HANDOFF_DESTINATION_MARKER, SOURCE_MENU_LABELS } from "./menu";
|
|
2
|
+
|
|
3
|
+
// Display-only source label list: the "(new session only)" qualifier appears in
|
|
4
|
+
// the reminder PROSE like the other source surfaces (bootstrap.ts, session-start,
|
|
5
|
+
// superpowers-doc-contract.md, ask-question-only.mdc), never in the machine
|
|
6
|
+
// label tuple `SOURCE_MENU_LABELS` — the receipt label must stay exactly
|
|
7
|
+
// `Handoff` for the native-question match (AR-12).
|
|
8
|
+
const SOURCE_MENU_LABELS_DISPLAY = SOURCE_MENU_LABELS.map((label) =>
|
|
9
|
+
label === "Handoff" ? "Handoff (new session only)" : label,
|
|
10
|
+
);
|
|
11
|
+
|
|
1
12
|
export const REMINDER_TEXT = `<workflow-contract-reminder>
|
|
2
13
|
- Bounded user choices → call the native \`question\` tool (never A/B/C or 1/2/3 lists in prose).
|
|
3
|
-
- After a plan is approved → native \`question\` menu with exactly:
|
|
14
|
+
- After a plan is approved → native \`question\` menu with exactly: ${SOURCE_MENU_LABELS_DISPLAY.join(", ")}.
|
|
4
15
|
- Tools with \`confirmed\` → call them; never fabricate their result.
|
|
5
16
|
- Before the first \`workflow_spec_approve\`/\`workflow_plan_approve\` (self-review) run the superpowers writing-plans Self-Review checklist: spec coverage (every spec requirement maps to a task), placeholder scan, type consistency; fix findings inline.
|
|
6
17
|
- Delivering docs → clickable markdown link \`[spec.md](docs/<slug>/spec.md)\` + 3-5 bullet summary.
|
|
7
18
|
</workflow-contract-reminder>`;
|
|
8
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Destination reminder (CA-07, CA-08): a marked handoff destination carries the
|
|
22
|
+
* exact marker and the four-label allow-list — never the originating Handoff
|
|
23
|
+
* choice. Hosts select source vs destination wording via `reminderTextFor`
|
|
24
|
+
* from `FlowState.handoff_destination`; the strings live here, not in adapters.
|
|
25
|
+
*/
|
|
26
|
+
export const DESTINATION_REMINDER_TEXT = `<workflow-contract-reminder>
|
|
27
|
+
- Bounded user choices → call the native \`question\` tool (never A/B/C or 1/2/3 lists in prose).
|
|
28
|
+
- This session is a handoff destination: present the post-plan menu with exactly: ${DESTINATION_MENU_LABELS.join(", ")}.
|
|
29
|
+
- Tools with \`confirmed\` → call them; never fabricate their result.
|
|
30
|
+
- Before the first \`workflow_spec_approve\`/\`workflow_plan_approve\` (self-review) run the superpowers writing-plans Self-Review checklist: spec coverage (every spec requirement maps to a task), placeholder scan, type consistency; fix findings inline.
|
|
31
|
+
- Delivering docs → clickable markdown link \`[spec.md](docs/<slug>/spec.md)\` + 3-5 bullet summary.
|
|
32
|
+
${HANDOFF_DESTINATION_MARKER}
|
|
33
|
+
</workflow-contract-reminder>`;
|
|
34
|
+
|
|
35
|
+
/** Select source vs destination reminder wording from the flow's destination flag (CA-08). */
|
|
36
|
+
export const reminderTextFor = (destination: boolean): string =>
|
|
37
|
+
destination ? DESTINATION_REMINDER_TEXT : REMINDER_TEXT;
|
|
38
|
+
|
|
9
39
|
export const DETECTION_TEXT = `<workflow-detection>
|
|
10
40
|
Your previous message presented choices as a numbered/bulleted list in prose.
|
|
11
41
|
That is a bounded user choice — use the native \`question\` tool instead (re-ask with \`question\` if still relevant).
|
package/src/core/sdd.ts
CHANGED
|
@@ -34,6 +34,63 @@ export function todosFromTasks(
|
|
|
34
34
|
return todos;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** SDD ledger completeness facts shared by lifecycle migration, completion,
|
|
38
|
+
* and active-plan detection (Task 2). */
|
|
39
|
+
export type LedgerCompletion = {
|
|
40
|
+
/** True only when progress.md contains at least one `Task N:` record. */
|
|
41
|
+
started: boolean;
|
|
42
|
+
/** True only when every canonical plan task id appears completed. */
|
|
43
|
+
complete: boolean;
|
|
44
|
+
required: number[];
|
|
45
|
+
completed: number[];
|
|
46
|
+
missing: number[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the canonical `docs/<slug>/sdd/progress.md` ledger and the plan's
|
|
51
|
+
* `### Task N:` headings. Fail-closed: a missing or unreadable ledger yields
|
|
52
|
+
* `started: false`, and a missing/unreadable plan yields an empty `required`
|
|
53
|
+
* set (never a partial trust of a stale ledger). `complete` requires a
|
|
54
|
+
* non-empty required set fully covered by completed task ids, so an empty plan
|
|
55
|
+
* can never read as "done".
|
|
56
|
+
*/
|
|
57
|
+
export function ledgerCompletion(root: string, slug: string): LedgerCompletion {
|
|
58
|
+
let started = false;
|
|
59
|
+
const completed: number[] = [];
|
|
60
|
+
const absProgress = path.join(root, "docs", slug, "sdd", "progress.md");
|
|
61
|
+
if (existsSync(absProgress)) {
|
|
62
|
+
try {
|
|
63
|
+
for (const line of readFileSync(absProgress, "utf8").split("\n")) {
|
|
64
|
+
const match = /^Task\s+(\d+):/i.exec(line);
|
|
65
|
+
if (match) {
|
|
66
|
+
started = true;
|
|
67
|
+
if (/^Task\s+\d+:\s*complete\b/i.test(line)) completed.push(Number(match[1]));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// unreadable ledger: started stays false (fail-closed)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const required: number[] = [];
|
|
75
|
+
try {
|
|
76
|
+
const absPlan = path.join(root, "docs", slug, "plan.md");
|
|
77
|
+
if (existsSync(absPlan)) {
|
|
78
|
+
for (const task of parseTasksFromPlan(readFileSync(absPlan, "utf8"))) required.push(task.id);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// unreadable/missing plan: required stays empty (fail-closed)
|
|
82
|
+
}
|
|
83
|
+
const completedSet = new Set(completed);
|
|
84
|
+
const missing = required.filter((id) => !completedSet.has(id));
|
|
85
|
+
return {
|
|
86
|
+
started,
|
|
87
|
+
complete: required.length > 0 && missing.length === 0,
|
|
88
|
+
required,
|
|
89
|
+
completed,
|
|
90
|
+
missing,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
37
94
|
export function sddContext({
|
|
38
95
|
slug,
|
|
39
96
|
plan_path,
|
package/src/core/vcs-config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { configDir } from "./config";
|
|
4
|
+
import { configDir, PRESETS } from "./config";
|
|
5
5
|
import { resolveWorkspace } from "./workspaces";
|
|
6
6
|
import { resolveBranchPolicyFor } from "./branch";
|
|
7
7
|
// Ports of scripts/vcs/config.sh + verify-token.sh + token-create-urls.sh + merged-style.sh.
|
|
@@ -90,10 +90,17 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
90
90
|
// and resolve so both surfaces stay consistent.
|
|
91
91
|
// CA-09: the one resolver wrapper — same policy resolution every consumer
|
|
92
92
|
// uses, so the tightened gate in branch-policy-resolver.test.ts stays green.
|
|
93
|
+
// CA-02: a matched workspace's OWN branchPolicy default beats any global
|
|
94
|
+
// vcs.json default (PR #43: global develop shadowed the personal github-flow
|
|
95
|
+
// main). Explicit workspace vcs.defaultTargetBranch stays authoritative; a
|
|
96
|
+
// workspace without a branchPolicy still falls back to the global vcs.json
|
|
97
|
+
// default, and unmatched repos keep it too.
|
|
98
|
+
const wp = (ws?.branchPolicy ?? {}) as Record<string, any>;
|
|
99
|
+
const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
|
|
100
|
+
const policyDefault = resolveBranchPolicyFor(root).defaultTargetBranch;
|
|
93
101
|
const defaultTarget = String(
|
|
94
102
|
wsVcs.defaultTargetBranch ??
|
|
95
|
-
cfg.defaultTargetBranch ??
|
|
96
|
-
resolveBranchPolicyFor(root).defaultTargetBranch ??
|
|
103
|
+
(hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
|
|
97
104
|
"develop",
|
|
98
105
|
);
|
|
99
106
|
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
@@ -5,6 +5,17 @@ Load `using-superpowers`, `subagent-driven-development`, `test-driven-developmen
|
|
|
5
5
|
**Branch:** <BRANCH>
|
|
6
6
|
**SDD:** `<SDD_DIR>`
|
|
7
7
|
|
|
8
|
+
## Handoff destination
|
|
9
|
+
|
|
10
|
+
This session is a handoff destination for a continued plan. The originating session already recorded the post-plan menu choice; present exactly these four choices and never re-offer the originating handoff option:
|
|
11
|
+
|
|
12
|
+
- Subagent-driven
|
|
13
|
+
- Inline
|
|
14
|
+
- Review spec first
|
|
15
|
+
- Review plan first
|
|
16
|
+
|
|
17
|
+
<workflow-handoff-destination>true</workflow-handoff-destination>
|
|
18
|
+
|
|
8
19
|
## Hard gates
|
|
9
20
|
|
|
10
21
|
- The parent is coordinator-only: it does not edit product code or perform delegated exploration.
|
|
@@ -63,6 +63,8 @@ On success, use native `question` / Cursor `AskQuestion` with exactly these opti
|
|
|
63
63
|
|
|
64
64
|
Never emit Superpowers text beginning “Two execution options”.
|
|
65
65
|
|
|
66
|
+
A handoff destination session (the seeded contract carries `<workflow-handoff-destination>true</workflow-handoff-destination>`) presents exactly four choices — Subagent-driven, Inline, Review spec first, Review plan first — and never re-offers the originating handoff option.
|
|
67
|
+
|
|
66
68
|
- Specs/plans must follow `templates/spec-template.md` / `templates/plan-template.md` (mandated diagrams, tables, CA-XX).
|
|
67
69
|
|
|
68
70
|
## Doc delivery
|