opencode-discipline 0.1.1 → 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.
Files changed (2) hide show
  1. package/dist/index.js +202 -192
  2. 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
- const value = result;
12627
- if (typeof value.id === "string") {
12628
- return value.id;
12629
+ if (hasStringProp(result, "id")) {
12630
+ return result.id;
12629
12631
  }
12630
- const data = value.data;
12631
- if (data && typeof data === "object") {
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
  }
@@ -12735,11 +12733,8 @@ function extractTodos(response) {
12735
12733
  return [];
12736
12734
  }
12737
12735
  function hasPlanKickoffTodo(todos, planName) {
12738
- const planNameLower = planName.toLowerCase();
12739
- return todos.some((todo) => {
12740
- const content = todo.content.toLowerCase();
12741
- return content.includes(planNameLower) && content.includes("plan");
12742
- });
12736
+ const expected = buildTodoSeedContent(planName).toLowerCase();
12737
+ return todos.some((todo) => todo.content.toLowerCase().includes(expected));
12743
12738
  }
12744
12739
  async function readSessionTodos(client, directory, sessionID) {
12745
12740
  const sessionApi = client.session;
@@ -12752,197 +12747,212 @@ async function readSessionTodos(client, directory, sessionID) {
12752
12747
  path: { id: sessionID }
12753
12748
  });
12754
12749
  return extractTodos(response);
12755
- } catch {
12750
+ } catch (error45) {
12751
+ console.warn("[discipline] readSessionTodos failed:", error45);
12756
12752
  return;
12757
12753
  }
12758
12754
  }
