opencode-discipline 0.2.2 → 0.3.1

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/README.md CHANGED
@@ -28,6 +28,39 @@ It provides:
28
28
  bun run build
29
29
  ```
30
30
 
31
+ ## Notifications (optional)
32
+
33
+ On startup, the plugin shows a small in-app toast: `opencode-discipline vX is running`.
34
+
35
+ You can enable milestone notifications for key planning events:
36
+
37
+ - `Need your answers` (agent question prompts)
38
+ - `Plan is ready` (Wave 4 handoff stage)
39
+ - `OpenCode has finished` (Build agent completion)
40
+
41
+ Set one environment variable before running OpenCode:
42
+
43
+ ```bash
44
+ export OPENCODE_DISCIPLINE_NOTIFY=metadata
45
+ ```
46
+
47
+ Supported modes:
48
+
49
+ - `off`
50
+ - `metadata` (emit structured notification metadata)
51
+ - `os` (default; desktop notification via `osascript`/`notify-send`/PowerShell)
52
+ - `both` (metadata + OS)
53
+
54
+ Optional custom command hook:
55
+
56
+ ```bash
57
+ export OPENCODE_DISCIPLINE_NOTIFY_COMMAND='terminal-notifier -title "{title}" -message "{message}"'
58
+ ```
59
+
60
+ Template variables: `{event}`, `{title}`, `{message}`, `{sessionID}`.
61
+
62
+ Build completion notifications are emitted automatically when OpenCode reports the Build session as idle (`session.idle`).
63
+
31
64
  ## Test
32
65
 
33
66
  ```bash
package/dist/index.js CHANGED
@@ -6915,8 +6915,9 @@ var require_public_api = __commonJS((exports) => {
6915
6915
  });
6916
6916
 
6917
6917
  // src/index.ts
