@w32191/just-loop 0.1.1 → 0.1.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/README.md CHANGED
@@ -30,14 +30,41 @@ Bun is only a maintenance and release prerequisite. It is not a runtime dependen
30
30
 
31
31
  ## Release
32
32
 
33
- Prerequisites: Node 20+, Bun, and a valid npm login/token.
34
-
35
- 1. Run `npm login`.
36
- 2. Run `npm whoami`.
37
- 3. Confirm `@w32191` scope access with `npm access list packages @w32191` (or equivalent access check).
38
- 4. Run `npm view @w32191/just-loop version`.
39
- - For the first publish, a 404 is expected until the package exists.
40
- - The real unblock condition is: `npm whoami` succeeds and scope access checks pass.
41
- 5. Bump the package version.
42
- 6. Run `npm run verify:publish`.
43
- 7. Publish with `npm publish --access public`.
33
+ Primary release flow uses GitHub Actions and only allows manual dispatch from `main`.
34
+
35
+ ### GitHub Actions release
36
+
37
+ Prerequisites:
38
+
39
+ - GitHub Actions release workflow is configured in this repository
40
+ - npm Trusted Publisher is configured for this package
41
+
42
+ Steps:
43
+
44
+ 1. Open the `Release` workflow in GitHub Actions from the `main` branch.
45
+ 2. Choose either:
46
+ - `auto-bump = patch|minor`, or
47
+ - `auto-bump = no` and provide `version = X.Y.Z`
48
+ 3. Optionally add one-line `notes`.
49
+ 4. Optionally set `dry_run = true` to validate without pushing, tagging, or publishing.
50
+
51
+ The workflow will:
52
+
53
+ 1. Lock to the dispatched `main` commit SHA.
54
+ 2. Install dependencies with `bun install --frozen-lockfile`.
55
+ 3. Update `package.json` to the release version.
56
+ 4. Run `npm run verify:publish`.
57
+ 5. Generate a release notes artifact.
58
+ 6. On non-dry-run:
59
+ - commit the version bump to `main`
60
+ - create and push tag `vX.Y.Z`
61
+ - publish to npm with `npm publish --provenance --access public`
62
+ - create the GitHub Release
63
+
64
+ ### Local fallback
65
+
66
+ If GitHub Actions is unavailable, the equivalent manual flow is:
67
+
68
+ 1. Bump the package version.
69
+ 2. Run `npm run verify:publish`.
70
+ 3. Publish with `npm publish --access public`.
@@ -15,6 +15,15 @@ export function parseRalphLoopCommand(input) {
15
15
  if (!rest.startsWith("--")) {
16
16
  break;
17
17
  }
18
+ if (/^--max-iterations=/.test(rest)) {
19
+ const valueMatch = rest.match(/^--max-iterations=([0-9]+)(?:\s+|$)/);
20
+ if (!valueMatch) {
21
+ throw new Error("invalid --max-iterations value");
22
+ }
23
+ maxIterations = Number(valueMatch[1]);
24
+ rest = rest.slice(valueMatch[0].length);
25
+ continue;
26
+ }
18
27
  if (/^--max(?:\s|$)/.test(rest)) {
19
28
  const valueMatch = rest.match(/^--max(?:\s+(\S+))(?:\s+|$)/);
20
29
  if (!valueMatch || !/^[0-9]+$/.test(valueMatch[1])) {
@@ -33,6 +42,15 @@ export function parseRalphLoopCommand(input) {
33
42
  rest = rest.slice(promiseMatch[0].length);
34
43
  continue;
35
44
  }
45
+ if (/^--completion-promise=/.test(rest)) {
46
+ const promiseMatch = rest.match(/^--completion-promise=([^\s]+)(?:\s+|$)/);
47
+ if (!promiseMatch) {
48
+ throw new Error("invalid --completion-promise format");
49
+ }
50
+ completionPromise = promiseMatch[1];
51
+ rest = rest.slice(promiseMatch[0].length);
52
+ continue;
53
+ }
36
54
  if (/^--strategy(?:\s|$)/.test(rest)) {
37
55
  const strategyMatch = rest.match(/^--strategy(?:\s+(\S+))(?:\s+|$)/);
38
56
  if (!strategyMatch) {
@@ -43,6 +61,20 @@ export function parseRalphLoopCommand(input) {
43
61
  }
44
62
  throw new Error("invalid --strategy value");
45
63
  }
64
+ if (/^--strategy=/.test(rest)) {
65
+ const strategyMatch = rest.match(/^--strategy=([^\s]+)(?:\s+|$)/);
66
+ if (!strategyMatch) {
67
+ throw new Error("invalid --strategy value");
68
+ }
69
+ if (strategyMatch[1] === "continue") {
70
+ rest = rest.slice(strategyMatch[0].length);
71
+ continue;
72
+ }
73
+ if (strategyMatch[1] === "reset") {
74
+ throw new Error("reset strategy is not supported in v1");
75
+ }
76
+ throw new Error("invalid --strategy value");
77
+ }
46
78
  throw new Error("unknown flag");
47
79
  }
48
80
  const prompt = rest.trim().replace(/\s+/g, " ");
@@ -1,11 +1,12 @@
1
+ import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK } from "../ralph-loop/constants.js";
1
2
  const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential development loop that runs until task completion.
2
3
 
3
4
  ## How Ralph Loop Works
4
5
 
5
6
  1. You will work on the task continuously
6
- 2. When you believe the task is FULLY complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
7
+ 2. When you believe the task is FULLY complete, output the configured completion promise exactly as provided
7
8
  3. If you don't output the promise, the loop will automatically inject another prompt to continue
8
- 4. Maximum iterations: Configurable (default 100)
9
+ 4. Maximum iterations: plugin config fallback (${DEFAULT_MAX_ITERATIONS_FALLBACK})
9
10
 
10
11
  ## Rules
11
12
 
@@ -17,16 +18,19 @@ const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential
17
18
 
18
19
  ## Exit Conditions
19
20
 
20
- 1. **Completion**: Output your completion promise tag when fully complete
21
+ 1. **Completion**: Output your completion promise when fully complete
21
22
  2. **Max Iterations**: Loop stops automatically at limit
22
23
  3. **Cancel**: User runs \`/cancel-ralph\` command
23
24
 
24
25
  ## Your Task
25
26
 
26
27
  Parse the arguments below and begin working on the task. The format is:
27
- \`"task description" [--completion-promise=TEXT] [--max-iterations=N] [--strategy=reset|continue]\`
28
+ \`[--completion-promise=TEXT] [--max-iterations=N] [--strategy=continue] task description\`
28
29
 
29
- Default completion promise is "DONE" and default max iterations is 100.`;
30
+ Aliases: \`--promise "TEXT"\`, \`--max N\`
31
+ Reset strategy is not supported in v1.
32
+
33
+ Default completion promise is the project default \`${DEFAULT_COMPLETION_PROMISE}\` and default max iterations follows the plugin config fallback (${DEFAULT_MAX_ITERATIONS_FALLBACK}).`;
30
34
  const CANCEL_RALPH_TEMPLATE = `Cancel the currently active Ralph Loop.
31
35
 
32
36
  This will:
@@ -0,0 +1,17 @@
1
+ type LoopCore = {
2
+ startLoop: (sessionID: string, prompt: string, options: {
3
+ maxIterations?: number;
4
+ completionPromise?: string;
5
+ }) => Promise<unknown>;
6
+ cancelLoop: (sessionID: string) => Promise<unknown>;
7
+ };
8
+ type CommandExecuteBeforeInput = {
9
+ command?: unknown;
10
+ sessionID?: unknown;
11
+ arguments?: unknown;
12
+ };
13
+ type CommandExecuteBeforeOptions = {
14
+ defaultMaxIterations?: number;
15
+ };
16
+ export declare function handleCommandExecuteBefore(input: CommandExecuteBeforeInput, core: LoopCore, options?: CommandExecuteBeforeOptions): Promise<void>;
17
+ export {};
@@ -0,0 +1,24 @@
1
+ import { parseRalphLoopCommand } from "../commands/parse-ralph-loop-command.js";
2
+ function buildCommandLine(command, args) {
3
+ const normalized = command.startsWith("/") ? command : `/${command}`;
4
+ const trimmedArgs = args.trim();
5
+ return trimmedArgs.length > 0 ? `${normalized} ${trimmedArgs}` : normalized;
6
+ }
7
+ export async function handleCommandExecuteBefore(input, core, options = {}) {
8
+ if (typeof input.command !== "string")
9
+ return;
10
+ if (typeof input.sessionID !== "string")
11
+ return;
12
+ const args = typeof input.arguments === "string" ? input.arguments : "";
13
+ const command = parseRalphLoopCommand(buildCommandLine(input.command, args));
14
+ if (!command)
15
+ return;
16
+ if (command.kind === "cancel") {
17
+ await core.cancelLoop(input.sessionID);
18
+ return;
19
+ }
20
+ await core.startLoop(input.sessionID, command.prompt, {
21
+ maxIterations: command.maxIterations ?? options.defaultMaxIterations,
22
+ completionPromise: command.completionPromise,
23
+ });
24
+ }
@@ -1 +1 @@
1
- export declare function handleConfig(input: Record<string, unknown>): Promise<void>;
1
+ export declare function handleConfig(input: Record<string, unknown>): Promise<import("../ralph-loop/types.js").RalphLoopRuntimeConfig>;
@@ -1,10 +1,15 @@
1
+ import { resolvePluginConfig } from "./plugin-config.js";
1
2
  import { getBuiltinCommands } from "./command-definitions.js";
2
3
  export async function handleConfig(input) {
4
+ const resolvedConfig = resolvePluginConfig(input);
3
5
  const existingCommands = input.command && typeof input.command === "object"
4
6
  ? input.command
5
7
  : {};
6
- input.command = {
7
- ...getBuiltinCommands(),
8
- ...existingCommands,
9
- };
8
+ input.command = resolvedConfig.enabled
9
+ ? {
10
+ ...getBuiltinCommands(),
11
+ ...existingCommands,
12
+ }
13
+ : existingCommands;
14
+ return resolvedConfig;
10
15
  }
@@ -25,6 +25,14 @@ type ChatMessageOutput = {
25
25
  type EventInput = {
26
26
  event?: unknown;
27
27
  };
28
+ type CommandExecuteBeforeInput = {
29
+ command?: unknown;
30
+ sessionID?: unknown;
31
+ arguments?: unknown;
32
+ };
33
+ type CommandExecuteBeforeOutput = {
34
+ parts?: unknown;
35
+ };
28
36
  type ToolExecuteBeforeInput = {
29
37
  tool?: unknown;
30
38
  sessionID?: unknown;
@@ -39,6 +47,7 @@ export type PluginHooks = {
39
47
  "chat.message": (input: ChatMessageInput, output: ChatMessageOutput) => Promise<void>;
40
48
  event: (input: EventInput) => Promise<void>;
41
49
  config: (input: Record<string, unknown>) => Promise<void>;
50
+ "command.execute.before": (input: CommandExecuteBeforeInput, output: CommandExecuteBeforeOutput) => Promise<void>;
42
51
  "tool.execute.before": (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => Promise<void>;
43
52
  };
44
53
  export declare function createPlugin(ctx?: PluginInput, deps?: CreatePluginDeps): Promise<PluginHooks>;
@@ -1,7 +1,9 @@
1
1
  import { createOpenCodeHostAdapter } from "../host-adapter/opencode-host-adapter.js";
2
2
  import { createLoopCore } from "../ralph-loop/loop-core.js";
3
+ import { handleCommandExecuteBefore } from "./command-execute-before-handler.js";
3
4
  import { handleConfig } from "./config-handler.js";
4
5
  import { handleEvent } from "./event-handler.js";
6
+ import { resolvePluginConfig } from "./plugin-config.js";
5
7
  import { handleToolExecuteBefore } from "./tool-execute-before-handler.js";
6
8
  function extractSessionID(input) {
7
9
  if (typeof input.sessionID === "string")
@@ -23,6 +25,7 @@ export async function createPlugin(ctx, deps = {}) {
23
25
  throw new Error("plugin context is required");
24
26
  const createAdapter = deps.createOpenCodeHostAdapter ?? createOpenCodeHostAdapter;
25
27
  const createCore = deps.createLoopCore ?? createLoopCore;
28
+ let resolvedConfig = resolvePluginConfig({});
26
29
  const adapter = createAdapter({
27
30
  directory: ctx.directory,
28
31
  client: {
@@ -36,10 +39,14 @@ export async function createPlugin(ctx, deps = {}) {
36
39
  },
37
40
  },
38
41
  });
39
- const core = createCore({ rootDir: ctx.directory, adapter });
42
+ const core = createCore({
43
+ rootDir: ctx.directory,
44
+ adapter,
45
+ getConfig: () => resolvedConfig,
46
+ });
40
47
  return {
41
48
  config: async (input) => {
42
- await handleConfig(input);
49
+ resolvedConfig = await handleConfig(input);
43
50
  },
44
51
  "chat.message": async (input, output) => {
45
52
  const sessionID = extractSessionID(input);
@@ -48,12 +55,25 @@ export async function createPlugin(ctx, deps = {}) {
48
55
  extractChatText(output.parts);
49
56
  },
50
57
  event: async (input) => {
58
+ if (!resolvedConfig.enabled)
59
+ return;
51
60
  if (!input.event)
52
61
  return;
53
62
  await handleEvent(input.event, core);
54
63
  },
64
+ "command.execute.before": async (input) => {
65
+ if (!resolvedConfig.enabled)
66
+ return;
67
+ await handleCommandExecuteBefore(input, core, {
68
+ defaultMaxIterations: resolvedConfig.defaultMaxIterations,
69
+ });
70
+ },
55
71
  "tool.execute.before": async (input, output) => {
56
- await handleToolExecuteBefore(input, output, core);
72
+ if (!resolvedConfig.enabled)
73
+ return;
74
+ await handleToolExecuteBefore(input, output, core, {
75
+ defaultMaxIterations: resolvedConfig.defaultMaxIterations,
76
+ });
57
77
  },
58
78
  };
59
79
  }
@@ -0,0 +1,6 @@
1
+ import type { RalphLoopRuntimeConfig } from "../ralph-loop/types.js";
2
+ type ConfigInput = Record<string, unknown> & {
3
+ ralph_loop?: unknown;
4
+ };
5
+ export declare function resolvePluginConfig(input: ConfigInput): RalphLoopRuntimeConfig;
6
+ export {};
@@ -0,0 +1,36 @@
1
+ import { DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY } from "../ralph-loop/constants.js";
2
+ function isObject(value) {
3
+ return typeof value === "object" && value !== null;
4
+ }
5
+ function parseEnabled(value) {
6
+ if (value === undefined)
7
+ return false;
8
+ if (typeof value === "boolean")
9
+ return value;
10
+ throw new Error("ralph_loop.enabled must be a boolean");
11
+ }
12
+ function parseDefaultMaxIterations(value) {
13
+ if (value === undefined)
14
+ return DEFAULT_MAX_ITERATIONS_FALLBACK;
15
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
16
+ return value;
17
+ throw new Error("ralph_loop.default_max_iterations must be a positive integer");
18
+ }
19
+ function parseDefaultStrategy(value) {
20
+ if (value === undefined)
21
+ return DEFAULT_STRATEGY;
22
+ if (value === "continue")
23
+ return value;
24
+ throw new Error("ralph_loop.default_strategy must be 'continue'");
25
+ }
26
+ export function resolvePluginConfig(input) {
27
+ if (input.ralph_loop !== undefined && !isObject(input.ralph_loop)) {
28
+ throw new Error("ralph_loop must be an object");
29
+ }
30
+ const rawConfig = isObject(input.ralph_loop) ? input.ralph_loop : {};
31
+ return {
32
+ enabled: parseEnabled(rawConfig.enabled),
33
+ defaultMaxIterations: parseDefaultMaxIterations(rawConfig.default_max_iterations),
34
+ defaultStrategy: parseDefaultStrategy(rawConfig.default_strategy),
35
+ };
36
+ }
@@ -5,6 +5,9 @@ type LoopCore = {
5
5
  }) => Promise<unknown>;
6
6
  cancelLoop: (sessionID: string) => Promise<unknown>;
7
7
  };
8
+ type ToolExecuteBeforeOptions = {
9
+ defaultMaxIterations?: number;
10
+ };
8
11
  type ToolExecuteBeforeInput = {
9
12
  tool?: unknown;
10
13
  sessionID?: unknown;
@@ -14,5 +17,5 @@ type ToolExecuteBeforeOutput = {
14
17
  name?: unknown;
15
18
  };
16
19
  };
17
- export declare function handleToolExecuteBefore(input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput, core: LoopCore): Promise<void>;
20
+ export declare function handleToolExecuteBefore(input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput, core: LoopCore, options?: ToolExecuteBeforeOptions): Promise<void>;
18
21
  export {};
@@ -2,7 +2,7 @@ import { parseRalphLoopCommand } from "../commands/parse-ralph-loop-command.js";
2
2
  function normalizeCommandName(name) {
3
3
  return name.startsWith("/") ? name : `/${name}`;
4
4
  }
5
- export async function handleToolExecuteBefore(input, output, core) {
5
+ export async function handleToolExecuteBefore(input, output, core, options = {}) {
6
6
  if (input.tool !== "skill")
7
7
  return;
8
8
  if (typeof input.sessionID !== "string")
@@ -17,7 +17,7 @@ export async function handleToolExecuteBefore(input, output, core) {
17
17
  return;
18
18
  }
19
19
  await core.startLoop(input.sessionID, command.prompt, {
20
- maxIterations: command.maxIterations,
20
+ maxIterations: command.maxIterations ?? options.defaultMaxIterations,
21
21
  completionPromise: command.completionPromise,
22
22
  });
23
23
  }
@@ -1,2 +1,4 @@
1
1
  export declare const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
2
2
  export declare const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
3
+ export declare const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
4
+ export declare const DEFAULT_STRATEGY = "continue";
@@ -1,2 +1,4 @@
1
1
  export const DEFAULT_STATE_PATH = ".loop/ralph-loop.local.md";
2
2
  export const DEFAULT_COMPLETION_PROMISE = "<promise>DONE</promise>";
3
+ export const DEFAULT_MAX_ITERATIONS_FALLBACK = 100;
4
+ export const DEFAULT_STRATEGY = "continue";
@@ -1,4 +1,5 @@
1
1
  import type { HostAdapter } from "../host-adapter/types.js";
2
+ import type { RalphLoopRuntimeConfig } from "./types.js";
2
3
  export type LoopEvent = {
3
4
  type: "session.idle";
4
5
  sessionID: string;
@@ -12,6 +13,7 @@ export type LoopEvent = {
12
13
  export type CreateLoopCoreDeps = {
13
14
  rootDir: string;
14
15
  adapter: HostAdapter;
16
+ getConfig?: () => RalphLoopRuntimeConfig;
15
17
  };
16
18
  export type StartLoopOptions = {
17
19
  maxIterations?: number;
@@ -1,13 +1,19 @@
1
- import { DEFAULT_COMPLETION_PROMISE } from "./constants.js";
1
+ import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS_FALLBACK, DEFAULT_STRATEGY } from "./constants.js";
2
2
  import { buildContinuationPrompt } from "./continuation-prompt.js";
3
3
  import { detectCompletion } from "./completion-detector.js";
4
4
  import { clearState, readState, writeState } from "./state-store.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
  export function createLoopCore(deps) {
7
7
  const inFlight = new Map();
8
+ const getConfig = () => deps.getConfig?.() ?? {
9
+ enabled: true,
10
+ defaultMaxIterations: DEFAULT_MAX_ITERATIONS_FALLBACK,
11
+ defaultStrategy: DEFAULT_STRATEGY,
12
+ };
8
13
  const getToken = (state) => state.incarnation_token ?? state.started_at;
9
14
  return {
10
15
  async startLoop(sessionID, prompt, options = {}) {
16
+ const config = getConfig();
11
17
  const existing = await readState(deps.rootDir);
12
18
  if (existing?.active) {
13
19
  const stillExists = await deps.adapter.sessionExists(existing.session_id);
@@ -26,7 +32,7 @@ export function createLoopCore(deps) {
26
32
  session_id: sessionID,
27
33
  prompt,
28
34
  iteration: 0,
29
- max_iterations: options.maxIterations,
35
+ max_iterations: options.maxIterations ?? config.defaultMaxIterations,
30
36
  completion_promise: options.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
31
37
  message_count_at_start: messageCountAtStart,
32
38
  incarnation_token: incarnationToken,
@@ -51,8 +57,23 @@ export function createLoopCore(deps) {
51
57
  }
52
58
  try {
53
59
  const state = await readState(deps.rootDir);
54
- if (!state || !state.active || state.session_id !== event.sessionID)
60
+ if (!state || !state.active)
61
+ return;
62
+ if (state.session_id !== event.sessionID) {
63
+ const observedSessionID = state.session_id;
64
+ const observedToken = getToken(state);
65
+ const stillExists = await deps.adapter.sessionExists(observedSessionID);
66
+ if (!stillExists) {
67
+ const currentState = await readState(deps.rootDir);
68
+ if (currentState &&
69
+ currentState.active &&
70
+ currentState.session_id === observedSessionID &&
71
+ getToken(currentState) === observedToken) {
72
+ await clearState(deps.rootDir);
73
+ }
74
+ }
55
75
  return;
76
+ }
56
77
  const incarnationToken = getToken(state);
57
78
  const inFlightToken = inFlight.get(event.sessionID);
58
79
  if (inFlightToken === incarnationToken)
@@ -10,3 +10,14 @@ export type RalphLoopState = {
10
10
  incarnation_token?: string;
11
11
  started_at: string;
12
12
  };
13
+ export type RalphLoopDefaultStrategy = "continue";
14
+ export type RalphLoopRuntimeConfig = {
15
+ enabled: boolean;
16
+ defaultMaxIterations: number;
17
+ defaultStrategy: RalphLoopDefaultStrategy;
18
+ };
19
+ export type RalphLoopPluginConfigInput = {
20
+ enabled?: unknown;
21
+ default_max_iterations?: unknown;
22
+ default_strategy?: unknown;
23
+ };
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@w32191/just-loop",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "description": "OpenCode plugin package for just-loop.",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/SamWang32191/just-loop"
10
+ },
7
11
  "main": "./dist/src/index.js",
8
12
  "types": "./dist/src/index.d.ts",
9
13
  "exports": {