@stigmer/runner 3.1.1 → 3.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.
Files changed (166) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Mid-run live capture — the harness-agnostic glue that turns a content-free git
3
+ * delta ({@link ./git-substrate.js} `captureProgressDelta`) into the transient
4
+ * `AgentExecutionStatus.file_change_progress` snapshot the "N files changed so
5
+ * far" strip renders (DD-32).
6
+ *
7
+ * THE MODEL
8
+ * ---------
9
+ * This is category 2 of the two status patterns: a runner-owned, latest-snapshot
10
+ * DISPLAY field, NOT the event-sourced file-review ledger. It is deliberately NOT
11
+ * a `FileChangeSet` — progress is never in the ledger, carries NO file bytes or
12
+ * digests, and is NEVER decidable. The turn-boundary CANDIDATE_CAPTURED (in
13
+ * `file_change_sets`) remains the single authoritative, reviewable diff; a mid-run
14
+ * snapshot is no more authoritative than a streamed tool-call arg.
15
+ *
16
+ * Both harnesses drive this through {@link captureFileChangeProgress} from their
17
+ * per-persist loop, each passing its OWN `excludePaths` (the runner-owned gate
18
+ * files it writes into the workspace), so progress and the turn-boundary candidate
19
+ * agree on what is "the agent's change". Capture is throttled by a floor +
20
+ * tree-sha short-circuit so a quiet turn is nearly free.
21
+ *
22
+ * SECRET SAFETY
23
+ * -------------
24
+ * No file bodies are ever carried. A secret-like path ({@link isSecretLikePath})
25
+ * is still surfaced (path visible) but with its line counts ZEROED — the same
26
+ * "path visible, content withheld" rule the ledger uses (DD-12). Nothing new can
27
+ * leak through this field.
28
+ *
29
+ * @since File-Change HITL Redesign (mid-run live capture / DD-32)
30
+ */
31
+
32
+ import { create } from "@bufbuild/protobuf";
33
+ import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
34
+ import type {
35
+ FileChangeProgress,
36
+ FileChangeProgressEntry,
37
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
38
+ import {
39
+ FileChangeProgressEntrySchema,
40
+ FileChangeProgressSchema,
41
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
42
+ import { utcTimestamp } from "../status.js";
43
+ import { toFileChangeKind } from "./capture.js";
44
+ import { captureProgressDelta, type ProgressDelta } from "./git-substrate.js";
45
+ import { isSecretLikePath } from "./secret-paths.js";
46
+
47
+ /**
48
+ * Cap the per-file entry list so a turn touching thousands of files does not
49
+ * bloat the persisted status (which rides Temporal / the store). `files_changed`
50
+ * and the aggregate counts stay honest over ALL files; the strip shows "… and K
51
+ * more" when the list is capped.
52
+ */
53
+ export const PROGRESS_MAX_ENTRIES = 200;
54
+
55
+ /**
56
+ * Minimum wall-clock gap between two mid-run captures, in ms. A floor on cost:
57
+ * every capture stages the working tree (`write-tree`), so we bound how often
58
+ * that runs regardless of persist frequency. Env-tunable for large-repo
59
+ * deployments; defaults to 2s (feels live without hammering git).
60
+ */
61
+ export const PROGRESS_CAPTURE_MIN_INTERVAL_MS = readMinIntervalMs();
62
+
63
+ function readMinIntervalMs(): number {
64
+ const raw = process.env.STIGMER_PROGRESS_CAPTURE_MIN_INTERVAL_MS;
65
+ if (!raw) return 2000;
66
+ const n = Number.parseInt(raw, 10);
67
+ return Number.isFinite(n) && n >= 0 ? n : 2000;
68
+ }
69
+
70
+ /**
71
+ * Whether enough wall-clock has elapsed since the last capture to take another.
72
+ * Pure and separated for direct testing (mirrors `persist-decision.ts`). A
73
+ * `lastAtMs` of 0 (never captured) always passes.
74
+ */
75
+ export function shouldCaptureProgress(
76
+ lastAtMs: number,
77
+ nowMs: number,
78
+ minIntervalMs: number = PROGRESS_CAPTURE_MIN_INTERVAL_MS,
79
+ ): boolean {
80
+ return nowMs - lastAtMs >= minIntervalMs;
81
+ }
82
+
83
+ /**
84
+ * Build the transient {@link FileChangeProgress} message from a content-free git
85
+ * delta. Zeroes counts for secret-like paths (path visible, magnitude withheld),
86
+ * caps the entry list at {@link PROGRESS_MAX_ENTRIES} while keeping
87
+ * `files_changed`/aggregate counts honest over every file, and stamps
88
+ * `captured_at`. Pure and exported for direct testing.
89
+ */
90
+ export function buildFileChangeProgress(
91
+ delta: ProgressDelta,
92
+ changeSetId: string,
93
+ ): FileChangeProgress {
94
+ let filesChanged = 0;
95
+ let totalAdded = 0;
96
+ let totalRemoved = 0;
97
+ const entries: FileChangeProgressEntry[] = [];
98
+
99
+ for (const entry of delta.entries) {
100
+ filesChanged += 1;
101
+ // A secret-like path is surfaced (path visible) but its magnitude is withheld
102
+ // — counts zeroed, mirroring the ledger's "path visible, content withheld".
103
+ const secret = isSecretLikePath(entry.pathAfter || entry.pathBefore);
104
+ const linesAdded = secret ? 0 : entry.linesAdded;
105
+ const linesRemoved = secret ? 0 : entry.linesRemoved;
106
+ totalAdded += linesAdded;
107
+ totalRemoved += linesRemoved;
108
+
109
+ if (entries.length < PROGRESS_MAX_ENTRIES) {
110
+ entries.push(
111
+ create(FileChangeProgressEntrySchema, {
112
+ pathBefore: entry.pathBefore,
113
+ pathAfter: entry.pathAfter,
114
+ kind: toFileChangeKind(entry.changeType),
115
+ linesAdded,
116
+ linesRemoved,
117
+ }),
118
+ );
119
+ }
120
+ }
121
+
122
+ return create(FileChangeProgressSchema, {
123
+ changeSetId,
124
+ filesChanged,
125
+ linesAdded: totalAdded,
126
+ linesRemoved: totalRemoved,
127
+ entries,
128
+ capturedAt: utcTimestamp(),
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Mutable per-turn state the caller threads across persists: the last captured
134
+ * tree sha (for the short-circuit) and the last capture time (for the floor).
135
+ * A fresh turn starts a fresh state (`{ lastAtMs: 0 }`).
136
+ */
137
+ export interface ProgressCaptureState {
138
+ lastTreeSha?: string;
139
+ lastAtMs: number;
140
+ }
141
+
142
+ /** A fresh per-turn progress state. */
143
+ export function newProgressCaptureState(): ProgressCaptureState {
144
+ return { lastAtMs: 0 };
145
+ }
146
+
147
+ /**
148
+ * Capture-and-attach the live progress snapshot onto `status.file_change_progress`,
149
+ * throttled by the floor + tree-sha short-circuit. Mutates `status` and `state`
150
+ * in place; a no-op when the floor has not elapsed or the working tree is
151
+ * unchanged since the last capture. Content-free and secret-safe (see
152
+ * {@link buildFileChangeProgress}). Called by both harnesses from their per-persist
153
+ * loop; each passes its own `excludePaths`.
154
+ *
155
+ * The field is set even when the delta is now EMPTY (the agent reverted its own
156
+ * edits) so the strip reflects the reversion (it hides at zero) rather than
157
+ * showing a stale count — the server's presence-guarded merge would keep the
158
+ * stale value if the runner omitted the field.
159
+ */
160
+ export async function captureFileChangeProgress(opts: {
161
+ readonly status: AgentExecutionStatus;
162
+ readonly gitRoot: string;
163
+ readonly executionId: string;
164
+ readonly changeSetId: string;
165
+ readonly baselineTree: string;
166
+ readonly excludePaths?: readonly string[];
167
+ readonly state: ProgressCaptureState;
168
+ /** Injectable clock for tests; defaults to `Date.now()`. */
169
+ readonly nowMs?: number;
170
+ }): Promise<void> {
171
+ const now = opts.nowMs ?? Date.now();
172
+ if (!shouldCaptureProgress(opts.state.lastAtMs, now)) return;
173
+ opts.state.lastAtMs = now;
174
+
175
+ const delta = await captureProgressDelta(
176
+ opts.gitRoot,
177
+ opts.executionId,
178
+ opts.baselineTree,
179
+ opts.excludePaths,
180
+ opts.state.lastTreeSha,
181
+ );
182
+ if (!delta) return; // working tree unchanged since the last capture
183
+
184
+ opts.state.lastTreeSha = delta.afterTree;
185
+ opts.status.fileChangeProgress = buildFileChangeProgress(delta, opts.changeSetId);
186
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The Build-from-plan prompt directive, shared by both harnesses.
3
+ *
4
+ * When the user approves a plan and clicks "Build from plan", the client
5
+ * submits an execution with `spec.execution_config.build_from_plan` set and a
6
+ * short human-readable message ("Build from plan") — it does NOT embed
7
+ * implement instructions in the message text. The runner owns the agent-facing
8
+ * instruction, injected from this module, so the chat thread can render the
9
+ * turn as a compact chip while the model still receives the full contract.
10
+ *
11
+ * The directive has two variants, chosen by whether the approved plan document
12
+ * actually materialized in the workspace:
13
+ * - Attached (the normal case): the client uploaded the approved plan text
14
+ * (edited or not) and attached it, mounted under `.stigmer/inputs/` as the
15
+ * plan's own file (`<slug>_<id>.plan.md`). The directive points the model at
16
+ * that file and names it authoritative —
17
+ * the user may have refined the document after the plan turn, so the
18
+ * conversation's version can be stale.
19
+ * - Conversation-only (the client's upload failed): the directive falls back
20
+ * to the plan as proposed in the conversation.
21
+ *
22
+ * Like `PLAN_MODE_DIRECTIVE` (plan-mode-prompt.ts), this module is the single
23
+ * source of truth for the words; each harness wraps them in its own prompt
24
+ * framing (XML-tag section for Cursor, markdown heading for the native
25
+ * harness).
26
+ */
27
+
28
+ import { isPlanArtifactName } from "./plan-artifact.js";
29
+
30
+ /**
31
+ * Progress-tracking instruction shared by both directive variants (Tier 3 of
32
+ * Plan mode — plan-driven build progress). The agent's own to-do tool is the
33
+ * single writer of `status.todos`, so instructing it to derive the list from
34
+ * the approved plan is the entire plan→progress linkage: the runner's todo
35
+ * extraction and the clients' todo renderers light up unchanged.
36
+ *
37
+ * Deliberately tool-agnostic ("your to-do list"): the Cursor harness exposes
38
+ * TodoWrite/updateTodos, the native harness write_todos, and each runtime
39
+ * already teaches the model its own tool.
40
+ *
41
+ * Wording constraint: this block rides BOTH variants, and the conversation-only
42
+ * variant is pinned by tests to never contain "plan.md" (it has no plan file
43
+ * to reference) — so say "the plan", never name the file.
44
+ */
45
+ const TRACK_PROGRESS_INSTRUCTION = [
46
+ "Track your progress with your to-do list so the user can follow the " +
47
+ "build:",
48
+ "- Before you start, break the plan into a concrete, ordered to-do list — " +
49
+ "roughly one item per implementation step.",
50
+ "- As you work, keep it current: mark each item in progress when you " +
51
+ "begin it and completed when it is done.",
52
+ ].join("\n");
53
+
54
+ /**
55
+ * Find the approved plan document among the workspace paths the harness
56
+ * injected for this execution's attachments. Detection keys on the plan
57
+ * filename convention ({@link isPlanArtifactName} — the legacy `plan.md` or any
58
+ * `*.plan.md`), the same convention the UI uses to detect the plan artifact.
59
+ * Returns `undefined` when no plan attachment landed (upload failed, or the
60
+ * attachment itself failed to inject), which selects the conversation-only
61
+ * directive variant.
62
+ */
63
+ export function findApprovedPlanPath(
64
+ attachmentPaths: readonly string[],
65
+ ): string | undefined {
66
+ return attachmentPaths.find((p) => {
67
+ const name = p.split("/").pop();
68
+ return name !== undefined && isPlanArtifactName(name);
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Build the implement-plan directive body for a Build-from-plan execution.
74
+ *
75
+ * @param planPath - Workspace-relative path of the attached approved plan
76
+ * (from {@link findApprovedPlanPath}), or `undefined` when the plan exists
77
+ * only in the conversation.
78
+ */
79
+ export function buildImplementPlanDirective(planPath?: string): string {
80
+ if (planPath) {
81
+ return [
82
+ "IMPORTANT: This turn implements a plan the user has reviewed and " +
83
+ "APPROVED.",
84
+ "",
85
+ `The approved plan document is attached at \`${planPath}\`. Read it ` +
86
+ "FIRST, then implement it step by step.",
87
+ "",
88
+ "That document is the authoritative version of the plan — the user may " +
89
+ "have edited it after it was proposed, so where it differs from the " +
90
+ "conversation above, follow the document.",
91
+ "",
92
+ TRACK_PROGRESS_INSTRUCTION,
93
+ ].join("\n");
94
+ }
95
+
96
+ return [
97
+ "IMPORTANT: This turn implements a plan the user has reviewed and " +
98
+ "APPROVED.",
99
+ "",
100
+ "Implement the plan proposed in the conversation above, step by step.",
101
+ "",
102
+ TRACK_PROGRESS_INSTRUCTION,
103
+ ].join("\n");
104
+ }
@@ -2,11 +2,30 @@
2
2
  * Plan-mode artifact publishing.
3
3
  *
4
4
  * When an execution runs in Plan mode (InteractionMode.PLAN), the agent's final
5
- * message IS the plan. We publish that text as a first-class `plan.md`
5
+ * message IS the plan. We publish that text as a first-class plan markdown
6
6
  * ExecutionArtifact so the UI can render a reviewable Plan card with
7
7
  * copy/download, and a follow-up "Implement" execution can reference it
8
8
  * deterministically.
9
9
  *
10
+ * The artifact is named from the plan's own title — a hyphenated slug of its
11
+ * leading `# H1`, a `_<hash>` discriminator, and a `.plan.md` suffix (e.g.
12
+ * `plan-card-ux-cleanup_a1b2c3d4.plan.md`), so a downloaded plan lands as a
13
+ * recognizable file and the card, plan tab, and saved file all agree on one
14
+ * name. A plan with no derivable title falls back to a bare `<hash>.plan.md`.
15
+ *
16
+ * The `_<hash>` discriminator (first 8 hex of the plan content's SHA-256) is
17
+ * NOT for storage uniqueness — storage keys are already execution-scoped
18
+ * (`artifacts/{execId}/<name>`). It exists because the artifact BASENAME is a
19
+ * user-facing shared namespace: downloads save under it (see the artifact
20
+ * download disposition) and the artifact list surfaces it, so two same-titled
21
+ * plans would otherwise collide in the user's Downloads folder and read
22
+ * identically in the list. Deriving it from content (not a random or
23
+ * execution-scoped value) keeps naming honestly idempotent — identical content
24
+ * yields an identical name, so a finalize retry re-uploads to the same key,
25
+ * while any real edit yields a distinct one. This refines DD-23 §D3 ("no
26
+ * uniqueness hash"), which was correct about storage but overlooked the
27
+ * download/list basename namespace introduced by DD-23 §D1.
28
+ *
10
29
  * This is deliberately a single, harness-agnostic helper:
11
30
  * - The native (deepagents) harness already auto-publishes files an agent
12
31
  * writes (InlinePublisher), but Plan mode is read-only, so there is no file to
@@ -21,8 +40,8 @@
21
40
  *
22
41
  * The plan content is NOT duplicated as a separate stored blob beyond this
23
42
  * artifact — the chat message remains the live/streamed view; the artifact is
24
- * the durable/exportable view, detected by convention (a FILE artifact named
25
- * `plan.md`).
43
+ * the durable/exportable view, detected by convention (a FILE artifact whose
44
+ * name satisfies {@link isPlanArtifactName}).
26
45
  */
27
46
 
28
47
  import { createHash } from "node:crypto";
@@ -36,15 +55,129 @@ import {
36
55
  import type { ArtifactStorage } from "./artifact-storage.js";
37
56
  import { utcTimestamp } from "./status.js";
38
57
 
39
- /** Canonical filename for a plan artifact. UI detection keys on this name. */
58
+ /**
59
+ * Legacy plan filename. Detection-only: {@link isPlanArtifactName} still
60
+ * accepts this exact name so plans published before named artifacts existed
61
+ * keep working. It is NEVER freshly emitted — a titleless plan now falls back
62
+ * to a bare `<hash>.plan.md` (see {@link planArtifactName}).
63
+ */
40
64
  export const PLAN_ARTIFACT_NAME = "plan.md";
41
65
 
42
66
  /**
43
- * Sandbox path recorded on the artifact. Routes under `.stigmer/` (the
44
- * session platform dir), so it never pollutes the user's workspace, and a
45
- * follow-up execution can reference it via workspace file refs if desired.
67
+ * Suffix every named plan artifact carries. The UI keys plan detection on this
68
+ * suffix, so it must stay in sync with the SDK's `PLAN_ARTIFACT_SUFFIX`
69
+ * (`sdk/react/src/library/detect-plan-artifact.ts`). The two cannot share a
70
+ * module — the runner and the browser SDK have disjoint module graphs — so the
71
+ * constant is duplicated by design, mirroring {@link PLAN_ARTIFACT_NAME}.
72
+ */
73
+ export const PLAN_ARTIFACT_SUFFIX = ".plan.md";
74
+
75
+ /** Longest slug we derive from a plan title before the `_<id>.plan.md` tail. */
76
+ const MAX_PLAN_SLUG_LENGTH = 60;
77
+
78
+ /** Hex length of the content-hash discriminator appended to named plans. */
79
+ const PLAN_ID_LENGTH = 8;
80
+
81
+ /**
82
+ * Reports whether an artifact filename is a plan: the legacy exact name, or any
83
+ * `*.plan.md`. Kept in sync with the SDK's `isPlanArtifactName`.
46
84
  */
47
- export const PLAN_ARTIFACT_SANDBOX_PATH = ".stigmer/plans/plan.md";
85
+ export function isPlanArtifactName(name: string): boolean {
86
+ return name === PLAN_ARTIFACT_NAME || name.endsWith(PLAN_ARTIFACT_SUFFIX);
87
+ }
88
+
89
+ // Plan-title extraction, mirrored from the SDK so the runner's derived filename
90
+ // and the card's displayed title agree on one title from one source (the plan's
91
+ // leading `# H1`). These three patterns are a verbatim copy of
92
+ // `sdk/react/src/internal/markdown-components.tsx`
93
+ // (`ENCLOSING_MARKDOWN_FENCE_RE`, `ENCLOSING_BARE_FENCE_RE`, `LEADING_H1_RE`)
94
+ // and MUST stay in sync with it — a fence-wrapped plan whose title the card
95
+ // unwraps must slug to that same title here, never to the fallback.
96
+ const ENCLOSING_MARKDOWN_FENCE_RE =
97
+ /^(`{3,})[ \t]*(?:markdown|md)[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/i;
98
+ const ENCLOSING_BARE_FENCE_RE = /^(`{3,})[ \t]*\r?\n([\s\S]*?)\r?\n\1[ \t]*$/;
99
+ const LEADING_H1_RE = /^#[ \t]+(.+?)[ \t]*(?:\r?\n+|$)/;
100
+
101
+ /**
102
+ * The plan's title: the leading `# H1` of the plan text, after the same
103
+ * plan-scoped enclosing-fence unwrap (tagged ```markdown``` or a bare ``` `)
104
+ * the document renderers apply. `undefined` when the plan has no leading H1.
105
+ */
106
+ function extractPlanTitle(planText: string): string | undefined {
107
+ const trimmed = planText.trim();
108
+ let body = trimmed;
109
+ const tagged = ENCLOSING_MARKDOWN_FENCE_RE.exec(trimmed);
110
+ if (tagged) {
111
+ body = tagged[2];
112
+ } else {
113
+ const bare = ENCLOSING_BARE_FENCE_RE.exec(trimmed);
114
+ if (bare) body = bare[2];
115
+ }
116
+ const h1 = LEADING_H1_RE.exec(body.trim());
117
+ return h1 ? h1[1] : undefined;
118
+ }
119
+
120
+ /**
121
+ * Strips a leading "Plan" LABEL from a title, e.g. `Plan: Create X` -> `Create
122
+ * X`. The separator (`:` or a dash) is REQUIRED: this removes a redundant label
123
+ * (a plan document already announces itself via the `.plan.md` suffix and the
124
+ * Plan card framing) without ever clipping a real title word — a bare `\bplan\b`
125
+ * would wrongly turn "Plan card UX cleanup" into "card UX cleanup".
126
+ *
127
+ * Deliberately runner/filename-only and NOT mirrored into the SDK's title
128
+ * extraction: display surfaces render the message's own `# H1` faithfully
129
+ * (`extractLeadingH1` is general-purpose and render-time-only), so the clean
130
+ * title is fixed at the source — the plan-mode prompt tells the model not to
131
+ * prefix titles with "Plan:". This strip is the durable-artifact safety net for
132
+ * when the model (or the enforcement-less Cursor harness) drifts.
133
+ */
134
+ function stripPlanLabel(title: string): string {
135
+ return title.replace(/^plan\s*[:\u2013\u2014-]\s*/i, "");
136
+ }
137
+
138
+ /**
139
+ * Slugifies a plan title into a filename-safe stem: lowercase, every run of
140
+ * non-alphanumerics collapsed to `-`, trimmed of leading/trailing `-`, and
141
+ * capped at {@link MAX_PLAN_SLUG_LENGTH}. Returns `""` for a title with no
142
+ * alphanumerics (e.g. only punctuation), which selects the fallback name.
143
+ */
144
+ function slugifyPlanTitle(title: string): string {
145
+ return title
146
+ .toLowerCase()
147
+ .replace(/[^a-z0-9]+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ .slice(0, MAX_PLAN_SLUG_LENGTH)
150
+ .replace(/-+$/g, "");
151
+ }
152
+
153
+ /**
154
+ * Derives the plan artifact's filename from its text: `<slug>_<id>.plan.md`
155
+ * when the plan opens with a titled `# H1`, else a bare `<id>.plan.md`. The
156
+ * `<id>` is the first {@link PLAN_ID_LENGTH} hex of the content's SHA-256 — a
157
+ * user-facing discriminator for the shared download/list basename namespace,
158
+ * not a storage-uniqueness device (see the module doc).
159
+ */
160
+ export function planArtifactName(planText: string): string {
161
+ const id = createHash("sha256")
162
+ .update(planText, "utf-8")
163
+ .digest("hex")
164
+ .slice(0, PLAN_ID_LENGTH);
165
+ const title = extractPlanTitle(planText);
166
+ const slug = title ? slugifyPlanTitle(stripPlanLabel(title)) : "";
167
+ return slug.length > 0
168
+ ? `${slug}_${id}${PLAN_ARTIFACT_SUFFIX}`
169
+ : `${id}${PLAN_ARTIFACT_SUFFIX}`;
170
+ }
171
+
172
+ /**
173
+ * Sandbox path recorded on the artifact for a given filename. Routes under
174
+ * `.stigmer/` (the session platform dir), so it never pollutes the user's
175
+ * workspace, and a follow-up execution can reference it via workspace file
176
+ * refs if desired.
177
+ */
178
+ export function planArtifactSandboxPath(name: string): string {
179
+ return `.stigmer/plans/${name}`;
180
+ }
48
181
 
49
182
  /**
50
183
  * Returns the text of the last AI message in a completed status, trimmed.
@@ -62,9 +195,11 @@ export function extractFinalPlanText(status: AgentExecutionStatus): string | und
62
195
  }
63
196
 
64
197
  /**
65
- * Publishes `planText` as a `plan.md` ExecutionArtifact and registers it on
66
- * `status.artifacts`. Idempotent: re-publishing replaces any existing `plan.md`
67
- * rather than appending a duplicate, preserving a single source of truth.
198
+ * Publishes `planText` as a plan ExecutionArtifact (named from its title —
199
+ * see {@link planArtifactName}) and registers it on `status.artifacts`.
200
+ * Idempotent: re-publishing replaces any existing plan artifact matched by
201
+ * {@link isPlanArtifactName}, not exact name, so a re-plan whose title changed
202
+ * still supersedes rather than appends — preserving a single source of truth.
68
203
  *
69
204
  * Fire-and-forget by contract: a plan that fails to upload must never fail the
70
205
  * execution. Errors are logged and swallowed.
@@ -84,13 +219,14 @@ export async function publishPlanArtifact(opts: {
84
219
  try {
85
220
  const content = Buffer.from(planText, "utf-8");
86
221
  const contentHash = createHash("sha256").update(content).digest("hex");
87
- const storageKey = `artifacts/${executionId}/${PLAN_ARTIFACT_NAME}`;
222
+ const name = planArtifactName(planText);
223
+ const storageKey = `artifacts/${executionId}/${name}`;
88
224
 
89
225
  await artifactStorage.upload(storageKey, content, "text/markdown");
90
226
 
91
227
  const artifact = create(ExecutionArtifactSchema, {
92
- name: PLAN_ARTIFACT_NAME,
93
- sandboxPath: PLAN_ARTIFACT_SANDBOX_PATH,
228
+ name,
229
+ sandboxPath: planArtifactSandboxPath(name),
94
230
  kind: ExecutionArtifactKind.FILE,
95
231
  sizeBytes: BigInt(content.length),
96
232
  storageKey,
@@ -98,7 +234,7 @@ export async function publishPlanArtifact(opts: {
98
234
  contentHash,
99
235
  });
100
236
 
101
- const existingIdx = status.artifacts.findIndex((a) => a.name === PLAN_ARTIFACT_NAME);
237
+ const existingIdx = status.artifacts.findIndex((a) => isPlanArtifactName(a.name));
102
238
  if (existingIdx >= 0) {
103
239
  status.artifacts[existingIdx] = artifact;
104
240
  } else {
@@ -106,7 +242,7 @@ export async function publishPlanArtifact(opts: {
106
242
  }
107
243
 
108
244
  console.log(
109
- `[plan-artifact] execution=${executionId} — published ${PLAN_ARTIFACT_NAME} ` +
245
+ `[plan-artifact] execution=${executionId} — published ${name} ` +
110
246
  `(${content.length} bytes, hash=${contentHash.slice(0, 12)})`,
111
247
  );
112
248
  } catch (err) {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The Plan-mode prompt directive, shared by both harnesses.
3
+ *
4
+ * Plan mode's output contract spans three components that must agree:
5
+ * - the model produces the plan as its FINAL message (this directive),
6
+ * - the runner publishes that final message verbatim as a plan markdown
7
+ * artifact, named from the plan's title (`plan-artifact.ts` —
8
+ * `extractFinalPlanText`, `planArtifactName`),
9
+ * - the SDK promotes the same message to a first-class plan document in the
10
+ * thread and offers "Build from plan".
11
+ *
12
+ * This module is the single source of truth for how the model is told to
13
+ * behave, so the two harnesses can never drift apart on the contract. Each
14
+ * harness wraps the directive in its own prompt framing (the Cursor harness
15
+ * uses XML-tag sections, the native harness markdown headings) — the framing
16
+ * is house style; the words are shared.
17
+ *
18
+ * Enforcement is separate from instruction: the native harness ALSO denies
19
+ * filesystem writes at the tool layer (see execute-deep-agent/setup.ts), so
20
+ * for it this directive is guidance toward a well-formed plan, not the
21
+ * enforcement mechanism. The Cursor harness has no tool-level lever (the
22
+ * Cursor SDK exposes no mode parameter), so there this directive is the
23
+ * enforcement — a documented stopgap.
24
+ */
25
+
26
+ /**
27
+ * Directive body injected into the system prompt of every Plan-mode
28
+ * execution. Deliberately explicit about the deliverable's shape: the final
29
+ * message is published verbatim as a plan document (whose filename is derived
30
+ * from the leading `#` title), so a fenced or chat-suffixed plan degrades the
31
+ * reviewable document the user sees.
32
+ */
33
+ export const PLAN_MODE_DIRECTIVE = [
34
+ "IMPORTANT: You are in Plan mode — a read-only analysis turn whose " +
35
+ "deliverable is an implementation plan.",
36
+ "",
37
+ "Constraints:",
38
+ "- Do NOT create, edit, or delete any files.",
39
+ "- Do NOT run commands that modify the filesystem or any external state.",
40
+ "- Only read, search, and analyze.",
41
+ "",
42
+ "Deliverable — your FINAL message IS the plan. It is published verbatim " +
43
+ "as a plan document that the user reviews and builds from, so:",
44
+ "- Write it as a complete, well-structured markdown document: start with " +
45
+ "a single `#` title and organize the work under `##` section headings. " +
46
+ "Use lists and tables where they aid scanning.",
47
+ '- Give the `#` title a concise, descriptive name for the work itself; do ' +
48
+ 'NOT prefix it with "Plan:" (this document is already a plan — the ' +
49
+ "prefix is redundant and leaks into the plan's filename).",
50
+ "- Reference concrete file paths and describe the specific changes " +
51
+ "planned for each.",
52
+ "- Do NOT wrap the document in a code fence.",
53
+ '- Do NOT end with conversational closers ("Let me know...", "Shall I ' +
54
+ 'proceed?") — the next step is the user\'s Build action, and trailing ' +
55
+ "chat would be published as part of the document.",
56
+ ].join("\n");
@@ -193,6 +193,17 @@ export async function persistStatus(
193
193
  /**
194
194
  * Report a setup progress phase (e.g. "Resolving MCP servers") so the
195
195
  * frontend can display a spinner with context.
196
+ *
197
+ * Deliberately carries NO execution phase (UNSPECIFIED = "leave unchanged"):
198
+ * the server keeps `setup_progress` only while the merged phase is still
199
+ * PENDING (update_status.go / AgentExecutionUpdateStatusHandler clear it the
200
+ * moment the phase leaves PENDING), and the UI renders the label only in
201
+ * that window (SetupProgress.tsx: "Server-driven — preferred during
202
+ * PENDING"). Sending IN_PROGRESS here flipped the phase on the very first
203
+ * setup report, so every label self-destructed on arrival and the user saw
204
+ * a generic spinner instead. The genuine PENDING→IN_PROGRESS transition
205
+ * belongs to the streaming path, which persists it when the agent actually
206
+ * starts producing output.
196
207
  */
197
208
  export async function reportSetupProgress(
198
209
  client: StigmerClient,
@@ -200,7 +211,6 @@ export async function reportSetupProgress(
200
211
  phase: string,
201
212
  ): Promise<void> {
202
213
  const status = create(AgentExecutionStatusSchema, {
203
- phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
204
214
  setupProgress: create(SetupProgressSchema, { currentPhase: phase }),
205
215
  });
206
216
  await persistStatus(client, executionId, status);