6918
- import { accessSync, constants, existsSync as existsSync2 } from "fs";
6918
+ import { accessSync, constants, existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
6919
6919
  import { basename as basename2, dirname, relative, resolve as resolve2 } from "path";
6920
+ import { spawn } from "child_process";
6920
6921
  import { fileURLToPath } from "url";
6921
6922
 
6922
6923
  // node_modules/@opencode-ai/plugin/node_modules/zod/v4/classic/external.js
@@ -19585,6 +19586,29 @@ class WaveStateManager {
19585
19586
  this.persist(nextState);
19586
19587
  return nextState;
19587
19588
  }
19589
+ isAcceptedBuildSession(sessionID) {
19590
+ for (const state of this.states.values()) {
19591
+ if (state.acceptedBySessionID === sessionID) {
19592
+ return true;
19593
+ }
19594
+ }
19595
+ if (!existsSync(this.stateDir)) {
19596
+ return false;
19597
+ }
19598
+ const entries = readdirSync2(this.stateDir).filter((entry) => entry.endsWith(".json"));
19599
+ for (const entry of entries) {
19600
+ try {
19601
+ const path = join(this.stateDir, entry);
19602
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
19603
+ if (parsed.acceptedBySessionID === sessionID) {
19604
+ return true;
19605
+ }
19606
+ } catch {
19607
+ continue;
19608
+ }
19609
+ }
19610
+ return false;
19611
+ }
19588
19612
  persist(state) {
19589
19613
  this.ensureDir();
19590
19614
  const filePath = join(this.stateDir, `${state.planName}.json`);
@@ -19627,6 +19651,155 @@ var WAVE_NEXT_STEPS = {
19627
19651
  3: "writing the plan file",
19628
19652
  4: "plan review and refinement"
19629
19653
  };
19654
+ var PLUGIN_DISPLAY_NAME = "opencode-discipline";
19655
+ var startupToastShown = false;
19656
+ function parseNotificationMode(raw) {
19657
+ const value = raw?.trim().toLowerCase();
19658
+ if (!value) {
19659
+ return "os";
19660
+ }
19661
+ if (value === "off" || value === "0" || value === "false" || value === "disabled") {
19662
+ return "off";
19663
+ }
19664
+ if (value === "metadata") {
19665
+ return "metadata";
19666
+ }
19667
+ if (value === "both" || value === "all") {
19668
+ return "both";
19669
+ }
19670
+ return "os";
19671
+ }
19672
+ function readPluginVersion(pluginDir) {
19673
+ try {
19674
+ const packageJsonPath = resolve2(pluginDir, "../package.json");
19675
+ const packageJson = JSON.parse(readFileSync3(packageJsonPath, "utf8"));
19676
+ return typeof packageJson.version === "string" ? packageJson.version : "unknown";
19677
+ } catch {
19678
+ return "unknown";
19679
+ }
19680
+ }
19681
+ async function showStartupToast(client, directory, version2) {
19682
+ if (startupToastShown) {
19683
+ return;
19684
+ }
19685
+ const tuiApi = client.tui;
19686
+ if (!tuiApi || typeof tuiApi.showToast !== "function") {
19687
+ return;
19688
+ }
19689
+ try {
19690
+ await tuiApi.showToast({
19691
+ query: { directory },
19692
+ body: {
19693
+ title: "Discipline Plugin",
19694
+ message: `${PLUGIN_DISPLAY_NAME} v${version2} is running`,
19695
+ variant: "info",
19696
+ duration: 3000
19697
+ }
19698
+ });
19699
+ startupToastShown = true;
19700
+ } catch {}
19701
+ }
19702
+ function escapeAppleScriptString(value) {
19703
+ return value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
19704
+ }
19705
+ function escapePowerShellString(value) {
19706
+ return value.replaceAll("'", "''");
19707
+ }
19708
+
19709
+ class NotificationManager {
19710
+ mode;
19711
+ commandTemplate;
19712
+ sentKeys = new Map;
19713
+ constructor() {
19714
+ this.mode = parseNotificationMode(process.env.OPENCODE_DISCIPLINE_NOTIFY);
19715
+ this.commandTemplate = process.env.OPENCODE_DISCIPLINE_NOTIFY_COMMAND?.trim();
19716
+ }
19717
+ notify(input) {
19718
+ if (this.mode === "off") {
19719
+ return;
19720
+ }
19721
+ if (this.isSent(input.sessionID, input.dedupeKey)) {
19722
+ return;
19723
+ }
19724
+ this.markSent(input.sessionID, input.dedupeKey);
19725
+ if (this.mode === "metadata" || this.mode === "both") {
19726
+ this.emitMetadata(input);
19727
+ }
19728
+ if (this.mode === "os" || this.mode === "both") {
19729
+ this.emitOsNotification(input);
19730
+ }
19731
+ }
19732
+ isSent(sessionID, key) {
19733
+ const keys = this.sentKeys.get(sessionID);
19734
+ return keys?.has(key) ?? false;
19735
+ }
19736
+ markSent(sessionID, key) {
19737
+ const keys = this.sentKeys.get(sessionID) ?? new Set;
19738
+ keys.add(key);
19739
+ this.sentKeys.set(sessionID, keys);
19740
+ }
19741
+ emitMetadata(input) {
19742
+ if (!input.toolContext || typeof input.toolContext !== "object") {
19743
+ return;
19744
+ }
19745
+ const maybeMetadata = input.toolContext.metadata;
19746
+ if (typeof maybeMetadata !== "function") {
19747
+ return;
19748
+ }
19749
+ try {
19750
+ maybeMetadata({
19751
+ type: "discipline.notification",
19752
+ event: input.event,
19753
+ title: input.title,
19754
+ message: input.message,
19755
+ sessionID: input.sessionID,
19756
+ createdAt: new Date().toISOString()
19757
+ });
19758
+ } catch {}
19759
+ }
19760
+ emitOsNotification(input) {
19761
+ if (this.commandTemplate) {
19762
+ const command = this.commandTemplate.replaceAll("{event}", input.event).replaceAll("{title}", input.title).replaceAll("{message}", input.message).replaceAll("{sessionID}", input.sessionID);
19763
+ this.spawnDetached(process.platform === "win32" ? "cmd.exe" : "sh", process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command]);
19764
+ return;
19765
+ }
19766
+ if (process.platform === "darwin") {
19767
+ const script = `display notification "${escapeAppleScriptString(input.message)}" with title "${escapeAppleScriptString(input.title)}"`;
19768
+ this.spawnDetached("osascript", ["-e", script]);
19769
+ return;
19770
+ }
19771
+ if (process.platform === "linux") {
19772
+ this.spawnDetached("notify-send", [input.title, input.message]);
19773
+ return;
19774
+ }
19775
+ if (process.platform === "win32") {
19776
+ const script = [
19777
+ "Add-Type -AssemblyName System.Windows.Forms",
19778
+ "$notify = New-Object System.Windows.Forms.NotifyIcon",
19779
+ "$notify.Icon = [System.Drawing.SystemIcons]::Information",
19780
+ "$notify.BalloonTipTitle = '",
19781
+ escapePowerShellString(input.title),
19782
+ "'",
19783
+ "$notify.BalloonTipText = '",
19784
+ escapePowerShellString(input.message),
19785
+ "'",
19786
+ "$notify.Visible = $true",
19787
+ "$notify.ShowBalloonTip(4000)"
19788
+ ].join(" ");
19789
+ this.spawnDetached("powershell", ["-NoProfile", "-Command", script]);
19790
+ }
19791
+ }
19792
+ spawnDetached(command, args) {
19793
+ try {
19794
+ const child = spawn(command, args, {
19795
+ stdio: "ignore",
19796
+ detached: true
19797
+ });
19798
+ child.on("error", () => {});
19799
+ child.unref();
19800
+ } catch {}
19801
+ }
19802
+ }
19630
19803
  function hasStringProp(obj, key) {
19631
19804
  return key in obj && typeof obj[key] === "string";
19632
19805
  }
@@ -19642,6 +19815,41 @@ function extractSessionID(result) {
19642
19815
  }
19643
19816
  return;
19644
19817
  }
