@yagni-app/code-staging 0.0.0 → 0.1.0-staging.997.1

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 (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,236 @@
1
+ /**
2
+ * P3 - interactive permission tiers + plan mode, wired to pi's tool-call gate.
3
+ *
4
+ * YAGNI Code registers no tool_call handler today, so the interactive session has
5
+ * no plan/approval surface. P3 adds one on pi's documented `tool_call` block seam
6
+ * plus a `/mode` command:
7
+ * - auto (default): never blocks. Byte-identical to today, so this is additive.
8
+ * - plan : blocks write/edit/bash so the agent can explore + propose without
9
+ * touching the tree.
10
+ * - review : surfaces a three-way ctx.ui.select before a write/edit/bash; a
11
+ * session bless (policy.isBlessed) auto-approves an action the user
12
+ * already OK'd for this tool + directory, so only novel actions
13
+ * prompt. Picking "Yes, and don't ask again for <tool> in <dir>"
14
+ * adds a session-scoped bless rule AND drafts a decision capture.
15
+ *
16
+ * `decideGate` is PURE; the live wiring holds the mode in a small closure (no
17
+ * module-global state). The default auto mode remains fail-open, but stricter
18
+ * modes fail closed for side-effect tools if the gate itself errors. Bless rules
19
+ * are session-scoped, path-prefix-bound, never persisted, and never consulted in
20
+ * plan mode (plan blocks outright before isBlessed is reached).
21
+ *
22
+ * Plan mode is a full mode, not just a blocker: while active, every agent turn
23
+ * carries a hidden [PLAN MODE ACTIVE] context message so the model plans
24
+ * instead of flailing against held writes, and a footer chip shows the mode.
25
+ * When the mode leaves plan, stale plan-context messages are filtered out of
26
+ * the context so the model doesn't keep believing it is restricted.
27
+ */
28
+ import { makeBlessStore as defaultMakeBlessStore } from "./bless.js";
29
+ export const DEFAULT_PERMISSION_POLICY = {
30
+ planBlockTools: ["write", "edit", "bash"],
31
+ reviewConfirmTools: ["write", "edit", "bash"],
32
+ };
33
+ /**
34
+ * Pure permission decision for one tool call under a mode + policy. auto always
35
+ * allows; plan blocks the write/exec set; review marks writes for confirmation
36
+ * unless a recorded decision blesses them.
37
+ */
38
+ export function decideGate(toolName, params, mode, policy) {
39
+ if (mode === "auto")
40
+ return { block: false };
41
+ if (mode === "plan") {
42
+ if (policy.planBlockTools.includes(toolName)) {
43
+ return {
44
+ block: true,
45
+ reason: `plan mode: ${toolName} is a write or exec action and is held. Switch to /mode auto to apply changes.`,
46
+ };
47
+ }
48
+ return { block: false };
49
+ }
50
+ // review
51
+ if (policy.reviewConfirmTools.includes(toolName)) {
52
+ if (policy.isBlessed?.(toolName, params))
53
+ return { block: false };
54
+ return { block: false, confirm: true };
55
+ }
56
+ return { block: false };
57
+ }
58
+ /** The customType tag on injected plan-mode context (filterable later). */
59
+ export const PLAN_CONTEXT_TYPE = "yagni-plan-context";
60
+ const PLAN_MARKER = "[PLAN MODE ACTIVE]";
61
+ export const PLAN_CONTEXT_MESSAGE = `${PLAN_MARKER}
62
+ You are in plan mode: explore and design, change nothing.
63
+ - write, edit, and bash are held by the permission gate; do not attempt them.
64
+ - Read, search, and ask_yagni freely to ground the plan in how this company works.
65
+ - Produce a concrete numbered plan of the steps you would take, with the files involved.
66
+ - End by asking the user to review the plan; they run /mode auto (or /mode review) to execute it.
67
+ - Once executing, track the plan's steps with todo_write.`;
68
+ function mentionsPlanMarker(content) {
69
+ if (typeof content === "string")
70
+ return content.includes(PLAN_MARKER);
71
+ if (Array.isArray(content)) {
72
+ return content.some((c) => typeof c?.text === "string" &&
73
+ (c.text.includes(PLAN_MARKER)));
74
+ }
75
+ return false;
76
+ }
77
+ /**
78
+ * Drop previously injected plan-mode context once plan mode is off, so the
79
+ * model stops believing writes are held. Pure; returns the SAME array when
80
+ * nothing needs filtering so callers can cheaply detect a no-op.
81
+ */
82
+ export function filterStalePlanContext(messages) {
83
+ const keep = messages.filter((m) => {
84
+ const msg = m;
85
+ if (msg?.customType === PLAN_CONTEXT_TYPE)
86
+ return false;
87
+ if (msg?.role === "user" && mentionsPlanMarker(msg.content))
88
+ return false;
89
+ return true;
90
+ });
91
+ return keep.length === messages.length ? messages : keep;
92
+ }
93
+ const MODE_STATUS = {
94
+ auto: undefined,
95
+ plan: "⏸ plan",
96
+ review: "✓ review",
97
+ };
98
+ const MODE_COPY = {
99
+ auto: "auto: changes apply without prompting (default).",
100
+ plan: "plan: write, edit, and bash are held so the agent can explore and propose only.",
101
+ review: "review: you confirm each write, edit, or bash command before it applies.",
102
+ };
103
+ function isMode(value) {
104
+ return value === "auto" || value === "plan" || value === "review";
105
+ }
106
+ function sideEffectTools(policy) {
107
+ return new Set([...policy.planBlockTools, ...policy.reviewConfirmTools]);
108
+ }
109
+ /**
110
+ * Wire the tool_call gate + the /mode command onto a shared mode holder. Default
111
+ * auto, so absent any /mode this is a no-op over today's behavior.
112
+ */
113
+ export function registerPermissionGate(pi, deps = {}) {
114
+ const basePolicy = deps.policy ?? DEFAULT_PERMISSION_POLICY;
115
+ let mode = deps.mode ?? "auto";
116
+ const makeStore = deps.makeBlessStore ?? defaultMakeBlessStore;
117
+ // The session bless store is created lazily on the first tool_call (it needs
118
+ // the cwd). Its isBlessed backs the review-mode auto-approve, UNLESS the caller
119
+ // injected its own isBlessed (e.g. a test policy) — that always wins.
120
+ let blessStore = null;
121
+ const effectivePolicy = {
122
+ planBlockTools: basePolicy.planBlockTools,
123
+ reviewConfirmTools: basePolicy.reviewConfirmTools,
124
+ isBlessed: basePolicy.isBlessed ?? ((tool, params) => blessStore?.isBlessed(tool, params) ?? false),
125
+ };
126
+ const sideEffects = sideEffectTools(effectivePolicy);
127
+ pi.on("tool_call", async (event, ctx) => {
128
+ try {
129
+ const input = event.input ?? {};
130
+ const decision = decideGate(event.toolName, input, mode, effectivePolicy);
131
+ if (decision.block)
132
+ return { block: true, reason: decision.reason };
133
+ if (decision.confirm) {
134
+ // Review mode needs a confirmation. With no dialog-capable UI (headless),
135
+ // fail CLOSED: the user explicitly chose a stricter mode, so a write we
136
+ // cannot get consent for is held rather than silently auto-applied (this
137
+ // mirrors plan mode, which blocks regardless of UI).
138
+ if (!ctx?.hasUI) {
139
+ return { block: true, reason: `review mode: ${event.toolName} held (no UI to confirm). Switch to /mode auto to apply.` };
140
+ }
141
+ // Lazily bind the bless store to this session's cwd.
142
+ if (!blessStore)
143
+ blessStore = makeStore(ctx.cwd ?? ".");
144
+ // Three-way prompt (pi's confirm is boolean-only, so use select): Yes,
145
+ // Yes-and-remember (only when a path-prefix bless is meaningful), or No.
146
+ const dir = blessStore.describeDir(input);
147
+ const blessable = dir !== null;
148
+ const yes = "Yes";
149
+ const no = "No";
150
+ const remember = blessable
151
+ ? `Yes, and don't ask again for ${event.toolName} in ${dir}`
152
+ : undefined;
153
+ const options = blessable ? [yes, remember, no] : [yes, no];
154
+ const choice = await ctx.ui.select("YAGNI Code review mode", options);
155
+ if (choice === yes)
156
+ return {};
157
+ if (blessable && choice === remember) {
158
+ // Session-scoped bless, then a fire-and-forget decision capture so the
159
+ // approved tool call is never delayed by the capture prompt.
160
+ blessStore.add(event.toolName, input);
161
+ if (deps.onBlessRemember) {
162
+ void Promise.resolve(deps.onBlessRemember(ctx, { tool: event.toolName, dir: dir })).catch(() => { });
163
+ }
164
+ return {};
165
+ }
166
+ // "No" or a dismissed dialog (undefined) → held.
167
+ return { block: true, reason: `declined ${event.toolName} in review mode` };
168
+ }
169
+ return {};
170
+ }
171
+ catch {
172
+ if (mode !== "auto" && sideEffects.has(event.toolName)) {
173
+ return {
174
+ block: true,
175
+ reason: `permission gate failed while ${mode} mode was active; held ${event.toolName}. Switch to /mode auto to apply.`,
176
+ };
177
+ }
178
+ return {};
179
+ }
180
+ });
181
+ // Model awareness: while plan mode is on, every agent start carries a hidden
182
+ // plan-mode context message so the model plans instead of attempting held
183
+ // writes. Off-plan turns inject nothing.
184
+ pi.on("before_agent_start", async () => {
185
+ if (mode !== "plan")
186
+ return;
187
+ return {
188
+ message: { customType: PLAN_CONTEXT_TYPE, content: PLAN_CONTEXT_MESSAGE, display: false },
189
+ };
190
+ });
191
+ // Once plan mode is off, strip the stale plan context so the model does not
192
+ // keep believing writes are held.
193
+ pi.on("context", async (event) => {
194
+ if (mode === "plan")
195
+ return;
196
+ const filtered = filterStalePlanContext(event.messages);
197
+ if (filtered === event.messages)
198
+ return;
199
+ return { messages: filtered };
200
+ });
201
+ const paintMode = (ctx) => {
202
+ try {
203
+ if (ctx.hasUI)
204
+ ctx.ui.setStatus?.("yagni-mode", MODE_STATUS[mode]);
205
+ }
206
+ catch {
207
+ // The chip is chrome; never let it break /mode.
208
+ }
209
+ };
210
+ pi.registerCommand("mode", {
211
+ description: "Set the permission tier: /mode auto | plan | review. No argument shows the current mode.",
212
+ handler: async (args, ctx) => {
213
+ const notify = (m, t) => {
214
+ if (ctx.hasUI)
215
+ ctx.ui.notify(m, t);
216
+ };
217
+ const arg = args.trim().toLowerCase();
218
+ if (!arg) {
219
+ notify(`Permission mode is ${mode}. ${MODE_COPY[mode]}`, "info");
220
+ return;
221
+ }
222
+ if (!isMode(arg)) {
223
+ notify(`Unknown mode "${arg}". Use auto, plan, or review.`, "warning");
224
+ return;
225
+ }
226
+ const leavingPlan = mode === "plan" && arg !== "plan";
227
+ mode = arg;
228
+ paintMode(ctx);
229
+ notify(`Permission mode set to ${mode}. ${MODE_COPY[mode]}`, "info");
230
+ if (leavingPlan) {
231
+ notify("Plan mode off. Tell the agent to execute the plan; it tracks steps with /todos.", "info");
232
+ }
233
+ },
234
+ });
235
+ }
236
+ //# sourceMappingURL=permission.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * PURE curation layer for the live `/go` activity feed (spec §3).
3
+ *
4
+ * Mirrors `events.ts`: deterministic functions over canned `JsonEvent`s, with no
5
+ * clock, no I/O, and no model call. This is where "summary, not every event" is
6
+ * enforced — we surface assistant narration and concrete tool actions and drop
7
+ * everything else. Every function is defensive on missing / oddly-shaped data:
8
+ * it never throws, only ever reads basenames (no absolute paths leak into the
9
+ * feed), and falls back to the bare tool name when args are unusable.
10
+ */
11
+ import { type ActivityLine, type JsonEvent, type StageTag } from "./types.js";
12
+ /**
13
+ * Deterministic, defensive label for a tool action. Reads only the fields the
14
+ * spec table names, only ever surfaces a basename, and falls back to the bare
15
+ * tool name whenever the expected arg is missing or not a usable string.
16
+ */
17
+ export declare function toolLabel(toolName: string, args?: Record<string, unknown>): string;
18
+ /**
19
+ * The first non-empty line of an assistant text part, collapsed and clipped to
20
+ * `NARRATION_MAX`. Returns null when there is no usable headline.
21
+ */
22
+ export declare function narrationHeadline(text: string): string | null;
23
+ /**
24
+ * Curate one raw NDJSON event into an `ActivityLine`, or null to drop it.
25
+ *
26
+ * - assistant `message_end` with a text part -> narration (drops pure tool-call
27
+ * turns and empty headlines)
28
+ * - `tool_execution_start` -> running action carrying its `toolCallId` + label
29
+ * - `tool_execution_end` -> resolving action (done / error) with no text; the
30
+ * feed model resolves it against the running line by `toolCallId`
31
+ * - everything else -> null
32
+ *
33
+ * `tag` travels alongside the line (the feed attributes it to a stage / lens);
34
+ * the curated line content itself is stage-agnostic, so `tag` is not read here.
35
+ */
36
+ export declare function eventToLine(ev: JsonEvent, _tag: StageTag): ActivityLine | null;
37
+ //# sourceMappingURL=activity.d.ts.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * PURE curation layer for the live `/go` activity feed (spec §3).
3
+ *
4
+ * Mirrors `events.ts`: deterministic functions over canned `JsonEvent`s, with no
5
+ * clock, no I/O, and no model call. This is where "summary, not every event" is
6
+ * enforced — we surface assistant narration and concrete tool actions and drop
7
+ * everything else. Every function is defensive on missing / oddly-shaped data:
8
+ * it never throws, only ever reads basenames (no absolute paths leak into the
9
+ * feed), and falls back to the bare tool name when args are unusable.
10
+ */
11
+ import { NARRATION_MAX } from "./types.js";
12
+ /** ~40 chars of a bash command is enough to recognize it without flooding the row. */
13
+ const BASH_LABEL_MAX = 40;
14
+ /** First usable non-empty string among the candidates, else undefined. */
15
+ function firstString(...vals) {
16
+ for (const v of vals) {
17
+ if (typeof v === "string" && v.trim())
18
+ return v;
19
+ }
20
+ return undefined;
21
+ }
22
+ /** Last path segment only — never an absolute path. Empty string if unusable. */
23
+ function basename(value) {
24
+ if (typeof value !== "string")
25
+ return "";
26
+ const parts = value.split(/[/\\]/).filter(Boolean);
27
+ return parts.length > 0 ? parts[parts.length - 1] : "";
28
+ }
29
+ /** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
30
+ function clip(text, max) {
31
+ const collapsed = text.replace(/\s+/g, " ").trim();
32
+ if (collapsed.length <= max)
33
+ return collapsed;
34
+ return `${collapsed.slice(0, max - 1)}…`;
35
+ }
36
+ /** Turn `record_decision` into a readable `record decision`. */
37
+ function humanize(toolName) {
38
+ return toolName.replace(/_/g, " ");
39
+ }
40
+ /**
41
+ * Deterministic, defensive label for a tool action. Reads only the fields the
42
+ * spec table names, only ever surfaces a basename, and falls back to the bare
43
+ * tool name whenever the expected arg is missing or not a usable string.
44
+ */
45
+ export function toolLabel(toolName, args) {
46
+ const a = args ?? {};
47
+ switch (toolName) {
48
+ case "read": {
49
+ const f = firstString(a.path, a.file_path);
50
+ return f ? `read ${basename(f)}` : toolName;
51
+ }
52
+ case "edit":
53
+ case "write": {
54
+ const f = firstString(a.path, a.file_path);
55
+ return f ? `edit ${basename(f)}` : toolName;
56
+ }
57
+ case "bash": {
58
+ const cmd = firstString(a.command);
59
+ return cmd ? `bash ${clip(cmd, BASH_LABEL_MAX)}` : toolName;
60
+ }
61
+ case "grep": {
62
+ const pattern = firstString(a.pattern);
63
+ return pattern ? `grep "${clip(pattern, BASH_LABEL_MAX)}"` : toolName;
64
+ }
65
+ case "find":
66
+ case "ls": {
67
+ const target = firstString(a.path, a.pattern);
68
+ return target ? `find ${basename(target) || target}` : toolName;
69
+ }
70
+ case "ask_yagni":
71
+ case "review_business_match":
72
+ case "record_decision":
73
+ return humanize(toolName);
74
+ default:
75
+ return toolName;
76
+ }
77
+ }
78
+ /**
79
+ * The first non-empty line of an assistant text part, collapsed and clipped to
80
+ * `NARRATION_MAX`. Returns null when there is no usable headline.
81
+ */
82
+ export function narrationHeadline(text) {
83
+ if (typeof text !== "string")
84
+ return null;
85
+ const firstLine = text.split("\n").find((l) => l.trim().length > 0);
86
+ if (!firstLine)
87
+ return null;
88
+ const headline = clip(firstLine, NARRATION_MAX);
89
+ return headline.length > 0 ? headline : null;
90
+ }
91
+ /** The last text part of an assistant `message_end`, if any. */
92
+ function lastAssistantText(ev) {
93
+ if (ev.message?.role !== "assistant")
94
+ return undefined;
95
+ const parts = ev.message.content ?? [];
96
+ for (let i = parts.length - 1; i >= 0; i--) {
97
+ const part = parts[i];
98
+ if (part.type === "text" && typeof part.text === "string")
99
+ return part.text;
100
+ }
101
+ return undefined;
102
+ }
103
+ /**
104
+ * Curate one raw NDJSON event into an `ActivityLine`, or null to drop it.
105
+ *
106
+ * - assistant `message_end` with a text part -> narration (drops pure tool-call
107
+ * turns and empty headlines)
108
+ * - `tool_execution_start` -> running action carrying its `toolCallId` + label
109
+ * - `tool_execution_end` -> resolving action (done / error) with no text; the
110
+ * feed model resolves it against the running line by `toolCallId`
111
+ * - everything else -> null
112
+ *
113
+ * `tag` travels alongside the line (the feed attributes it to a stage / lens);
114
+ * the curated line content itself is stage-agnostic, so `tag` is not read here.
115
+ */
116
+ export function eventToLine(ev, _tag) {
117
+ switch (ev.type) {
118
+ case "message_end": {
119
+ const text = lastAssistantText(ev);
120
+ if (text === undefined)
121
+ return null;
122
+ const headline = narrationHeadline(text);
123
+ if (!headline)
124
+ return null;
125
+ return { kind: "narration", text: headline, state: "done" };
126
+ }
127
+ case "tool_execution_start": {
128
+ const line = {
129
+ kind: "action",
130
+ text: toolLabel(ev.toolName ?? "", ev.args),
131
+ state: "running",
132
+ };
133
+ if (ev.toolCallId !== undefined)
134
+ line.toolCallId = ev.toolCallId;
135
+ return line;
136
+ }
137
+ case "tool_execution_end": {
138
+ const line = {
139
+ kind: "action",
140
+ text: "",
141
+ state: ev.isError ? "error" : "done",
142
+ };
143
+ if (ev.toolCallId !== undefined)
144
+ line.toolCallId = ev.toolCallId;
145
+ return line;
146
+ }
147
+ default:
148
+ return null;
149
+ }
150
+ }
151
+ //# sourceMappingURL=activity.js.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The TERMINAL renderer for the live `/go` feed (spec §4).
3
+ *
4
+ * State lives in {@link RunState}; this file is presentation only. It renders
5
+ * the boxed panel painted above the editor, owning no I/O and no clock: the
6
+ * caller (the `/go` handler) injects the elapsed string and the spinner frame so
7
+ * `render` stays a pure function of current state and is fully deterministic in
8
+ * tests.
9
+ *
10
+ * Every constraint here is a TUI constraint and stays here rather than moving
11
+ * into the model: `ROW_MAX` (terminal width), `MAX_VISIBLE_ACTIONS` (pi's
12
+ * 10-line widget cap), and the flat ring buffer's `FEED_WINDOW` bound. The
13
+ * desktop renders the same state into a scrolling pane where none of them apply.
14
+ */
15
+ import { type ActivityLine, type PipelineProgress, type PipelineResult, type StageTag } from "./types.js";
16
+ /** The minimal slice of pi's `Theme` the feed styles with (a subset, like the masthead). */
17
+ export interface FeedTheme {
18
+ bold(s: string): string;
19
+ fg(color: string, s: string): string;
20
+ }
21
+ /** Spinner frames advanced by the caller; `render` is handed one frame, never a timer. */
22
+ export declare const SPINNER_FRAMES: string[];
23
+ /**
24
+ * Cap on the action rows actually rendered for the buffer stage. The ring buffer
25
+ * still holds up to FEED_WINDOW entries (state), but pi's aboveEditor widget keeps
26
+ * only the first MAX_WIDGET_LINES (10) lines and appends a "(widget truncated)"
27
+ * marker above that, dropping the NEWEST rows. We paint at most this many of the
28
+ * most-recent rows so the panel is header(1) + 5 stage rows + 4 actions = exactly
29
+ * 10 lines: at pi's ceiling (it truncates only ABOVE 10), and showing the newest
30
+ * actions rather than pi's first-10 (which would keep the oldest and add the marker).
31
+ */
32
+ export declare const MAX_VISIBLE_ACTIONS = 4;
33
+ export declare class ActivityFeed {
34
+ private readonly run;
35
+ /**
36
+ * The stage whose actions the ring buffer currently holds, and the buffer
37
+ * itself. Terminal-only: this flat, arrival-ordered ring is what lets the panel
38
+ * paint interleaved rows from the parallel review lenses under one `review`
39
+ * row, and it is bounded by FEED_WINDOW — the "can't get noisy" guarantee.
40
+ */
41
+ private bufferStageId;
42
+ private actions;
43
+ constructor(ticket: string);
44
+ /** Fold a structured progress signal into stage transitions + the header tally. */
45
+ applyProgress(p: PipelineProgress): void;
46
+ /** Fold one curated line: narration updates a stage summary; actions ride the ring buffer. */
47
+ applyEvent(line: ActivityLine, tag: StageTag): void;
48
+ /** The boxed panel lines (spec §4). Pure: spinner frame + elapsed are injected. */
49
+ render(theme: FeedTheme, elapsed?: string, spinnerFrame?: string): string[];
50
+ /**
51
+ * One compact line summarizing the finished run (spec §4). This is the feed's
52
+ * own terse outcome glyph-line and is intentionally DISTINCT from goCommand's
53
+ * `formatSummary`: that one is the durable `sendUserMessage` record and must
54
+ * also work on headless (no-feed) runs, so the two carry deliberately different
55
+ * phrasings rather than sharing one outcome-copy source.
56
+ */
57
+ finalLine(result: PipelineResult): string;
58
+ }
59
+ //# sourceMappingURL=activityFeed.d.ts.map
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The TERMINAL renderer for the live `/go` feed (spec §4).
3
+ *
4
+ * State lives in {@link RunState}; this file is presentation only. It renders
5
+ * the boxed panel painted above the editor, owning no I/O and no clock: the
6
+ * caller (the `/go` handler) injects the elapsed string and the spinner frame so
7
+ * `render` stays a pure function of current state and is fully deterministic in
8
+ * tests.
9
+ *
10
+ * Every constraint here is a TUI constraint and stays here rather than moving
11
+ * into the model: `ROW_MAX` (terminal width), `MAX_VISIBLE_ACTIONS` (pi's
12
+ * 10-line widget cap), and the flat ring buffer's `FEED_WINDOW` bound. The
13
+ * desktop renders the same state into a scrolling pane where none of them apply.
14
+ */
15
+ import { BRAND_NAME } from "../branding.js";
16
+ import { RunState } from "./runState.js";
17
+ import { FEED_WINDOW, } from "./types.js";
18
+ /** Spinner frames advanced by the caller; `render` is handed one frame, never a timer. */
19
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
20
+ /** Defensive cap so a row never blows the terminal width even with a long summary. */
21
+ const ROW_MAX = 96;
22
+ /**
23
+ * Cap on the action rows actually rendered for the buffer stage. The ring buffer
24
+ * still holds up to FEED_WINDOW entries (state), but pi's aboveEditor widget keeps
25
+ * only the first MAX_WIDGET_LINES (10) lines and appends a "(widget truncated)"
26
+ * marker above that, dropping the NEWEST rows. We paint at most this many of the
27
+ * most-recent rows so the panel is header(1) + 5 stage rows + 4 actions = exactly
28
+ * 10 lines: at pi's ceiling (it truncates only ABOVE 10), and showing the newest
29
+ * actions rather than pi's first-10 (which would keep the oldest and add the marker).
30
+ */
31
+ export const MAX_VISIBLE_ACTIONS = 4;
32
+ /** Compact outcome phrasing per stop reason (no em-dashes). */
33
+ const FINAL_REASON = {
34
+ clean: "clean, no blockers remain",
35
+ round_cap: "round cap reached, blockers remain",
36
+ aborted: "aborted before review completed",
37
+ failed: "a stage failed before completing",
38
+ no_changes: "no changes produced, the implement stage wrote nothing. Re-run /go",
39
+ budget_exceeded: "budget ceiling reached, stopped before finishing",
40
+ partial: "a stage stopped early, partial changes left in your working tree",
41
+ };
42
+ function glyphForStatus(status, spinnerFrame) {
43
+ switch (status) {
44
+ case "done":
45
+ return "✔";
46
+ case "active":
47
+ return spinnerFrame;
48
+ case "failed":
49
+ return "✗";
50
+ default:
51
+ return "○";
52
+ }
53
+ }
54
+ function glyphForAction(state, spinnerFrame) {
55
+ if (state === "done")
56
+ return "✔";
57
+ if (state === "error")
58
+ return "✗";
59
+ return spinnerFrame;
60
+ }
61
+ const themeColorForStatus = {
62
+ done: "success",
63
+ active: "accent",
64
+ failed: "error",
65
+ pending: "dim",
66
+ };
67
+ function plural(n, word) {
68
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
69
+ }
70
+ function clipRow(text, max) {
71
+ if (text.length <= max)
72
+ return text;
73
+ return `${text.slice(0, max - 1)}…`;
74
+ }
75
+ function countBlocking(result) {
76
+ return result.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
77
+ }
78
+ export class ActivityFeed {
79
+ run;
80
+ /**
81
+ * The stage whose actions the ring buffer currently holds, and the buffer
82
+ * itself. Terminal-only: this flat, arrival-ordered ring is what lets the panel
83
+ * paint interleaved rows from the parallel review lenses under one `review`
84
+ * row, and it is bounded by FEED_WINDOW — the "can't get noisy" guarantee.
85
+ */
86
+ bufferStageId;
87
+ actions = [];
88
+ constructor(ticket) {
89
+ // The terminal surface has no run id and no clock: `render` is handed the
90
+ // elapsed string and spinner frame, so timestamps are never read here.
91
+ this.run = new RunState({ runId: "", ticket, startedAt: 0 });
92
+ }
93
+ /** Fold a structured progress signal into stage transitions + the header tally. */
94
+ applyProgress(p) {
95
+ // A per-lens signal is a desktop concern: it must not steal the buffer from
96
+ // the review stage that owns it, nor reset it mid fan-out.
97
+ if (p.kind === "stage_start" && !p.lens && p.stageId !== this.bufferStageId) {
98
+ this.bufferStageId = p.stageId;
99
+ this.actions = [];
100
+ }
101
+ this.run.applyProgress(p);
102
+ }
103
+ /** Fold one curated line: narration updates a stage summary; actions ride the ring buffer. */
104
+ applyEvent(line, tag) {
105
+ this.run.applyLine(line, tag);
106
+ if (line.kind !== "action")
107
+ return;
108
+ if (line.state === "running") {
109
+ if (this.bufferStageId === undefined)
110
+ this.bufferStageId = tag.stageId;
111
+ this.actions.push({ ...line });
112
+ if (this.actions.length > FEED_WINDOW)
113
+ this.actions.shift();
114
+ return;
115
+ }
116
+ if (line.toolCallId === undefined)
117
+ return;
118
+ const match = this.actions.find((a) => a.toolCallId === line.toolCallId);
119
+ if (match)
120
+ match.state = line.state;
121
+ }
122
+ /** The boxed panel lines (spec §4). Pure: spinner frame + elapsed are injected. */
123
+ render(theme, elapsed, spinnerFrame = SPINNER_FRAMES[0]) {
124
+ const { ticket, round, findingsTotal, findingsBlocking } = this.run.header();
125
+ const headerParts = [`${BRAND_NAME} · /go ${ticket}`, `round ${round}`, plural(findingsTotal, "finding")];
126
+ if (findingsBlocking > 0)
127
+ headerParts.push(`${findingsBlocking} blocking`);
128
+ if (elapsed)
129
+ headerParts.push(elapsed);
130
+ const header = theme.bold(clipRow(headerParts.join(" · "), ROW_MAX));
131
+ const lines = [header];
132
+ // Only the lens-less stage rows: the review fan-out's per-lens children are
133
+ // the desktop's business, and surfacing them here would break the 10-line cap.
134
+ for (const s of this.run.stageAgents()) {
135
+ // The FINISH row only exists once the stage actually starts (a clean stop):
136
+ // hiding it while pending keeps every non-clean run's panel byte-identical
137
+ // to the pre-FINISH layout and inside pi's 10-line widget cap.
138
+ if (s.id === "finish" && s.status === "pending")
139
+ continue;
140
+ const glyph = glyphForStatus(s.status, spinnerFrame);
141
+ const label = s.stageId.padEnd(9);
142
+ const row = clipRow(`${glyph} ${label}${s.summary}`.trimEnd(), ROW_MAX);
143
+ lines.push(theme.fg(themeColorForStatus[s.status], row));
144
+ // The ring buffer renders under its OWNING stage (see RunState), so a
145
+ // just-finished stage's resolved actions stay briefly visible beneath its ✔
146
+ // row. This keeps the immediate stage_done paint useful rather than blanking
147
+ // the actions the instant the stage flips to "done".
148
+ if (s.stageId === this.bufferStageId) {
149
+ // Only the most-recent rows render. A taller panel trips pi's
150
+ // MAX_WIDGET_LINES=10 truncation, which keeps the FIRST 10 lines and
151
+ // drops the newest actions plus a "(widget truncated)" marker; capping
152
+ // here keeps the newest actions visible and avoids the marker.
153
+ for (const a of this.actions.slice(-MAX_VISIBLE_ACTIONS)) {
154
+ const aGlyph = glyphForAction(a.state, spinnerFrame);
155
+ lines.push(theme.fg("dim", clipRow(` ↳ ${aGlyph} ${a.text}`, ROW_MAX)));
156
+ }
157
+ }
158
+ }
159
+ return lines;
160
+ }
161
+ /**
162
+ * One compact line summarizing the finished run (spec §4). This is the feed's
163
+ * own terse outcome glyph-line and is intentionally DISTINCT from goCommand's
164
+ * `formatSummary`: that one is the durable `sendUserMessage` record and must
165
+ * also work on headless (no-feed) runs, so the two carry deliberately different
166
+ * phrasings rather than sharing one outcome-copy source.
167
+ */
168
+ finalLine(result) {
169
+ const glyph = result.stopReason === "clean" ? "✔" : result.stopReason === "round_cap" ? "⚠" : "✗";
170
+ const blocking = countBlocking(result);
171
+ return (`${glyph} ${BRAND_NAME} /go ${this.run.header().ticket}: ${FINAL_REASON[result.stopReason]} · ` +
172
+ `${plural(result.rounds.length, "round")} · ${plural(result.findings.length, "finding")} (${blocking} blocking)`);
173
+ }
174
+ }
175
+ //# sourceMappingURL=activityFeed.js.map