clawspec 1.0.8 → 1.0.11
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/package.json
CHANGED
|
@@ -675,6 +675,13 @@ export class ClawSpecService {
|
|
|
675
675
|
repoStatePaths.planningJournalSnapshotFile,
|
|
676
676
|
project.planningJournal?.lastSyncedAt,
|
|
677
677
|
);
|
|
678
|
+
const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
|
|
679
|
+
const digest = await journalStore.digest(project.changeName);
|
|
680
|
+
this.logger.info(`[clawspec] cs-work check for ${project.changeName}:`);
|
|
681
|
+
this.logger.info(` - hasUnsyncedChanges: ${hasUnsyncedChanges}`);
|
|
682
|
+
this.logger.info(` - Snapshot: entryCount=${snapshot?.entryCount}, lastEntryAt=${snapshot?.lastEntryAt}, hash=${snapshot?.contentHash?.slice(0, 8)}`);
|
|
683
|
+
this.logger.info(` - Current digest: entryCount=${digest.entryCount}, lastEntryAt=${digest.lastEntryAt}, hash=${digest.contentHash.slice(0, 8)}`);
|
|
684
|
+
this.logger.info(` - fallbackLastSyncedAt: ${project.planningJournal?.lastSyncedAt}`);
|
|
678
685
|
if (!hasUnsyncedChanges) {
|
|
679
686
|
const isDetached = !isProjectContextAttached(project);
|
|
680
687
|
if (isDetached) {
|
|
@@ -951,7 +958,8 @@ export class ClawSpecService {
|
|
|
951
958
|
return;
|
|
952
959
|
}
|
|
953
960
|
|
|
954
|
-
const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
|
|
961
|
+
const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
|
|
962
|
+
?? await this.findPlanningProjectByContext(ctx);
|
|
955
963
|
if (planningProject) {
|
|
956
964
|
await this.finalizePlanningTurn(planningProject, event);
|
|
957
965
|
return;
|
|
@@ -2314,6 +2322,27 @@ export class ClawSpecService {
|
|
|
2314
2322
|
);
|
|
2315
2323
|
}
|
|
2316
2324
|
|
|
2325
|
+
private async findPlanningProjectByContext(ctx: PromptBuildContext): Promise<ProjectState | undefined> {
|
|
2326
|
+
const routingContext = deriveRoutingContext(ctx);
|
|
2327
|
+
if (!routingContext.channelId) {
|
|
2328
|
+
return undefined;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const match = await this.stateStore.findActiveProjectForMessage({
|
|
2332
|
+
channel: routingContext.channel,
|
|
2333
|
+
channelId: routingContext.channelId,
|
|
2334
|
+
accountId: routingContext.accountId,
|
|
2335
|
+
conversationId: routingContext.conversationId,
|
|
2336
|
+
});
|
|
2337
|
+
if (!match) {
|
|
2338
|
+
return undefined;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
return match.project.status === "planning" && match.project.phase === "planning_sync"
|
|
2342
|
+
? match.project
|
|
2343
|
+
: undefined;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2317
2346
|
private async findDiscussionProjectBySessionKey(sessionKey?: string): Promise<ProjectState | undefined> {
|
|
2318
2347
|
if (!sessionKey) {
|
|
2319
2348
|
return undefined;
|
|
@@ -2438,9 +2467,12 @@ export class ClawSpecService {
|
|
|
2438
2467
|
}
|
|
2439
2468
|
}
|
|
2440
2469
|
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2470
|
+
await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
|
|
2471
|
+
const writtenSnapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
|
|
2472
|
+
const currentDigest = await journalStore.digest(project.changeName);
|
|
2473
|
+
this.logger.info(`[clawspec] Planning snapshot written for ${project.changeName}:`);
|
|
2474
|
+
this.logger.info(` - Snapshot: entryCount=${writtenSnapshot?.entryCount}, lastEntryAt=${writtenSnapshot?.lastEntryAt}, hash=${writtenSnapshot?.contentHash?.slice(0, 8)}`);
|
|
2475
|
+
this.logger.info(` - Current digest: entryCount=${currentDigest.entryCount}, lastEntryAt=${currentDigest.lastEntryAt}, hash=${currentDigest.contentHash.slice(0, 8)}`);
|
|
2444
2476
|
await this.writeLatestSummary(repoStatePaths, latestSummary);
|
|
2445
2477
|
|
|
2446
2478
|
const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
|
|
@@ -3172,6 +3204,10 @@ function isPassiveAssistantPlanningMessage(text: string): boolean {
|
|
|
3172
3204
|
return true;
|
|
3173
3205
|
}
|
|
3174
3206
|
|
|
3207
|
+
if (/^heartbeat(?:[_\s-]?(?:ok|ping|alive))?$/i.test(normalized)) {
|
|
3208
|
+
return true;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3175
3211
|
const collapsed = normalized.replace(/\s+/g, " ").trim();
|
|
3176
3212
|
if (/^[▶✓⚠↻ℹ]/u.test(collapsed)) {
|
|
3177
3213
|
return true;
|
package/src/worker/prompts.ts
CHANGED
|
@@ -188,18 +188,26 @@ export function buildPlanningPrependContext(params: {
|
|
|
188
188
|
"Required workflow for this turn:",
|
|
189
189
|
"0. The active change directory shown above is the only OpenSpec change directory you may inspect or modify in this turn.",
|
|
190
190
|
"1. Read planning-journal.jsonl, .openspec.yaml, and any planning artifacts that already exist.",
|
|
191
|
-
"2.
|
|
192
|
-
"3.
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
191
|
+
"2. **CRITICAL**: The prefetched OpenSpec instruction blocks below contain the EXACT prompts you must follow to generate each artifact.",
|
|
192
|
+
"3. Each instruction block shows:",
|
|
193
|
+
" - Instruction: The EXACT prompt/guidance for generating that artifact",
|
|
194
|
+
" - Template: The structure/format to follow",
|
|
195
|
+
"4. **YOU MUST**:",
|
|
196
|
+
" - Treat each instruction block's 'Instruction' section as if it were given to you directly by the user",
|
|
197
|
+
" - Follow the instruction's requirements, structure, and sections EXACTLY as specified",
|
|
198
|
+
" - Use the planning journal and user requirements to fill in the CONTENT, but follow the instruction for STRUCTURE",
|
|
199
|
+
" - Do NOT create your own format - the instruction tells you exactly what to write and how",
|
|
200
|
+
"5. The allowed planning scope is limited to what is explicitly supported by the current user message, the planning journal, existing dependency artifacts, and the prefetched OpenSpec instructions.",
|
|
201
|
+
"6. Do not invent endpoints, features, constraints, files, acceptance criteria, test scenarios, or architecture details that are not grounded in those sources.",
|
|
202
|
+
"7. If there is no substantive planning change, say so clearly in chat and do not rewrite artifacts unnecessarily.",
|
|
203
|
+
"8. Update artifacts in dependency order: proposal → specs → design → tasks, using the prefetched instruction prompts for each.",
|
|
204
|
+
"9. Before updating each artifact, post a short chat update naming the artifact you are about to refresh.",
|
|
205
|
+
"10. After updating each artifact, post a short chat update describing what changed and what artifact comes next.",
|
|
206
|
+
"11. For any implementation-oriented task item, ensure tasks.md contains an explicit testing task (new tests or updated tests) and a validation command.",
|
|
207
|
+
"12. Stop after planning artifacts are refreshed and apply-ready. Do not implement code in this turn.",
|
|
208
|
+
"13. End with a concise summary and a mandatory final line exactly in this shape: `Next: run `cs-work` to start implementation.`",
|
|
209
|
+
"14. Never scan sibling directories under `openspec/changes`, never switch to another change, and never restore or rewrite unrelated files.",
|
|
210
|
+
"15. Do not claim that OpenSpec instructions were skipped in this sync turn. The plugin already executed those commands before this turn began.",
|
|
203
211
|
]
|
|
204
212
|
: [
|
|
205
213
|
"Discussion rules for this turn:",
|
|
@@ -166,3 +166,38 @@ test("inbound bot/system messages are ignored by planning journal capture", asyn
|
|
|
166
166
|
assert.equal(project?.planningJournal?.dirty, false);
|
|
167
167
|
assert.equal(project?.planningJournal?.entryCount, 0);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
test("heartbeat assistant replies are not appended to the planning journal", async () => {
|
|
171
|
+
const harness = await createServiceHarness("clawspec-ignore-heartbeat-journal-");
|
|
172
|
+
const { service, repoPath } = harness;
|
|
173
|
+
const channelKey = "discord:ignore-heartbeat-journal:default:main";
|
|
174
|
+
const promptContext = createPromptContext("ignore-heartbeat-journal");
|
|
175
|
+
const userPrompt = "Add one more API endpoint.";
|
|
176
|
+
|
|
177
|
+
await service.startProject(channelKey);
|
|
178
|
+
await service.useProject(channelKey, "demo-app");
|
|
179
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
180
|
+
await service.recordPlanningMessageFromContext(promptContext, userPrompt);
|
|
181
|
+
await service.handleBeforePromptBuild(
|
|
182
|
+
{ prompt: userPrompt, messages: [] },
|
|
183
|
+
promptContext,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await service.handleAgentEnd(
|
|
187
|
+
{
|
|
188
|
+
success: true,
|
|
189
|
+
messages: [
|
|
190
|
+
{ role: "user", content: userPrompt },
|
|
191
|
+
{ role: "assistant", content: "HEARTBEAT_OK" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
promptContext,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
198
|
+
const journalStore = new PlanningJournalStore(repoStatePaths.planningJournalFile);
|
|
199
|
+
const entries = await journalStore.list("demo-change");
|
|
200
|
+
|
|
201
|
+
assert.equal(entries.length, 1);
|
|
202
|
+
assert.equal(entries[0]?.role, "user");
|
|
203
|
+
});
|
|
@@ -67,3 +67,58 @@ test("planning journal falls back to lastSyncedAt when no snapshot exists yet",
|
|
|
67
67
|
true,
|
|
68
68
|
);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test("snapshot is always written after planning sync regardless of journal dirty state", async () => {
|
|
72
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-snapshot-always-"));
|
|
73
|
+
const journalPath = path.join(tempRoot, "planning-journal.jsonl");
|
|
74
|
+
const snapshotPath = path.join(tempRoot, "planning-journal.snapshot.json");
|
|
75
|
+
const store = new PlanningJournalStore(journalPath);
|
|
76
|
+
|
|
77
|
+
await store.append({
|
|
78
|
+
timestamp: "2026-03-27T03:00:00.000Z",
|
|
79
|
+
changeName: "test",
|
|
80
|
+
role: "user",
|
|
81
|
+
text: "initial requirement",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const snapshot1 = await store.writeSnapshot(snapshotPath, "test", "2026-03-27T03:05:00.000Z");
|
|
85
|
+
assert.equal(snapshot1.entryCount, 1);
|
|
86
|
+
|
|
87
|
+
await store.append({
|
|
88
|
+
timestamp: "2026-03-27T03:10:00.000Z",
|
|
89
|
+
changeName: "test",
|
|
90
|
+
role: "assistant",
|
|
91
|
+
text: "planning sync response",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const snapshot2 = await store.writeSnapshot(snapshotPath, "test", "2026-03-27T03:15:00.000Z");
|
|
95
|
+
assert.equal(snapshot2.entryCount, 2);
|
|
96
|
+
assert.equal(await store.hasUnsyncedChanges("test", snapshotPath), false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("snapshot correctly captures all journal entries including assistant messages", async () => {
|
|
100
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-snapshot-complete-"));
|
|
101
|
+
const journalPath = path.join(tempRoot, "planning-journal.jsonl");
|
|
102
|
+
const snapshotPath = path.join(tempRoot, "planning-journal.snapshot.json");
|
|
103
|
+
const store = new PlanningJournalStore(journalPath);
|
|
104
|
+
|
|
105
|
+
await store.append({
|
|
106
|
+
timestamp: "2026-03-27T03:00:00.000Z",
|
|
107
|
+
changeName: "test",
|
|
108
|
+
role: "user",
|
|
109
|
+
text: "user requirement",
|
|
110
|
+
});
|
|
111
|
+
await store.append({
|
|
112
|
+
timestamp: "2026-03-27T03:05:00.000Z",
|
|
113
|
+
changeName: "test",
|
|
114
|
+
role: "assistant",
|
|
115
|
+
text: "assistant response",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const snapshot = await store.writeSnapshot(snapshotPath, "test");
|
|
119
|
+
const digest = await store.digest("test");
|
|
120
|
+
|
|
121
|
+
assert.equal(snapshot.entryCount, digest.entryCount);
|
|
122
|
+
assert.equal(snapshot.lastEntryAt, digest.lastEntryAt);
|
|
123
|
+
assert.equal(snapshot.contentHash, digest.contentHash);
|
|
124
|
+
});
|
|
@@ -168,6 +168,8 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
168
168
|
assert.match(injected?.prependContext ?? "", /openspec instructions proposal --change demo-change --json/);
|
|
169
169
|
assert.match(injected?.prependContext ?? "", /planning-instructions[\\/]+proposal\.json/);
|
|
170
170
|
assert.deepEqual(instructionCalls, ["proposal", "specs", "design", "tasks"]);
|
|
171
|
+
assert.match(injected?.prependContext ?? "", /allowed planning scope is limited/i);
|
|
172
|
+
assert.match(injected?.prependContext ?? "", /Do not invent endpoints, features, constraints, files, acceptance criteria, test scenarios, or architecture details/i);
|
|
171
173
|
assert.match(injected?.prependContext ?? "", /mandatory final line exactly in this shape/i);
|
|
172
174
|
assert.equal(runningProject?.status, "planning");
|
|
173
175
|
assert.equal(runningProject?.phase, "planning_sync");
|
|
@@ -207,6 +209,48 @@ test("cs-plan runs visible planning sync and writes a fresh snapshot", async ()
|
|
|
207
209
|
assert.equal(snapshot?.changeName, "demo-change");
|
|
208
210
|
});
|
|
209
211
|
|
|
212
|
+
test("cs-plan finalizes and writes a fresh snapshot even if agent_end sessionKey changes", async () => {
|
|
213
|
+
const harness = await createServiceHarness("clawspec-visible-plan-fallback-");
|
|
214
|
+
const { service, stateStore, repoPath } = harness;
|
|
215
|
+
const channelKey = "discord:visible-plan-fallback:default:main";
|
|
216
|
+
const promptContext = {
|
|
217
|
+
trigger: "user",
|
|
218
|
+
channel: "discord",
|
|
219
|
+
channelId: "visible-plan-fallback",
|
|
220
|
+
accountId: "default",
|
|
221
|
+
conversationId: "main",
|
|
222
|
+
sessionKey: "agent:main:discord:channel:visible-plan-fallback",
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
await service.startProject(channelKey);
|
|
226
|
+
await service.useProject(channelKey, "demo-app");
|
|
227
|
+
await service.proposalProject(channelKey, "demo-change Demo change");
|
|
228
|
+
await service.recordPlanningMessageFromContext(promptContext, "add another API endpoint");
|
|
229
|
+
|
|
230
|
+
await service.handleBeforePromptBuild(
|
|
231
|
+
{ prompt: "cs-plan", messages: [] },
|
|
232
|
+
promptContext,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
await service.handleAgentEnd(
|
|
236
|
+
{ messages: [], success: true, durationMs: 10 },
|
|
237
|
+
{
|
|
238
|
+
...promptContext,
|
|
239
|
+
sessionKey: "agent:main:discord:channel:visible-plan-fallback:other",
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const finalized = await stateStore.getActiveProject(channelKey);
|
|
244
|
+
const repoStatePaths = getRepoStatePaths(repoPath, "archives");
|
|
245
|
+
const snapshot = await readJsonFile<any>(repoStatePaths.planningJournalSnapshotFile, null);
|
|
246
|
+
|
|
247
|
+
assert.equal(finalized?.status, "ready");
|
|
248
|
+
assert.equal(finalized?.phase, "tasks");
|
|
249
|
+
assert.equal(finalized?.planningJournal?.dirty, false);
|
|
250
|
+
assert.equal(snapshot?.changeName, "demo-change");
|
|
251
|
+
assert.equal(snapshot?.entryCount, 1);
|
|
252
|
+
});
|
|
253
|
+
|
|
210
254
|
test("cs-plan clears stale execution control artifacts from earlier worker runs", async () => {
|
|
211
255
|
const harness = await createServiceHarness("clawspec-visible-plan-cleanup-");
|
|
212
256
|
const { service, stateStore, repoPath } = harness;
|