19818
+ function extractSessionIDFromHookInput(input) {
19819
+ if (!input || typeof input !== "object") {
19820
+ return;
19821
+ }
19822
+ if (hasStringProp(input, "sessionID")) {
19823
+ return input.sessionID;
19824
+ }
19825
+ if (hasStringProp(input, "id")) {
19826
+ return input.id;
19827
+ }
19828
+ const value = input;
19829
+ const path = value.path;
19830
+ if (path && typeof path === "object" && hasStringProp(path, "id")) {
19831
+ return path.id;
19832
+ }
19833
+ const session = value.session;
19834
+ if (session && typeof session === "object" && hasStringProp(session, "id")) {
19835
+ return session.id;
19836
+ }
19837
+ return;
19838
+ }
19839
+ function extractAgentFromHookInput(input) {
19840
+ if (!input || typeof input !== "object") {
19841
+ return;
19842
+ }
19843
+ if (hasStringProp(input, "agent")) {
19844
+ return input.agent.toLowerCase();
19845
+ }
19846
+ const value = input;
19847
+ const session = value.session;
19848
+ if (session && typeof session === "object" && hasStringProp(session, "agent")) {
19849
+ return session.agent.toLowerCase();
19850
+ }
19851
+ return;
19852
+ }
19645
19853
  function getWaveLabel(wave) {
19646
19854
  return WAVE_NAMES[wave];
19647
19855
  }
@@ -19664,6 +19872,7 @@ function isBlockedEnvRead(filePath) {
19664
19872
  return fileName.startsWith(".env.");
19665
19873
  }
