opencode-discipline 0.1.0 → 0.1.2
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/dist/index.js +249 -156
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12619,20 +12619,18 @@ var WAVE_NEXT_STEPS = {
|
|
|
12619
12619
|
3: "writing the plan file",
|
|
12620
12620
|
4: "plan review and refinement"
|
|
12621
12621
|
};
|
|
12622
|
+
function hasStringProp(obj, key) {
|
|
12623
|
+
return key in obj && typeof obj[key] === "string";
|
|
12624
|
+
}
|
|
12622
12625
|
function extractSessionID(result) {
|
|
12623
12626
|
if (!result || typeof result !== "object") {
|
|
12624
12627
|
return;
|
|
12625
12628
|
}
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
return value.id;
|
|
12629
|
+
if (hasStringProp(result, "id")) {
|
|
12630
|
+
return result.id;
|
|
12629
12631
|
}
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
const dataRecord = data;
|
|
12633
|
-
if (typeof dataRecord.id === "string") {
|
|
12634
|
-
return dataRecord.id;
|
|
12635
|
-
}
|
|
12632
|
+
if ("data" in result && result.data && typeof result.data === "object" && hasStringProp(result.data, "id")) {
|
|
12633
|
+
return result.data.id;
|
|
12636
12634
|
}
|
|
12637
12635
|
return;
|
|
12638
12636
|
}
|
|
@@ -12703,163 +12701,258 @@ function buildCompactionContext(state) {
|
|
|
12703
12701
|
].join(`
|
|
12704
12702
|
`);
|
|
12705
12703
|
}
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
12712
|
-
|
|
12704
|
+
function buildTodoSeedContent(planName) {
|
|
12705
|
+
return `Plan kickoff: ${planName}`;
|
|
12706
|
+
}
|
|
12707
|
+
function buildTodoSeedInstruction(planName, retry) {
|
|
12708
|
+
const content = buildTodoSeedContent(planName);
|
|
12709
|
+
const title = retry ? "## Discipline Plugin \u2014 Todo Seed Retry" : "## Discipline Plugin \u2014 Todo Seed";
|
|
12710
|
+
const intro = retry ? "The plan kickoff todo is still missing in the right panel." : "Before continuing, create the kickoff todo so progress is visible in the right panel.";
|
|
12711
|
+
return [
|
|
12712
|
+
title,
|
|
12713
|
+
intro,
|
|
12714
|
+
"Call `todowrite` now with this item:",
|
|
12715
|
+
`- content: \`${content}\``,
|
|
12716
|
+
"- status: `in_progress`",
|
|
12717
|
+
"- priority: `high`"
|
|
12718
|
+
].join(`
|
|
12719
|
+
`);
|
|
12720
|
+
}
|
|
12721
|
+
function extractTodos(response) {
|
|
12722
|
+
if (Array.isArray(response)) {
|
|
12723
|
+
return response.filter((item) => {
|
|
12724
|
+
return typeof item === "object" && item !== null && typeof item.content === "string" && typeof item.status === "string" && typeof item.priority === "string";
|
|
12725
|
+
});
|
|
12726
|
+
}
|
|
12727
|
+
if (response && typeof response === "object") {
|
|
12728
|
+
const data = response.data;
|
|
12729
|
+
if (Array.isArray(data)) {
|
|
12730
|
+
return extractTodos(data);
|
|
12731
|
+
}
|
|
12732
|
+
}
|
|
12733
|
+
return [];
|
|
12734
|
+
}
|
|
12735
|
+
function hasPlanKickoffTodo(todos, planName) {
|
|
12736
|
+
const expected = buildTodoSeedContent(planName).toLowerCase();
|
|
12737
|
+
return todos.some((todo) => todo.content.toLowerCase().includes(expected));
|
|
12738
|
+
}
|
|
12739
|
+
async function readSessionTodos(client, directory, sessionID) {
|
|
12740
|
+
const sessionApi = client.session;
|
|
12741
|
+
if (!sessionApi || typeof sessionApi.todo !== "function") {
|
|
12742
|
+
return;
|
|
12743
|
+
}
|
|
12744
|
+
try {
|
|
12745
|
+
const response = await sessionApi.todo({
|
|
12746
|
+
query: { directory },
|
|
12747
|
+
path: { id: sessionID }
|
|
12748
|
+
});
|
|
12749
|
+
return extractTodos(response);
|
|
12750
|
+
} catch (error45) {
|
|
12751
|
+
console.warn("[discipline] readSessionTodos failed:", error45);
|
|
12752
|
+
return;
|
|
12753
|
+
}
|
|
12754
|
+
}
|
|
12755
|
+
async function handleCompacting(ctx, input, output) {
|
|
12756
|
+
const state = ctx.manager.getState(input.sessionID);
|
|
12757
|
+
if (!state) {
|
|
12758
|
+
return;
|
|
12759
|
+
}
|
|
12760
|
+
output.context.push(buildCompactionContext(state));
|
|
12761
|
+
}
|
|
12762
|
+
async function handleSystemTransform(ctx, input, output) {
|
|
12763
|
+
const sessionID = input.sessionID;
|
|
12764
|
+
const state = sessionID ? ctx.manager.getState(sessionID) : undefined;
|
|
12765
|
+
if (!state) {
|
|
12766
|
+
output.system.push("## Discipline Plugin\nYou are operating under the opencode-discipline plugin. To start a structured plan, call `advance_wave` to begin Wave 1 (Interview).");
|
|
12767
|
+
return;
|
|
12768
|
+
}
|
|
12769
|
+
output.system.push(buildWaveStateSystemBlock(state.wave, state.planName));
|
|
12770
|
+
const currentAgent = input.agent;
|
|
12771
|
+
if (currentAgent === "plan" && state.wave < 3) {
|
|
12772
|
+
output.system.push(buildPlanReadOnlyReminder());
|
|
12773
|
+
}
|
|
12774
|
+
if (sessionID && currentAgent === "plan" && !state.accepted) {
|
|
12775
|
+
const nudgeState = ctx.todoNudges.get(sessionID) ?? {
|
|
12776
|
+
prompted: false,
|
|
12777
|
+
retried: false,
|
|
12778
|
+
seeded: false
|
|
12779
|
+
};
|
|
12780
|
+
const todos = await readSessionTodos(ctx.client, ctx.directory, sessionID);
|
|
12781
|
+
const hasSeededTodo = todos !== undefined && hasPlanKickoffTodo(todos, state.planName);
|
|
12782
|
+
if (hasSeededTodo) {
|
|
12783
|
+
nudgeState.seeded = true;
|
|
12784
|
+
}
|
|
12785
|
+
if (!nudgeState.seeded) {
|
|
12786
|
+
if (!nudgeState.prompted) {
|
|
12787
|
+
output.system.push(buildTodoSeedInstruction(state.planName, false));
|
|
12788
|
+
nudgeState.prompted = true;
|
|
12789
|
+
} else if (todos !== undefined && !hasSeededTodo && !nudgeState.retried) {
|
|
12790
|
+
output.system.push(buildTodoSeedInstruction(state.planName, true));
|
|
12791
|
+
nudgeState.retried = true;
|
|
12713
12792
|
}
|
|
12714
|
-
|
|
12715
|
-
|
|
12716
|
-
|
|
12717
|
-
|
|
12718
|
-
|
|
12719
|
-
|
|
12720
|
-
|
|
12721
|
-
|
|
12793
|
+
}
|
|
12794
|
+
ctx.todoNudges.set(sessionID, nudgeState);
|
|
12795
|
+
}
|
|
12796
|
+
}
|
|
12797
|
+
async function handleToolExecuteBefore(ctx, input, output) {
|
|
12798
|
+
const filePath = output.args?.filePath;
|
|
12799
|
+
if (typeof filePath === "string") {
|
|
12800
|
+
if (input.tool === "read" && isBlockedEnvRead(filePath)) {
|
|
12801
|
+
throw new Error("Reading .env files is blocked by opencode-discipline.");
|
|
12802
|
+
}
|
|
12803
|
+
if (input.tool === "edit" || input.tool === "write") {
|
|
12804
|
+
const state = ctx.manager.getState(input.sessionID);
|
|
12805
|
+
if (state?.accepted) {
|
|
12806
|
+
throw new Error('Plan session is read-only after accept_plan(action="accept"). Switch to Build for implementation.');
|
|
12722
12807
|
}
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
if (currentAgent === "plan" && state.wave < 3) {
|
|
12726
|
-
output.system.push(buildPlanReadOnlyReminder());
|
|
12808
|
+
if (state && state.wave < 3 && isPlanMarkdownFile(ctx.worktree, filePath)) {
|
|
12809
|
+
throw new Error(`Cannot write plan files until Wave 3 (Plan Generation). Current wave: ${state.wave}. Call advance_wave to progress.`);
|
|
12727
12810
|
}
|
|
12811
|
+
}
|
|
12812
|
+
}
|
|
12813
|
+
}
|
|
12814
|
+
function createAdvanceWaveTool(ctx) {
|
|
12815
|
+
return tool({
|
|
12816
|
+
description: "Advance the planning wave. Call this to move from one wave to the next. Wave 1: Interview, Wave 2: Gap Analysis, Wave 3: Plan Generation, Wave 4: Review. You must call this before starting each wave. The first call starts a new plan and generates the plan filename.",
|
|
12817
|
+
args: {
|
|
12818
|
+
sessionID: tool.schema.string().describe("The current session ID")
|
|
12728
12819
|
},
|
|
12729
|
-
|
|
12730
|
-
|
|
12731
|
-
|
|
12732
|
-
|
|
12733
|
-
|
|
12734
|
-
|
|
12735
|
-
|
|
12736
|
-
|
|
12737
|
-
|
|
12738
|
-
|
|
12739
|
-
|
|
12740
|
-
|
|
12741
|
-
|
|
12742
|
-
}
|
|
12820
|
+
async execute(args, context) {
|
|
12821
|
+
if (context.agent !== "plan") {
|
|
12822
|
+
return "Error: advance_wave is only available to the Plan agent.";
|
|
12823
|
+
}
|
|
12824
|
+
try {
|
|
12825
|
+
const current = ctx.manager.getState(args.sessionID);
|
|
12826
|
+
const state = current ? ctx.manager.advanceWave(args.sessionID) : ctx.manager.startPlan(args.sessionID);
|
|
12827
|
+
if (!current) {
|
|
12828
|
+
ctx.todoNudges.set(args.sessionID, {
|
|
12829
|
+
prompted: false,
|
|
12830
|
+
retried: false,
|
|
12831
|
+
seeded: false
|
|
12832
|
+
});
|
|
12743
12833
|
}
|
|
12834
|
+
const waveName = WAVE_NAMES[state.wave];
|
|
12835
|
+
const nextStep = WAVE_NEXT_STEPS[state.wave];
|
|
12836
|
+
return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
|
|
12837
|
+
} catch (error45) {
|
|
12838
|
+
const message = error45 instanceof Error ? error45.message : "Unknown error";
|
|
12839
|
+
return `Error: ${message}`;
|
|
12744
12840
|
}
|
|
12841
|
+
}
|
|
12842
|
+
});
|
|
12843
|
+
}
|
|
12844
|
+
function createAcceptPlanTool(ctx) {
|
|
12845
|
+
return tool({
|
|
12846
|
+
description: "Accept or revise the generated plan. Use action='revise' to keep planning in Wave 4, or action='accept' to hand off to Build with only the plan path.",
|
|
12847
|
+
args: {
|
|
12848
|
+
sessionID: tool.schema.string().describe("The current plan session ID"),
|
|
12849
|
+
action: tool.schema.enum(["accept", "revise"]),
|
|
12850
|
+
planPath: tool.schema.string().optional().describe("Optional relative or absolute path to the plan file")
|
|
12745
12851
|
},
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
12752
|
-
|
|
12753
|
-
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
12757
|
-
|
|
12758
|
-
|
|
12759
|
-
|
|
12760
|
-
|
|
12761
|
-
|
|
12762
|
-
|
|
12763
|
-
|
|
12764
|
-
|
|
12765
|
-
}
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
12769
|
-
|
|
12770
|
-
|
|
12771
|
-
|
|
12772
|
-
|
|
12773
|
-
|
|
12774
|
-
|
|
12775
|
-
|
|
12776
|
-
|
|
12777
|
-
|
|
12778
|
-
|
|
12779
|
-
|
|
12780
|
-
|
|
12781
|
-
|
|
12782
|
-
|
|
12783
|
-
|
|
12784
|
-
|
|
12785
|
-
const
|
|
12786
|
-
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
|
|
12790
|
-
if (
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
|
|
12796
|
-
|
|
12797
|
-
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
} catch {
|
|
12805
|
-
return `Error: Plan file '${relativePlanPath}' is missing or unreadable.`;
|
|
12806
|
-
}
|
|
12807
|
-
let buildSessionID = "manual-build-session";
|
|
12808
|
-
let fallback = false;
|
|
12809
|
-
const sessionApi = client.session;
|
|
12810
|
-
const canCreate = sessionApi && typeof sessionApi.create === "function";
|
|
12811
|
-
const canPrompt = sessionApi && typeof sessionApi.prompt === "function";
|
|
12812
|
-
if (canCreate && canPrompt) {
|
|
12813
|
-
try {
|
|
12814
|
-
const createResult = await sessionApi.create({
|
|
12815
|
-
query: { directory },
|
|
12816
|
-
body: { title: `Build handoff: ${state.planName}` }
|
|
12817
|
-
});
|
|
12818
|
-
const createdSessionID = extractSessionID(createResult);
|
|
12819
|
-
if (!createdSessionID) {
|
|
12820
|
-
fallback = true;
|
|
12821
|
-
} else {
|
|
12822
|
-
buildSessionID = createdSessionID;
|
|
12823
|
-
await sessionApi.prompt({
|
|
12824
|
-
query: { directory },
|
|
12825
|
-
path: { id: createdSessionID },
|
|
12826
|
-
body: {
|
|
12827
|
-
agent: "build",
|
|
12828
|
-
system: "Use only this file as source of truth; do not rely on Plan context.",
|
|
12829
|
-
parts: [
|
|
12830
|
-
{
|
|
12831
|
-
type: "text",
|
|
12832
|
-
text: `Read ${relativePlanPath} and implement it phase-by-phase. Use only this plan file as handoff context.`
|
|
12833
|
-
}
|
|
12834
|
-
]
|
|
12852
|
+
async execute(args, context) {
|
|
12853
|
+
if (context.agent !== "plan") {
|
|
12854
|
+
return "Error: accept_plan is only available to the Plan agent.";
|
|
12855
|
+
}
|
|
12856
|
+
const state = ctx.manager.getState(args.sessionID);
|
|
12857
|
+
if (!state) {
|
|
12858
|
+
return `Error: No active plan found for session '${args.sessionID}'.`;
|
|
12859
|
+
}
|
|
12860
|
+
const defaultPlanPath = `tasks/plans/${state.planName}.md`;
|
|
12861
|
+
const resolvedPlanPath = args.planPath ?? defaultPlanPath;
|
|
12862
|
+
const absolutePlanPath = resolve(ctx.worktree, resolvedPlanPath);
|
|
12863
|
+
const relativePlanPath = toWorktreeRelativePath(ctx.worktree, absolutePlanPath);
|
|
12864
|
+
if (!isPlanMarkdownFile(ctx.worktree, absolutePlanPath)) {
|
|
12865
|
+
return "Error: planPath must point to tasks/plans/{planName}.md within the current worktree.";
|
|
12866
|
+
}
|
|
12867
|
+
if (args.action === "revise") {
|
|
12868
|
+
const nextStep = state.wave < 4 ? "Next step: call advance_wave until Wave 4, then continue review/revision." : "Next step: continue in Wave 4 review/revision and update the plan as needed.";
|
|
12869
|
+
return [
|
|
12870
|
+
"Plan revision requested.",
|
|
12871
|
+
`Current wave: ${state.wave} (${getWaveLabel(state.wave)}).`,
|
|
12872
|
+
`Plan file: ${relativePlanPath}`,
|
|
12873
|
+
nextStep
|
|
12874
|
+
].join(" ");
|
|
12875
|
+
}
|
|
12876
|
+
if (state.wave !== 4) {
|
|
12877
|
+
return `Error: accept_plan(action="accept") is only available in Wave 4 (Review). Current wave: ${state.wave}.`;
|
|
12878
|
+
}
|
|
12879
|
+
try {
|
|
12880
|
+
accessSync(absolutePlanPath, constants.R_OK);
|
|
12881
|
+
} catch {
|
|
12882
|
+
return `Error: Plan file '${relativePlanPath}' is missing or unreadable.`;
|
|
12883
|
+
}
|
|
12884
|
+
let buildSessionID = "manual-build-session";
|
|
12885
|
+
let fallback = false;
|
|
12886
|
+
const sessionApi = ctx.client.session;
|
|
12887
|
+
const canCreate = sessionApi && typeof sessionApi.create === "function";
|
|
12888
|
+
const canPrompt = sessionApi && typeof sessionApi.prompt === "function";
|
|
12889
|
+
if (canCreate && canPrompt) {
|
|
12890
|
+
try {
|
|
12891
|
+
const createResult = await sessionApi.create({
|
|
12892
|
+
query: { directory: ctx.directory },
|
|
12893
|
+
body: { title: `Build handoff: ${state.planName}` }
|
|
12894
|
+
});
|
|
12895
|
+
const createdSessionID = extractSessionID(createResult);
|
|
12896
|
+
if (!createdSessionID) {
|
|
12897
|
+
fallback = true;
|
|
12898
|
+
} else {
|
|
12899
|
+
buildSessionID = createdSessionID;
|
|
12900
|
+
await sessionApi.prompt({
|
|
12901
|
+
query: { directory: ctx.directory },
|
|
12902
|
+
path: { id: createdSessionID },
|
|
12903
|
+
body: {
|
|
12904
|
+
agent: "build",
|
|
12905
|
+
system: "Use only this file as source of truth; do not rely on Plan context.",
|
|
12906
|
+
parts: [
|
|
12907
|
+
{
|
|
12908
|
+
type: "text",
|
|
12909
|
+
text: `Read ${relativePlanPath} and implement it phase-by-phase. Use only this plan file as handoff context.`
|
|
12835
12910
|
}
|
|
12836
|
-
|
|
12911
|
+
]
|
|
12837
12912
|
}
|
|
12838
|
-
}
|
|
12839
|
-
fallback = true;
|
|
12840
|
-
}
|
|
12841
|
-
} else {
|
|
12842
|
-
fallback = true;
|
|
12843
|
-
}
|
|
12844
|
-
const acceptedState = manager.markAccepted(args.sessionID, buildSessionID);
|
|
12845
|
-
if (fallback) {
|
|
12846
|
-
return [
|
|
12847
|
-
"Plan accepted.",
|
|
12848
|
-
`Plan file: ${relativePlanPath}`,
|
|
12849
|
-
"Direct session handoff is unavailable in this environment.",
|
|
12850
|
-
"Fallback: switch to Build agent and read the plan file first.",
|
|
12851
|
-
`State saved with accepted timestamp ${acceptedState.acceptedAt}.`
|
|
12852
|
-
].join(" ");
|
|
12913
|
+
});
|
|
12853
12914
|
}
|
|
12854
|
-
|
|
12855
|
-
|
|
12856
|
-
|
|
12857
|
-
|
|
12858
|
-
|
|
12859
|
-
|
|
12860
|
-
|
|
12861
|
-
|
|
12862
|
-
|
|
12915
|
+
} catch {
|
|
12916
|
+
fallback = true;
|
|
12917
|
+
}
|
|
12918
|
+
} else {
|
|
12919
|
+
fallback = true;
|
|
12920
|
+
}
|
|
12921
|
+
const acceptedState = ctx.manager.markAccepted(args.sessionID, buildSessionID);
|
|
12922
|
+
if (fallback) {
|
|
12923
|
+
return [
|
|
12924
|
+
"Plan accepted.",
|
|
12925
|
+
`Plan file: ${relativePlanPath}`,
|
|
12926
|
+
"Direct session handoff is unavailable in this environment.",
|
|
12927
|
+
"Fallback: switch to Build agent and read the plan file first.",
|
|
12928
|
+
`State saved with accepted timestamp ${acceptedState.acceptedAt}.`
|
|
12929
|
+
].join(" ");
|
|
12930
|
+
}
|
|
12931
|
+
return [
|
|
12932
|
+
"Plan accepted and handed off to Build.",
|
|
12933
|
+
`Plan file: ${relativePlanPath}`,
|
|
12934
|
+
`Build session: ${buildSessionID}`,
|
|
12935
|
+
"First build action seeded: read the plan file with clean handoff context.",
|
|
12936
|
+
`State saved with accepted timestamp ${acceptedState.acceptedAt}.`
|
|
12937
|
+
].join(" ");
|
|
12938
|
+
}
|
|
12939
|
+
});
|
|
12940
|
+
}
|
|
12941
|
+
var DisciplinePlugin = async ({ worktree, directory, client }) => {
|
|
12942
|
+
const ctx = {
|
|
12943
|
+
manager: new WaveStateManager(worktree),
|
|
12944
|
+
todoNudges: new Map,
|
|
12945
|
+
worktree,
|
|
12946
|
+
directory,
|
|
12947
|
+
client
|
|
12948
|
+
};
|
|
12949
|
+
return {
|
|
12950
|
+
"experimental.session.compacting": (input, output) => handleCompacting(ctx, input, output),
|
|
12951
|
+
"experimental.chat.system.transform": (input, output) => handleSystemTransform(ctx, input, output),
|
|
12952
|
+
"tool.execute.before": (input, output) => handleToolExecuteBefore(ctx, input, output),
|
|
12953
|
+
tool: {
|
|
12954
|
+
advance_wave: createAdvanceWaveTool(ctx),
|
|
12955
|
+
accept_plan: createAcceptPlanTool(ctx)
|
|
12863
12956
|
}
|
|
12864
12957
|
};
|
|
12865
12958
|
};
|