shraga 0.1.39 → 0.1.41
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.
|
@@ -131,9 +131,16 @@ Write to `data/workspace/garden-reports/{YYYY-MM-DD}.md`:
|
|
|
131
131
|
|
|
132
132
|
The full report is the audit trail. Raw agent reports let you verify the diagnosis. Diff previews let you verify the prescription. Skipped findings let you verify the triage.
|
|
133
133
|
|
|
134
|
+
### Check auto-apply preference
|
|
135
|
+
|
|
136
|
+
Read `data/workspace/preferences/garden.json` (if present). This is the owner's standing instruction and **overrides the default approval gate** — check it before deciding whether to stop and wait.
|
|
137
|
+
|
|
138
|
+
- `{"autoApply": true, ...}` → **Phase 4 applies automatically**, no waiting. Only proposals whose `Risk` is `none` or `low` auto-apply; anything `medium`/`high`, or involving conflicting sources / a judgment call, still goes to DM-and-wait regardless of the flag (per the file's own `note` on what still needs Elya).
|
|
139
|
+
- Missing file, or `autoApply` absent/false → default gate applies: propose, DM, and **STOP** (below).
|
|
140
|
+
|
|
134
141
|
### Send Slack DM to owners
|
|
135
142
|
|
|
136
|
-
Send a DM to each owner (from `<known_contacts>` with Slack IDs) using `post_slack_message
|
|
143
|
+
Send a DM to each owner (from `<known_contacts>` with Slack IDs) using `post_slack_message`. Adjust wording based on the auto-apply check above:
|
|
137
144
|
|
|
138
145
|
```
|
|
139
146
|
🌱 Knowledge Garden — {date}
|
|
@@ -152,17 +159,21 @@ Full audit trail: data/workspace/garden-reports/{date}.md
|
|
|
152
159
|
Diff: -{removed summary} +{added summary}
|
|
153
160
|
|
|
154
161
|
Skipped {N} lower-priority findings (see report).
|
|
155
|
-
Reply with numbers to approve (e.g. "1,3") or "all".
|
|
156
162
|
```
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
If autoApply: append `Auto-applying #1, #2 (risk: none/low). Nothing needs your review this run.` — or, if something is held back — `Applied #1 automatically. #2 needs your call (risk: medium / conflicting sources) — reply with the number to approve.`
|
|
165
|
+
If not autoApply: append `Reply with numbers to approve (e.g. "1,3") or "all".` and **STOP here. Do not apply changes. Wait for user approval.**
|
|
166
|
+
|
|
167
|
+
## Phase 4: Apply
|
|
168
|
+
|
|
169
|
+
**If autoApply (per the check above):** apply every proposed change with `Risk: none` or `Risk: low` immediately, in the same run — no waiting. Hold back anything `medium`/`high` risk and DM as described above.
|
|
159
170
|
|
|
160
|
-
|
|
171
|
+
**If not autoApply:** apply only when the user replies with numbers (e.g. "1,3") or "all".
|
|
161
172
|
|
|
162
|
-
|
|
173
|
+
Either way:
|
|
163
174
|
|
|
164
175
|
1. Load the report from `data/workspace/garden-reports/{date}.md`
|
|
165
|
-
2. Apply
|
|
176
|
+
2. Apply the approved/qualifying changes using Edit tool
|
|
166
177
|
3. Commit:
|
|
167
178
|
|
|
168
179
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.41",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -78,6 +78,18 @@ export function start(broadcast: Broadcast): void {
|
|
|
78
78
|
s.lastRun.error = 'interrupted by server restart';
|
|
79
79
|
}
|
|
80
80
|
if (!s.enabled) { s.nextRun = undefined; continue; }
|
|
81
|
+
// A one-off that already ran must never be armed again. It is normally DELETED on success,
|
|
82
|
+
// but schedules.json is a whole-file JSON array shared by this process and by humans over
|
|
83
|
+
// git — a conflicting rebase, or data-sync's union-merge conflict resolver, can bring the
|
|
84
|
+
// deleted entry back. If its `at` is still in the future the past-due guard below cannot
|
|
85
|
+
// see it, and a completed campaign send would go out twice. Refuse it here instead.
|
|
86
|
+
const ranBefore = oneShotAlreadyRan(s);
|
|
87
|
+
if (ranBefore) {
|
|
88
|
+
console.warn(`[scheduler] ⚠️ refusing to re-arm completed one-off ${s.id} ("${s.name}") — ${ranBefore}. Disabled; delete it or create a new schedule.`);
|
|
89
|
+
s.enabled = false;
|
|
90
|
+
s.nextRun = undefined;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
81
93
|
const next = computeNextRun(s.trigger);
|
|
82
94
|
if (next === null) {
|
|
83
95
|
if (s.trigger.kind === 'once') s.enabled = false;
|
|
@@ -164,6 +176,10 @@ export function getSchedule(id: string): Schedule | undefined {
|
|
|
164
176
|
export function upsertSchedule(s: Schedule): { ok: true; schedule: Schedule } | { ok: false; error: string } {
|
|
165
177
|
const err = validateTrigger(s.trigger);
|
|
166
178
|
if (err) return { ok: false, error: err };
|
|
179
|
+
// Same guard as boot: re-adding a spent one-off under its old id is a resurrection, not an
|
|
180
|
+
// edit. A genuinely new job gets a new id.
|
|
181
|
+
const ranBefore = oneShotAlreadyRan(s);
|
|
182
|
+
if (ranBefore) return { ok: false, error: `This one-off schedule already ran — ${ranBefore}. Create a new schedule instead of re-adding this one.` };
|
|
167
183
|
if (s.onMissed !== undefined && !MISSED_POLICIES.includes(s.onMissed)) {
|
|
168
184
|
return { ok: false, error: `Invalid onMissed "${s.onMissed}" (expected ${MISSED_POLICIES.join(' | ')})` };
|
|
169
185
|
}
|
|
@@ -361,6 +377,31 @@ export function resumeRun(scheduleId: string, sessionId: string, prompt: string)
|
|
|
361
377
|
|
|
362
378
|
// ── Internals ───────────────────────────────────────────────────────────────
|
|
363
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Evidence that a `once` schedule has ALREADY had its single run — i.e. this entry is a
|
|
382
|
+
* resurrection of one the engine deleted on success, not a new job. Returns a human-readable
|
|
383
|
+
* reason, or null when the schedule is not a spent one-off.
|
|
384
|
+
*
|
|
385
|
+
* Two independent witnesses, because neither survives everything:
|
|
386
|
+
* - the completion marker (data/scheduler/completions/<id>.json), which is per-id, gitignored
|
|
387
|
+
* and local to the firing instance — so a git merge can never carry it away, but a rebuilt
|
|
388
|
+
* data dir loses it;
|
|
389
|
+
* - the entry's own `lastRun.status === 'ok'`, which travels inside schedules.json — so it
|
|
390
|
+
* survives a wiped data dir, but only if the resurrected copy is from after that run.
|
|
391
|
+
*/
|
|
392
|
+
function oneShotAlreadyRan(s: Schedule): string | null {
|
|
393
|
+
if (s.trigger.kind !== 'once') return null;
|
|
394
|
+
const marker = readCompletionMarker(s.id);
|
|
395
|
+
// `completedAt` is the last SUCCESSFUL completion, and 0 when there has never been one. A
|
|
396
|
+
// marker is also written at fire time (`markRunStarted`) and on failure
|
|
397
|
+
// (`recordAttemptOutcome`), so its mere existence proves only an attempt. A future-dated
|
|
398
|
+
// one-off whose manual test-run errored has a marker but has never sent — retiring it would
|
|
399
|
+
// cancel a send that never happened, and report it as completed at epoch 0.
|
|
400
|
+
if (marker && marker.completedAt > 0) return `it completed at ${new Date(marker.completedAt).toISOString()} (${marker.triggeredBy})`;
|
|
401
|
+
if (s.lastRun?.status === 'ok') return `its own lastRun records a successful run at ${new Date(s.lastRun.at).toISOString()}`;
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
364
405
|
const MISSED_POLICIES: MissedPolicy[] = ['run', 'skip', 'offer'];
|
|
365
406
|
|
|
366
407
|
function refuse(reason: RunRefusal, message: string): RunOutcome {
|
|
@@ -43,6 +43,13 @@ function buildBlocks(id: string, questions: AskQuestion[]): unknown[] {
|
|
|
43
43
|
return blocks;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** Replace a question message's blocks with a final state, so its buttons stop looking live. */
|
|
47
|
+
async function retire(p: Pending, text: string) {
|
|
48
|
+
if (!p.messageTs) return;
|
|
49
|
+
await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] }, p.useUserToken)
|
|
50
|
+
.catch((err) => console.error(`${PREFIX} retire failed:`, (err as Error)?.message));
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
/** Build a QuestionHandler bound to a Slack channel/thread for the current turn. */
|
|
47
54
|
export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
|
|
48
55
|
return async (id, questions) => {
|
|
@@ -55,7 +62,12 @@ export function makeSlackQuestionHandler(ctx: Ctx): QuestionHandler {
|
|
|
55
62
|
console.log(`${PREFIX} posted question ${id} (${questions.length}q) to ${ctx.channel}`);
|
|
56
63
|
return new Promise<QuestionAnswers | null>((resolve) => {
|
|
57
64
|
const timer = setTimeout(() => {
|
|
58
|
-
|
|
65
|
+
const p = pending.get(id);
|
|
66
|
+
if (!p) return;
|
|
67
|
+
pending.delete(id);
|
|
68
|
+
console.log(`${PREFIX} ${id} timed out`);
|
|
69
|
+
void retire(p, '⌛ This question expired — I went ahead without an answer. Reply in the thread to steer me.');
|
|
70
|
+
resolve(null);
|
|
59
71
|
}, TTL_MS);
|
|
60
72
|
pending.set(id, { ...ctx, resolve, questions, messageTs: res.ts, timer });
|
|
61
73
|
});
|
|
@@ -98,14 +110,22 @@ export async function handleSlackInteraction(payload: any): Promise<boolean> {
|
|
|
98
110
|
if (!submit) return false;
|
|
99
111
|
const id = submit.value as string;
|
|
100
112
|
const p = pending.get(id);
|
|
101
|
-
if (!p)
|
|
113
|
+
if (!p) {
|
|
114
|
+
// Expired or lost to a restart: the buttons are still clickable but nothing is listening.
|
|
115
|
+
// Silence reads as a broken app, so always answer the click.
|
|
116
|
+
console.warn(`${PREFIX} submit for unknown/expired question ${id}`);
|
|
117
|
+
const channel = payload.channel?.id;
|
|
118
|
+
if (channel && userId) {
|
|
119
|
+
await slackPost('chat.postEphemeral', { channel, user: userId, text: '⌛ That question is no longer active (expired or I restarted). Just reply in the thread and I\'ll pick it up.' })
|
|
120
|
+
.catch((err) => console.error(`${PREFIX} ephemeral failed:`, (err as Error)?.message));
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
102
124
|
pending.delete(id);
|
|
103
125
|
clearTimeout(p.timer);
|
|
104
126
|
const answers = parseAnswers(payload.state?.values, p.questions);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await slackPost('chat.update', { channel: p.channel, ts: p.messageTs, text: '✅ Got your answers.', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: `✅ Got your answers:\n${summary}` } }] }, p.useUserToken).catch(() => {});
|
|
108
|
-
}
|
|
127
|
+
const summary = Object.entries(answers).map(([q, a]) => `• *${q}* — ${Array.isArray(a) ? a.join(', ') : a}`).join('\n') || '_(no selection)_';
|
|
128
|
+
await retire(p, `✅ Got your answers:\n${summary}`);
|
|
109
129
|
console.log(`${PREFIX} resolved question ${id} (${Object.keys(answers).length} answered)`);
|
|
110
130
|
p.resolve(Object.keys(answers).length ? answers : null);
|
|
111
131
|
return true;
|