19666
19874
  function buildWaveStateSystemBlock(wave, planName) {
19875
+ const advanceGuidance = wave === 1 ? "Call `advance_wave` only after Wave 1 interview + checklist work is complete." : wave === 2 ? "Call `advance_wave` only after the Wave 2 Oracle check has completed and gap analysis is done." : wave === 3 ? "Call `advance_wave` only after the plan file is written and Wave 3 is complete." : "Do not call `advance_wave` again unless you are truly done with Wave 4 and ready for handoff decisions.";
19667
19876
  return [
19668
19877
  "## \uD83D\uDD12 Discipline Plugin \u2014 Wave State",
19669
19878
  "",
@@ -19677,7 +19886,7 @@ function buildWaveStateSystemBlock(wave, planName) {
19677
19886
  "- Wave 3 (Plan Generation): NOW write the plan to tasks/plans/{planName}.md using the structured template.",
19678
19887
  "- Wave 4 (Review): Self-review the plan. Delegate to @oracle for high-stakes decisions. Edit the plan if needed.",
19679
19888
  "",
19680
- `**You are in Wave ${wave}. Call \`advance_wave\` to move to the next wave.**`,
19889
+ `**You are in Wave ${wave}.** ${advanceGuidance}`,
19681
19890
  "**Writing to tasks/plans/*.md is BLOCKED until Wave 3.**"
19682
19891
  ].join(`
19683
19892
  `);
@@ -19687,7 +19896,8 @@ function buildWave2OraclePrompt() {
19687
19896
  "## MANDATORY: Wave 2 Oracle Check",
19688
19897
  "Before you can advance to Wave 3, delegate to `@oracle` once for a gap-analysis sanity check.",
19689
19898
  'Use the Task tool with `subagent_type: "oracle"` and summarize the result in your analysis.',
19690
- "Wave 2 -> 3 is enforced: `advance_wave` will fail until this Oracle check is completed."
19899
+ "Wave 2 -> 3 is enforced: `advance_wave` will fail until this Oracle check is completed.",
19900
+ "Do NOT retry `advance_wave` until the Oracle task returns successfully."
19691
19901
  ].join(`
19692
19902
  `);
19693
19903
  }
@@ -19908,6 +20118,25 @@ async function handleCompacting(ctx, input, output) {
19908
20118
  }
19909
20119
  output.context.push(buildCompactionContext(state));
19910
20120
  }
