Skip to content

Commit af56b6d

Browse files
author
StackMemory Bot (CLI)
committed
feat(cli): add loop/watch command for monitoring CI, deploys, and external state
- New `stackmemory loop` command (alias: `watch`) polls a shell command until a condition is met - Options: --until, --until-not, --until-empty, --until-non-empty, --until-exit - Configurable interval (-i) and timeout (-t) with s/m/h duration parsing - JSON output mode (--json) for programmatic use - Add loop-monitor skill rule to detect monitor/watch intent in prompts - Update skill-eval engine to surface suggestion field from skill rules - Bump version to 1.5.0
1 parent 1676690 commit af56b6d

File tree

7 files changed

+232
-1
lines changed

7 files changed

+232
-1
lines changed

.claude/hooks/skill-eval.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@ function evaluate(prompt) {
350350
if (config.showMatchReasons && match.reasons.length > 0) {
351351
output += ` Matched: ${match.reasons.slice(0, 3).join(', ')}\n`;
352352
}
353+
354+
// Show suggestion if the skill defines one (e.g. loop-monitor)
355+
const skillDef = skills[match.name];
356+
if (skillDef?.suggestion) {
357+
output += ` Suggestion: ${skillDef.suggestion}\n`;
358+
}
353359
}
354360

355361
if (relatedSkills.length > 0) {

.claude/hooks/skill-rules.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,22 @@
255255
},
256256
"relatedSkills": ["linear-task-runner"]
257257
},
258+
"loop-monitor": {
259+
"description": "Monitor CI, deploys, logs, or external state with stackmemory loop",
260+
"priority": 9,
261+
"triggers": {
262+
"keywords": ["monitor", "watch", "poll", "wait for", "check status", "loop", "keep checking", "deploy status", "ci status", "action status", "run status"],
263+
"keywordPatterns": ["\\b(?:monitor|watch|poll|loop)\\b", "wait\\s+(?:for|until)", "keep\\s+checking", "check.*(?:status|state|progress)", "(?:gh|github)\\s+(?:run|action|check)", "deploy.*(?:status|log|done)", "\\buntil\\b.*(?:done|complete|pass|success|fail)"],
264+
"intentPatterns": [
265+
"(?:monitor|watch|poll|check).*(?:ci|deploy|action|run|build|status|log|inbox)",
266+
"(?:wait|loop).*(?:until|for).*(?:done|complete|pass|success|finish)",
267+
"(?:keep|continue).*(?:checking|monitoring|watching)",
268+
"(?:let me know|notify|alert).*(?:when|if).*(?:done|complete|ready|fail)"
269+
]
270+
},
271+
"suggestion": "Use `stackmemory loop` to poll a command until a condition is met:\n stackmemory loop \"<command>\" --until \"<pattern>\" -i 10s -t 30m\n stackmemory loop \"gh run view <id> --json status -q .status\" --until \"completed\"\n stackmemory loop \"curl -s http://localhost/health\" --until \"ok\" --json",
272+
"relatedSkills": ["github-actions"]
273+
},
258274
"linear-task-runner": {
259275
"description": "Execute Linear tasks via RLM orchestrator",
260276
"priority": 8,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@stackmemoryai/stackmemory",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
55
"engines": {
66
"node": ">=20.0.0",

src/cli/commands/loop.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* Loop command for StackMemory CLI
3+
* Repeatedly runs a shell command until a condition is met.
4+
* Useful for monitoring GitHub Actions, deploy logs, inboxes, etc.
5+
*/
6+
7+
import { Command } from 'commander';
8+
import { execSync } from 'child_process';
9+
import chalk from 'chalk';
10+
11+
export interface LoopOptions {
12+
until?: string;
13+
untilNot?: string;
14+
untilEmpty: boolean;
15+
untilNonEmpty: boolean;
16+
untilExit: boolean;
17+
interval: string;
18+
timeout: string;
19+
quiet: boolean;
20+
json: boolean;
21+
label?: string;
22+
shell: string;
23+
}
24+
25+
export interface LoopResult {
26+
ok: boolean;
27+
reason: 'matched' | 'timeout' | 'error';
28+
iterations: number;
29+
elapsed: number;
30+
lastOutput: string;
31+
}
32+
33+
function parseSeconds(value: string): number {
34+
const match = value.match(/^(\d+)(s|m|h)?$/);
35+
if (!match) return parseInt(value, 10) || 10;
36+
const num = parseInt(match[1], 10);
37+
switch (match[2]) {
38+
case 'h':
39+
return num * 3600;
40+
case 'm':
41+
return num * 60;
42+
default:
43+
return num;
44+
}
45+
}
46+
47+
function sleep(ms: number): Promise<void> {
48+
return new Promise((resolve) => setTimeout(resolve, ms));
49+
}
50+
51+
function checkCondition(
52+
output: string,
53+
exitCode: number,
54+
opts: LoopOptions
55+
): boolean {
56+
if (opts.untilExit) return exitCode === 0;
57+
if (opts.untilEmpty) return output.trim().length === 0;
58+
if (opts.untilNonEmpty) return output.trim().length > 0;
59+
if (opts.until) return output.includes(opts.until);
60+
if (opts.untilNot) return !output.includes(opts.untilNot);
61+
// Default: succeed when command exits 0
62+
return exitCode === 0;
63+
}
64+
65+
export function createLoopCommand(): Command {
66+
return new Command('loop')
67+
.alias('watch')
68+
.description(
69+
'Run a command repeatedly until a condition is met (monitor CI, deploys, logs)'
70+
)
71+
.argument('<command>', 'Shell command to run on each iteration')
72+
.option('--until <pattern>', 'Stop when output contains this string')
73+
.option(
74+
'--until-not <pattern>',
75+
'Stop when output no longer contains this string'
76+
)
77+
.option('--until-empty', 'Stop when output is empty', false)
78+
.option('--until-non-empty', 'Stop when output is non-empty', false)
79+
.option(
80+
'--until-exit',
81+
'Stop when command exits with code 0 (ignore output)',
82+
false
83+
)
84+
.option('-i, --interval <duration>', 'Check interval (e.g. 10s, 1m)', '10s')
85+
.option('-t, --timeout <duration>', 'Max wait time (e.g. 30m, 1h)', '30m')
86+
.option('-q, --quiet', 'Only show final result', false)
87+
.option('--json', 'Output result as JSON', false)
88+
.option('-l, --label <name>', 'Label for this loop (shown in output)')
89+
.option('--shell <path>', 'Shell to use', '/bin/sh')
90+
.action(async (command: string, opts: LoopOptions) => {
91+
const intervalSec = parseSeconds(opts.interval);
92+
const timeoutSec = parseSeconds(opts.timeout);
93+
const label = opts.label || command.slice(0, 40);
94+
const startTime = Date.now();
95+
const deadline = startTime + timeoutSec * 1000;
96+
let iterations = 0;
97+
let lastOutput = '';
98+
99+
if (!opts.quiet && !opts.json) {
100+
console.log(
101+
chalk.cyan(`[loop] ${label}`) +
102+
chalk.gray(` (every ${intervalSec}s, timeout ${timeoutSec}s)`)
103+
);
104+
}
105+
106+
while (Date.now() < deadline) {
107+
iterations++;
108+
let output = '';
109+
let exitCode = 0;
110+
111+
try {
112+
output = execSync(command, {
113+
encoding: 'utf8',
114+
timeout: Math.min(intervalSec * 1000, 60_000),
115+
shell: opts.shell,
116+
stdio: ['pipe', 'pipe', 'pipe'],
117+
});
118+
} catch (error: unknown) {
119+
const e = error as {
120+
status?: number;
121+
stdout?: string;
122+
stderr?: string;
123+
};
124+
exitCode = e.status ?? 1;
125+
output = (e.stdout || '') + (e.stderr || '');
126+
}
127+
128+
lastOutput = output;
129+
130+
if (!opts.quiet && !opts.json) {
131+
const elapsed = Math.round((Date.now() - startTime) / 1000);
132+
const preview = output.trim().split('\n').slice(-3).join('\n');
133+
console.log(
134+
chalk.gray(`[${elapsed}s #${iterations}]`) +
135+
(preview ? `\n${preview}` : chalk.gray(' (no output)'))
136+
);
137+
}
138+
139+
if (checkCondition(output, exitCode, opts)) {
140+
const result: LoopResult = {
141+
ok: true,
142+
reason: 'matched',
143+
iterations,
144+
elapsed: Date.now() - startTime,
145+
lastOutput: lastOutput.trim(),
146+
};
147+
148+
if (opts.json) {
149+
console.log(JSON.stringify(result));
150+
} else if (!opts.quiet) {
151+
console.log(
152+
chalk.green(`[loop] Condition met after ${iterations} iterations`)
153+
);
154+
}
155+
return;
156+
}
157+
158+
// Sleep unless we'd exceed the deadline
159+
const remaining = deadline - Date.now();
160+
if (remaining > 0) {
161+
await sleep(Math.min(intervalSec * 1000, remaining));
162+
}
163+
}
164+
165+
// Timeout
166+
const result: LoopResult = {
167+
ok: false,
168+
reason: 'timeout',
169+
iterations,
170+
elapsed: Date.now() - startTime,
171+
lastOutput: lastOutput.trim(),
172+
};
173+
174+
if (opts.json) {
175+
console.log(JSON.stringify(result));
176+
} else {
177+
console.log(
178+
chalk.yellow(
179+
`[loop] Timed out after ${timeoutSec}s (${iterations} iterations)`
180+
)
181+
);
182+
}
183+
process.exit(1);
184+
});
185+
}

src/cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { createDesiresCommands } from './commands/desires.js';
6767
import { createConductorCommands } from './commands/orchestrate.js';
6868
import { createPreflightCommand } from './commands/preflight.js';
6969
import { createSnapshotCommand } from './commands/snapshot.js';
70+
import { createLoopCommand } from './commands/loop.js';
7071
import chalk from 'chalk';
7172
import * as fs from 'fs';
7273
import * as path from 'path';
@@ -694,6 +695,7 @@ program.addCommand(createDesiresCommands());
694695
program.addCommand(createConductorCommands());
695696
program.addCommand(createPreflightCommand());
696697
program.addCommand(createSnapshotCommand());
698+
program.addCommand(createLoopCommand());
697699

698700
// Register setup and diagnostic commands
699701
registerSetupCommands(program);

templates/claude-hooks/skill-eval.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@ function evaluate(prompt) {
350350
if (config.showMatchReasons && match.reasons.length > 0) {
351351
output += ` Matched: ${match.reasons.slice(0, 3).join(', ')}\n`;
352352
}
353+
354+
// Show suggestion if the skill defines one (e.g. loop-monitor)
355+
const skillDef = skills[match.name];
356+
if (skillDef?.suggestion) {
357+
output += ` Suggestion: ${skillDef.suggestion}\n`;
358+
}
353359
}
354360

355361
if (relatedSkills.length > 0) {

templates/claude-hooks/skill-rules.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,22 @@
255255
},
256256
"relatedSkills": ["linear-task-runner"]
257257
},
258+
"loop-monitor": {
259+
"description": "Monitor CI, deploys, logs, or external state with stackmemory loop",
260+
"priority": 9,
261+
"triggers": {
262+
"keywords": ["monitor", "watch", "poll", "wait for", "check status", "loop", "keep checking", "deploy status", "ci status", "action status", "run status"],
263+
"keywordPatterns": ["\\b(?:monitor|watch|poll|loop)\\b", "wait\\s+(?:for|until)", "keep\\s+checking", "check.*(?:status|state|progress)", "(?:gh|github)\\s+(?:run|action|check)", "deploy.*(?:status|log|done)", "\\buntil\\b.*(?:done|complete|pass|success|fail)"],
264+
"intentPatterns": [
265+
"(?:monitor|watch|poll|check).*(?:ci|deploy|action|run|build|status|log|inbox)",
266+
"(?:wait|loop).*(?:until|for).*(?:done|complete|pass|success|finish)",
267+
"(?:keep|continue).*(?:checking|monitoring|watching)",
268+
"(?:let me know|notify|alert).*(?:when|if).*(?:done|complete|ready|fail)"
269+
]
270+
},
271+
"suggestion": "Use `stackmemory loop` to poll a command until a condition is met:\n stackmemory loop \"<command>\" --until \"<pattern>\" -i 10s -t 30m\n stackmemory loop \"gh run view <id> --json status -q .status\" --until \"completed\"\n stackmemory loop \"curl -s http://localhost/health\" --until \"ok\" --json",
272+
"relatedSkills": ["github-actions"]
273+
},
258274
"linear-task-runner": {
259275
"description": "Execute Linear tasks via RLM orchestrator",
260276
"priority": 8,

0 commit comments

Comments
 (0)