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.
Files changed (2) hide show
  1. package/dist/index.js +249 -156
  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
  }
@@ -12703,163 +12701,258 @@ function buildCompactionContext(state) {
12703
12701
  ].join(`
12704
12702
  `);
12705
12703
  }
12706
- var DisciplinePlugin = async ({ worktree, directory, client }) => {
12707
- const manager = new WaveStateManager(worktree);
12708
- return {
12709
- "experimental.session.compacting": async (input, output) => {
12710
- const state = manager.getState(input.sessionID);
12711
- if (!state) {
12712
- return;
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
- output.context.push(buildCompactionContext(state));
12715
- },
12716
- "experimental.chat.system.transform": async (input, output) => {
12717
- const sessionID = input.sessionID;
12718
- const state = sessionID ? manager.getState(sessionID) : undefined;
12719
- if (!state) {
12720
- 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).");
12721
- return;
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
- output.system.push(buildWaveStateSystemBlock(state.wave, state.planName));
12724
- const currentAgent = input.agent;
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
- "tool.execute.before": async (input, output) => {
12730
- const filePath = output.args?.filePath;
12731
- if (typeof filePath === "string") {
12732
- if (input.tool === "read" && isBlockedEnvRead(filePath)) {
12733
- throw new Error("Reading .env files is blocked by opencode-discipline.");
12734
- }
12735
- if (input.tool === "edit" || input.tool === "write") {
12736
- const state = manager.getState(input.sessionID);
12737
- if (state?.accepted) {
12738
- throw new Error('Plan session is read-only after accept_plan(action="accept"). Switch to Build for implementation.');
12739
- }
12740
- if (state && state.wave < 3 && isPlanMarkdownFile(worktree, filePath)) {
12741
- throw new Error(`Cannot write plan files until Wave 3 (Plan Generation). Current wave: ${state.wave}. Call advance_wave to progress.`);
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
- tool: {
12747
- advance_wave: tool({
12748
- 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.",
12749
- args: {
12750
- sessionID: tool.schema.string().describe("The current session ID")
12751
- },
12752
- async execute(args, context) {
12753
- if (context.agent !== "plan") {
12754
- return "Error: advance_wave is only available to the Plan agent.";
12755
- }
12756
- try {
12757
- const current = manager.getState(args.sessionID);
12758
- const state = current ? manager.advanceWave(args.sessionID) : manager.startPlan(args.sessionID);
12759
- const waveName = WAVE_NAMES[state.wave];
12760
- const nextStep = WAVE_NEXT_STEPS[state.wave];
12761
- return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
12762
- } catch (error45) {
12763
- const message = error45 instanceof Error ? error45.message : "Unknown error";
12764
- return `Error: ${message}`;
12765
- }
12766
- }
12767
- }),
12768
- accept_plan: tool({
12769
- 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.",
12770
- args: {
12771
- sessionID: tool.schema.string().describe("The current plan session ID"),
12772
- action: tool.schema.enum(["accept", "revise"]),
12773
- planPath: tool.schema.string().optional().describe("Optional relative or absolute path to the plan file")
12774
- },
12775
- async execute(args, context) {
12776
- if (context.agent !== "plan") {
12777
- return "Error: accept_plan is only available to the Plan agent.";
12778
- }
12779
- const state = manager.getState(args.sessionID);
12780
- if (!state) {
12781
- return `Error: No active plan found for session '${args.sessionID}'.`;
12782
- }
12783
- const defaultPlanPath = `tasks/plans/${state.planName}.md`;
12784
- const resolvedPlanPath = args.planPath ?? defaultPlanPath;
12785
- const absolutePlanPath = resolve(worktree, resolvedPlanPath);
12786
- const relativePlanPath = toWorktreeRelativePath(worktree, absolutePlanPath);
12787
- if (!isPlanMarkdownFile(worktree, absolutePlanPath)) {
12788
- return "Error: planPath must point to tasks/plans/{planName}.md within the current worktree.";
12789
- }
12790
- if (args.action === "revise") {
12791
- 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.";
12792
- return [
12793
- "Plan revision requested.",
12794
- `Current wave: ${state.wave} (${getWaveLabel(state.wave)}).`,
12795
- `Plan file: ${relativePlanPath}`,
12796
- nextStep
12797
- ].join(" ");
12798
- }
12799
- if (state.wave !== 4) {
12800
- return `Error: accept_plan(action="accept") is only available in Wave 4 (Review). Current wave: ${state.wave}.`;
12801
- }
12802
- try {
12803
- accessSync(absolutePlanPath, constants.R_OK);
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
- } catch {
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
- return [
12855
- "Plan accepted and handed off to Build.",
12856
- `Plan file: ${relativePlanPath}`,
12857
- `Build session: ${buildSessionID}`,
12858
- "First build action seeded: read the plan file with clean handoff context.",
12859
- `State saved with accepted timestamp ${acceptedState.acceptedAt}.`
12860
- ].join(" ");
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discipline",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",