20121
+ async function handleSessionIdle(ctx, input, _output) {
20122
+ const sessionID = extractSessionIDFromHookInput(input);
20123
+ if (!sessionID) {
20124
+ return;
20125
+ }
20126
+ const agent = extractAgentFromHookInput(input);
20127
+ const isBuildIdle = agent === "build" || ctx.manager.isAcceptedBuildSession(sessionID);
20128
+ if (!isBuildIdle) {
20129
+ return;
20130
+ }
20131
+ ctx.notifications.notify({
20132
+ sessionID,
20133
+ event: "coding_complete",
20134
+ title: "OpenCode has finished",
20135
+ message: "The Build agent is done and OpenCode is ready for input.",
20136
+ dedupeKey: "session-idle-coding-complete",
20137
+ toolContext: input
20138
+ });
20139
+ }
19911
20140
  async function handleSystemTransform(ctx, input, output) {
19912
20141
  const sessionID = input.sessionID;
19913
20142
  const state = sessionID ? ctx.manager.getState(sessionID) : undefined;
@@ -19929,6 +20158,15 @@ async function handleSystemTransform(ctx, input, output) {
19929
20158
  if (state.wave === 4) {
19930
20159
  const planFilePath = resolve2(ctx.worktree, `tasks/plans/${state.planName}.md`);
19931
20160
  if (existsSync2(planFilePath)) {
20161
+ if (sessionID) {
20162
+ ctx.notifications.notify({
20163
+ sessionID,
20164
+ event: "plan_ready",
20165
+ title: "Plan is ready",
20166
+ message: `tasks/plans/${state.planName}.md is ready for handoff review.`,
20167
+ dedupeKey: "wave-4-plan-ready"
20168
+ });
20169
+ }
19932
20170
  output.system.push(buildPlanHandoffPrompt(state.planName));
19933
20171
  }
19934
20172
  }
@@ -19969,6 +20207,19 @@ async function handleToolExecuteBefore(ctx, input, output) {
19969
20207
  }
19970
20208
  }
19971
20209
  async function handleToolExecuteAfter(ctx, input, output) {
20210
+ if (input.tool === "question") {
20211
+ const condensed = output.output.replace(/\s+/g, " ").trim();
20212
+ const questionText = condensed.length > 180 ? `${condensed.slice(0, 177)}...` : condensed;
20213
+ ctx.notifications.notify({
20214
+ sessionID: input.sessionID,
20215
+ event: "need_answers",
20216
+ title: "Need your answers",
20217
+ message: questionText || "The agent asked a question and is waiting for your answer.",
20218
+ dedupeKey: `question-${input.callID}`,
20219
+ toolContext: output
20220
+ });
20221
+ return;
20222
+ }
19972
20223
  const state = ctx.manager.getState(input.sessionID);
19973
20224
  if (!state || state.accepted) {
19974
20225
  return;
@@ -20028,9 +20279,31 @@ function createAdvanceWaveTool(ctx) {
20028
20279
  }
20029
20280
  const waveName = WAVE_NAMES[state.wave];
20030
20281
  const nextStep = WAVE_NEXT_STEPS[state.wave];
20031
- return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}.`;
20282
+ const advanceWhen = state.wave === 1 ? "Call `advance_wave` only after interview + checklist work is complete." : state.wave === 2 ? "Call `advance_wave` only after Oracle review is complete and gap analysis is done." : state.wave === 3 ? "Call `advance_wave` only after the plan file is written." : "Stay in Wave 4 for plan review/handoff; call `advance_wave` only if explicitly needed (normally you should use `accept_plan`).";
20283
+ if (state.wave === 4) {
20284
+ const planFilePath = resolve2(ctx.worktree, `tasks/plans/${state.planName}.md`);
20285
+ if (existsSync2(planFilePath)) {
20286
+ ctx.notifications.notify({
20287
+ sessionID: args.sessionID,
20288
+ event: "plan_ready",
20289
+ title: "Plan is ready",
20290
+ message: `tasks/plans/${state.planName}.md is ready for handoff review.`,
20291
+ dedupeKey: "wave-4-plan-ready",
20292
+ toolContext: context
20293
+ });
20294
+ }
20295
+ }
20296
+ return `Wave ${state.wave} (${waveName}) started for plan '${state.planName}'. You may now proceed with ${nextStep}. ${advanceWhen}`;
20032
20297
  } catch (error45) {
20033
20298
  const message = error45 instanceof Error ? error45.message : "Unknown error";
20299
+ if (message.includes("Cannot advance to Wave 3 before Oracle gap review is completed")) {
20300
+ return [
20301
+ `Error: ${message}`,
20302
+ "You are still in Wave 2 (Gap Analysis).",
20303
+ 'Next action: run Task with subagent_type="oracle" and wait for successful completion.',
20304
+ "Do NOT call advance_wave again until that Oracle task succeeds."
20305
+ ].join(" ");
20306
+ }
20034
20307
  return `Error: ${message}`;
20035
20308
  }
20036
20309
  }
@@ -20137,14 +20410,17 @@ function createAcceptPlanTool(ctx) {
20137
20410
  }
20138
20411
  var DisciplinePlugin = async ({ worktree, directory, client }) => {
20139
20412
  const pluginDir = dirname(fileURLToPath(import.meta.url));
20413
+ const pluginVersion = readPluginVersion(pluginDir);
20140
20414
  const agentConfigs = loadAgentConfigs(resolve2(pluginDir, "../agents"));
20141
20415
  const ctx = {
20142
20416
  manager: new WaveStateManager(worktree),
20143
20417
  todoNudges: new Map,
20418
+ notifications: new NotificationManager,
20144
20419
  worktree,
20145
20420
  directory,
20146
20421
  client
20147
20422
  };
20423
+ await showStartupToast(client, directory, pluginVersion);
20148
20424
  return {
20149
20425
  config: async (input) => {
20150
20426
  const agents = input.agent;
@@ -20158,6 +20434,7 @@ var DisciplinePlugin = async ({ worktree, directory, client }) => {
20158
20434
  "experimental.chat.system.transform": (input, output) => handleSystemTransform(ctx, input, output),
20159
20435
  "tool.execute.before": (input, output) => handleToolExecuteBefore(ctx, input, output),
20160
20436
  "tool.execute.after": (input, output) => handleToolExecuteAfter(ctx, input, output),
20437
+ "session.idle": (input, output) => handleSessionIdle(ctx, input, output),
20161
20438
  tool: {
20162
20439
  advance_wave: createAdvanceWaveTool(ctx),
20163
20440
  accept_plan: createAcceptPlanTool(ctx)
@@ -23,6 +23,7 @@ export declare class WaveStateManager {
23
23
  getPlanName(sessionID: string): string | undefined;
24
24
  isWaveAtLeast(sessionID: string, minWave: Wave): boolean;
25
25
  markAccepted(sessionID: string, buildSessionID: string): WaveState;
26
+ isAcceptedBuildSession(sessionID: string): boolean;
26
27
  private persist;
27
28
  private ensureDir;
28
29
  private loadStateFromDisk;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-discipline",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",