12759
- var DisciplinePlugin = async ({ worktree, directory, client }) => {
12760
- const manager = new WaveStateManager(worktree);
12761
- const todoNudges = new Map;
12762
- return {
12763
- "experimental.session.compacting": async (input, output) => {
12764
- const state = manager.getState(input.sessionID);
12765
- if (!state) {
12766
- return;
12767
- }
12768
- output.context.push(buildCompactionContext(state));
12769
- },
12770
- "experimental.chat.system.transform": async (input, output) => {
12771
- const sessionID = input.sessionID;
12772
- const state = sessionID ? manager.getState(sessionID) : undefined;
12773
- if (!state) {
12774
- 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).");
12775
- return;
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;
12776
12792
  }
12777
- output.system.push(buildWaveStateSystemBlock(state.wave, state.planName));
12778
- const currentAgent = input.agent;
12779
- if (currentAgent === "plan" && state.wave < 3) {
12780
- output.system.push(buildPlanReadOnlyReminder());
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.');
12781
12807
  }
12782
- if (sessionID && currentAgent === "plan" && !state.accepted) {
12783
- const nudgeState = todoNudges.get(sessionID) ?? {
12784
- prompted: false,
12785
- retried: false,
12786
- seeded: false
12787
- };
12788
- const todos = await readSessionTodos(client, directory, sessionID);
12789
- const hasSeededTodo = todos !== undefined && hasPlanKickoffTodo(todos, state.planName);
12790
- if (hasSeededTodo) {
12791
- nudgeState.seeded = true;
12792
- }
12793
- if (!nudgeState.seeded) {
12794
- if (!nudgeState.prompted) {
12795
- output.system.push(buildTodoSeedInstruction(state.planName, false));
12796
- nudgeState.prompted = true;
12797
- } else if (todos !== undefined && !hasSeededTodo && !nudgeState.retried) {
12798
- output.system.push(buildTodoSeedInstruction(state.planName, true));
12799
- nudgeState.retried = true;
12800
- }
12801
- }
12802
- todoNudges.set(sessionID, nudgeState);
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.`);
12803
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")
12804
12819
  },
12805
- "tool.execute.before": async (input, output) => {
12806
- const filePath = output.args?.filePath;
12807
- if (typeof filePath === "string") {
12808
- if (input.tool === "read" && isBlockedEnvRead(filePath)) {
12809
- throw new Error("Reading .env files is blocked by opencode-discipline.");
12810
- }
12811
- if (input.tool === "edit" || input.tool === "write") {
12812
- const state = manager.getState(input.sessionID);
12813
- if (state?.accepted) {
12814
- throw new Error('Plan session is read-only after accept_plan(action="accept"). Switch to Build for implementation.');
12815
- }
12816
- if (state && state.wave < 3 && isPlanMarkdownFile(worktree, filePath)) {
12817
- throw new Error(`Cannot write plan files until Wave 3 (Plan Generation). Current wave: ${state.wave}. Call advance_wave to progress.`);
12818
- }
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
+ });
12819
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}`;
12820
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")
12821
12851
  },
12822
- tool: {
12823
- advance_wave: tool({
12824
- 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.",
12825
- args: {
12826
- sessionID: tool.schema.string().describe("The current session ID")
12827
- },
12828
- async execute(args, context) {
12829
- if (context.agent !== "plan") {
12830
- return "Error: advance_wave is only available to the Plan agent.";
12831
- }
12832
- try {
12833
- const current = manager.getState(args.sessionID);
12834
- const state = current ? manager.advanceWave(args.sessionID) : manager.startPlan(args.sessionID);
12835
- if (!current) {
12836
- todoNudges.set(args.sessionID, {
12837
- prompted: false,
12838
- retried: false,
12839
- seeded: false
12840
- });
12841
- }
12842
- const waveName = WAVE_NAMES[state.wave];
12843
- const nextStep = WAVE_NEXT_STEPS[state.wave];
12844
- return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
12845
- } catch (error45) {
12846
- const message = error45 instanceof Error ? error45.message : "Unknown error";
12847
- return `Error: ${message}`;
12848
- }
12849
- }
12850
- }),
12851
- accept_plan: tool({
12852
- 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.",
12853
- args: {
12854
- sessionID: tool.schema.string().describe("The current plan session ID"),
12855
- action: tool.schema.enum(["accept", "revise"]),
12856
- planPath: tool.schema.string().optional().describe("Optional relative or absolute path to the plan file")
12857
- },
12858
- async execute(args, context) {
12859
- if (context.agent !== "plan") {
12860
- return "Error: accept_plan is only available to the Plan agent.";
12861
- }
12862
- const state = manager.getState(args.sessionID);
12863
- if (!state) {
12864
- return `Error: No active plan found for session '${args.sessionID}'.`;
12865
- }
12866
- const defaultPlanPath = `tasks/plans/${state.planName}.md`;
12867
- const resolvedPlanPath = args.planPath ?? defaultPlanPath;
12868
- const absolutePlanPath = resolve(worktree, resolvedPlanPath);
12869
- const relativePlanPath = toWorktreeRelativePath(worktree, absolutePlanPath);
12870
- if (!isPlanMarkdownFile(worktree, absolutePlanPath)) {
12871
- return "Error: planPath must point to tasks/plans/{planName}.md within the current worktree.";
12872
- }
12873
- if (args.action === "revise") {
12874
- 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.";
12875
- return [
12876
- "Plan revision requested.",
12877
- `Current wave: ${state.wave} (${getWaveLabel(state.wave)}).`,
12878
- `Plan file: ${relativePlanPath}`,
12879
- nextStep
12880
- ].join(" ");
12881
- }
12882
- if (state.wave !== 4) {
12883
- return `Error: accept_plan(action="accept") is only available in Wave 4 (Review). Current wave: ${state.wave}.`;
12884
- }
12885
- try {
12886
- accessSync(absolutePlanPath, constants.R_OK);
12887
- } catch {
12888
- return `Error: Plan file '${relativePlanPath}' is missing or unreadable.`;
12889
- }
12890
- let buildSessionID = "manual-build-session";
12891
- let fallback = false;
12892
- const sessionApi = client.session;
12893
- const canCreate = sessionApi && typeof sessionApi.create === "function";
12894
- const canPrompt = sessionApi && typeof sessionApi.prompt === "function";
12895
- if (canCreate && canPrompt) {
12896
- try {
12897
- const createResult = await sessionApi.create({
12898
- query: { directory },
12899
- body: { title: `Build handoff: ${state.planName}` }
12900
- });
12901
- const createdSessionID = extractSessionID(createResult);
12902
- if (!createdSessionID) {
12903
- fallback = true;
12904
- } else {
12905
- buildSessionID = createdSessionID;
12906
- await sessionApi.prompt({
12907
- query: { directory },
12908
- path: { id: createdSessionID },
12909
- body: {
12910
- agent: "build",
12911
- system: "Use only this file as source of truth; do not rely on Plan context.",
12912
- parts: [
12913
- {
12914
- type: "text",
12915
- text: `Read ${relativePlanPath} and implement it phase-by-phase. Use only this plan file as handoff context.`
12916
- }
12917
- ]
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.`
12918
12910
  }
12919
- });
12911
+ ]
12920
12912
  }
12921
- } catch {
12922
- fallback = true;
12923
- }
12924
- } else {
12925
- fallback = true;
12926
- }
12927
- const acceptedState = manager.markAccepted(args.sessionID, buildSessionID);
12928
- if (fallback) {
12929
- return [
12930
- "Plan accepted.",
12931
- `Plan file: ${relativePlanPath}`,
12932
- "Direct session handoff is unavailable in this environment.",
12933
- "Fallback: switch to Build agent and read the plan file first.",
12934
- `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12935
- ].join(" ");
12913
+ });
12936
12914
  }
12937
- return [
12938
- "Plan accepted and handed off to Build.",
12939
- `Plan file: ${relativePlanPath}`,
12940
- `Build session: ${buildSessionID}`,
12941
- "First build action seeded: read the plan file with clean handoff context.",
12942
- `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12943
- ].join(" ");
12944
- }
12945
- })
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)
12946
12956
  }
12947
12957
  };
12948
12958
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discipline",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",