@sayknow-cli/coding-agent 0.3.1 → 0.3.4
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/bin/skc.js +4 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/config/model-profiles.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/models-config-schema.d.ts +0 -5
- package/dist/types/config/settings-schema.d.ts +79 -68
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/skc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/skc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/skc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/skc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/skc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/skc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/skc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/skc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/skc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/skc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/skc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/skc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/skc-plugins/validation.d.ts +8 -1
- package/dist/types/i18n/messages/en.d.ts +0 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +8 -0
- package/dist/types/modes/components/settings-defs.d.ts +0 -1
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +60 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +114 -16
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +12 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/skc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/skc-runtime/state-renderer.d.ts +5 -0
- package/dist/types/skc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/skc-runtime/tmux-common.d.ts +30 -2
- package/dist/types/skc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/skc-runtime/ultragoal-guard.d.ts +37 -1
- package/dist/types/skc-runtime/ultragoal-runtime.d.ts +80 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +31 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/computer-gc.d.ts +23 -0
- package/dist/types/tools/cron.d.ts +36 -61
- package/dist/types/tools/index.d.ts +3 -2
- package/dist/types/tools/resource-gc.d.ts +54 -0
- package/dist/types/utils/changelog.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +6 -3
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/dist/types/web/search/index.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +11 -4
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +180 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/args.ts +0 -1
- package/src/cli/fast-help.ts +29 -2
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli/web-search-cli.ts +5 -0
- package/src/cli.ts +30 -12
- package/src/commands/mcp.ts +117 -0
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-profile-activation.ts +62 -8
- package/src/config/model-profiles.ts +3 -4
- package/src/config/model-registry.ts +3 -6
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +88 -103
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/skc/skills/deep-interview/SKILL.md +7 -6
- package/src/defaults/skc/skills/team/SKILL.md +5 -3
- package/src/defaults/skc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -12
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/skc-plugins/compiler.ts +351 -0
- package/src/extensibility/skc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/skc-plugins/index.ts +9 -0
- package/src/extensibility/skc-plugins/injection.ts +109 -0
- package/src/extensibility/skc-plugins/installer.ts +434 -0
- package/src/extensibility/skc-plugins/loader.ts +3 -1
- package/src/extensibility/skc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/skc-plugins/observability.ts +84 -0
- package/src/extensibility/skc-plugins/paths.ts +1 -1
- package/src/extensibility/skc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/skc-plugins/registry.ts +180 -0
- package/src/extensibility/skc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/skc-plugins/schema.ts +250 -20
- package/src/extensibility/skc-plugins/session-validation.ts +147 -0
- package/src/extensibility/skc-plugins/types.ts +199 -3
- package/src/extensibility/skc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/goals/tools/goal-tool.ts +14 -1
- package/src/hooks/skill-state.ts +57 -0
- package/src/i18n/messages/de.ts +0 -1
- package/src/i18n/messages/en.ts +0 -1
- package/src/i18n/messages/es.ts +0 -1
- package/src/i18n/messages/fr.ts +0 -1
- package/src/i18n/messages/ja.ts +0 -1
- package/src/i18n/messages/ko.ts +0 -1
- package/src/i18n/messages/zh.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +14 -11
- package/src/main.ts +14 -3
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/assistant-message.ts +49 -1
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +65 -12
- package/src/modes/components/settings-defs.ts +1 -2
- package/src/modes/components/settings-selector.ts +2 -7
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +59 -11
- package/src/modes/interactive-mode.ts +4 -1
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/theme.ts +0 -4
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +238 -0
- package/src/notifications/lifecycle-control-runtime.ts +405 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +778 -257
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +23 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/prompts/system/system-prompt.md +2 -2
- package/src/prompts/tools/cron.md +5 -3
- package/src/prompts/tools/read.md +1 -1
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +162 -10
- package/src/session/agent-session.ts +210 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +762 -12
- package/src/skc-runtime/launch-tmux.ts +68 -19
- package/src/skc-runtime/psmux-detect.ts +239 -0
- package/src/skc-runtime/state-renderer.ts +13 -0
- package/src/skc-runtime/team-runtime.ts +56 -23
- package/src/skc-runtime/tmux-common.ts +88 -4
- package/src/skc-runtime/tmux-sessions.ts +111 -9
- package/src/skc-runtime/ultragoal-guard.ts +192 -8
- package/src/skc-runtime/ultragoal-runtime.ts +296 -16
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/browser/tab-supervisor.ts +86 -2
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/computer-gc.ts +66 -0
- package/src/tools/computer.ts +2 -0
- package/src/tools/cron.ts +75 -112
- package/src/tools/fetch.ts +18 -2
- package/src/tools/index.ts +5 -9
- package/src/tools/read.ts +25 -55
- package/src/tools/renderers.ts +0 -2
- package/src/tools/resource-gc.ts +291 -0
- package/src/tools/ultragoal-ask-guard.ts +7 -1
- package/src/utils/changelog.ts +8 -0
- package/src/web/insane/url-guard.ts +18 -14
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
- package/src/web/search/index.ts +1 -0
- package/src/web/search/providers/utils.ts +22 -5
- package/vendor/insane-search/MANIFEST.json +3 -1
- package/vendor/insane-search/engine/__init__.py +14 -0
- package/vendor/insane-search/engine/content_safety.py +151 -0
- package/vendor/insane-search/engine/fetch_chain.py +32 -0
- package/vendor/insane-search/engine/tests/test_u8.py +216 -0
- package/dist/types/tools/inspect-image-renderer.d.ts +0 -26
- package/dist/types/tools/inspect-image.d.ts +0 -31
- package/src/prompts/tools/inspect-image-system.md +0 -20
- package/src/prompts/tools/inspect-image.md +0 -32
- package/src/tools/inspect-image-renderer.ts +0 -103
- package/src/tools/inspect-image.ts +0 -172
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { ResolvedTmuxBinary } from "./psmux-detect";
|
|
2
|
+
import { resolveSkcTmuxBinary } from "./psmux-detect";
|
|
3
|
+
|
|
1
4
|
export const SKC_DEFAULT_TMUX_SESSION = "sayknow_cli";
|
|
2
5
|
export const SKC_TMUX_SESSION_PREFIX = `${SKC_DEFAULT_TMUX_SESSION}_`;
|
|
3
6
|
export const SKC_TMUX_COMMAND_ENV = "SKC_TMUX_COMMAND";
|
|
@@ -11,6 +14,7 @@ export const SKC_TMUX_PROJECT_OPTION = "@skc-project";
|
|
|
11
14
|
export const SKC_TMUX_SESSION_ID_OPTION = "@skc-session-id";
|
|
12
15
|
export const SKC_TMUX_SESSION_STATE_FILE_OPTION = "@skc-session-state-file";
|
|
13
16
|
export const SKC_TMUX_VERSION_OPTION = "@skc-version";
|
|
17
|
+
export const SKC_PSMUX_PROFILE_FORCE_ENV = "SKC_PSMUX_PROFILE_FORCE";
|
|
14
18
|
|
|
15
19
|
export interface SkcTmuxProfileCommand {
|
|
16
20
|
description: string;
|
|
@@ -31,10 +35,33 @@ export function envDisabled(value: string | undefined): boolean {
|
|
|
31
35
|
return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the tmux (or tmux-compatible multiplexer) command SKC should invoke.
|
|
40
|
+
*
|
|
41
|
+
* This is the shared entry point used by every SKC code path that needs to talk
|
|
42
|
+
* to a multiplexer: `skc --tmux` planning, `skc session ...`, `skc team ...`,
|
|
43
|
+
* the lifecycle controller, and the harness resident owner. Routing all of
|
|
44
|
+
* them through the same resolver means a single `SKC_TMUX_COMMAND` override or
|
|
45
|
+
* a single Windows psmux / pmux detection wins for the whole process — the
|
|
46
|
+
* failure mode where `skc --tmux` creates a psmux-backed session and then
|
|
47
|
+
* `skc session status` fails because it queries literal `tmux` is closed off.
|
|
48
|
+
*
|
|
49
|
+
* Explicit `SKC_TMUX_COMMAND` / `SKC_TEAM_TMUX_COMMAND` overrides are honored on
|
|
50
|
+
* every platform. On native Windows without an override the resolver walks
|
|
51
|
+
* `psmux`, then `pmux`, then `tmux` and uses the first binary present on PATH.
|
|
52
|
+
* On POSIX the resolver returns `tmux` (the historical default) and only
|
|
53
|
+
* falls through to the platform-aware walker if the caller opts in.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveSkcTmuxCommand(
|
|
56
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
57
|
+
platform: NodeJS.Platform = process.platform,
|
|
58
|
+
): string {
|
|
59
|
+
return resolveSkcTmuxBinary({ env, platform }).command;
|
|
36
60
|
}
|
|
37
61
|
|
|
62
|
+
export type { PsmuxProbe, ResolvedTmuxBinary, ResolveSkcTmuxBinaryOptions } from "./psmux-detect";
|
|
63
|
+
export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveSkcTmuxBinary } from "./psmux-detect";
|
|
64
|
+
|
|
38
65
|
/**
|
|
39
66
|
* Build the exact-session target for tmux *option* commands
|
|
40
67
|
* (`show-options` / `set-option`) and `display-message -t`.
|
|
@@ -45,7 +72,17 @@ export function resolveSkcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
|
|
|
45
72
|
* keeps the exact-session match while giving tmux the window-qualified target
|
|
46
73
|
* those commands require. See sayknow-cli#580.
|
|
47
74
|
*/
|
|
48
|
-
export function buildSkcTmuxExactOptionTarget(
|
|
75
|
+
export function buildSkcTmuxExactOptionTarget(
|
|
76
|
+
sessionName: string,
|
|
77
|
+
opts: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; binary?: ResolvedTmuxBinary } = {},
|
|
78
|
+
): string {
|
|
79
|
+
const binary = opts.binary ?? resolveSkcTmuxBinary({ env: opts.env, platform: opts.platform });
|
|
80
|
+
// psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
|
|
81
|
+
// commands ("no server running on session '=NAME'"); bare `NAME` and
|
|
82
|
+
// window-qualified `NAME:` both work. tmux 3.6a needs the
|
|
83
|
+
// window-qualified `=NAME:` to resolve the session for option
|
|
84
|
+
// commands (sayknow-cli#580).
|
|
85
|
+
if (binary.isPsmux) return sessionName;
|
|
49
86
|
return `=${sessionName}:`;
|
|
50
87
|
}
|
|
51
88
|
|
|
@@ -55,7 +92,9 @@ export function buildSkcTmuxUntaggedSessionHint(tmuxCommand: string): string {
|
|
|
55
92
|
return (
|
|
56
93
|
`the active multiplexer "${tmuxCommand}" lists this session but did not return SKC's ${SKC_TMUX_PROFILE_OPTION} ownership tag; ` +
|
|
57
94
|
"SKC-managed sessions and `skc team` require a tmux provider that round-trips tmux user options. " +
|
|
58
|
-
"
|
|
95
|
+
"For psmux on Windows, cwd/start-directory flags such as `-c` do not isolate the server namespace; psmux uses the tmux-compatible global `-L <namespace>` flag for that. " +
|
|
96
|
+
"SKC_TMUX_COMMAND and SKC_TEAM_TMUX_COMMAND are binary overrides, not shell command lines, so `psmux -L name` is not a supported value. " +
|
|
97
|
+
"Alternative multiplexers such as psmux on Windows do not reliably persist user options yet, so the Windows-native psmux path is not fully supported; " +
|
|
59
98
|
"use real tmux for SKC-managed session and team flows."
|
|
60
99
|
);
|
|
61
100
|
}
|
|
@@ -144,6 +183,16 @@ export function buildSkcTmuxRequiredProfileCommands(
|
|
|
144
183
|
return commands;
|
|
145
184
|
}
|
|
146
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Keys whose set-option / set-window-option round-trip is unreliable on psmux
|
|
188
|
+
* 3.3.0. psmux does not support the tmux `set-window-option` command at all
|
|
189
|
+
* (it reports "unknown command: set-window-option") and silently drops several
|
|
190
|
+
* `set-option` keys. The list lives here so every code path that tags a tmux
|
|
191
|
+
* session (skc --tmux planning, skc session create, skc team bootstrap)
|
|
192
|
+
* applies the same filter.
|
|
193
|
+
*/
|
|
194
|
+
const PSMUX_UNSUPPORTED_PROFILE_KEYS = new Set(["mouse", "set-clipboard", "mode-style"]);
|
|
195
|
+
|
|
147
196
|
export function buildSkcTmuxProfileCommands(
|
|
148
197
|
target: string,
|
|
149
198
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -155,6 +204,7 @@ export function buildSkcTmuxProfileCommands(
|
|
|
155
204
|
sessionStateFile?: string | null;
|
|
156
205
|
version?: string | null;
|
|
157
206
|
} = {},
|
|
207
|
+
opts: { platform?: NodeJS.Platform; tmuxCommand?: string } = {},
|
|
158
208
|
): SkcTmuxProfileCommand[] {
|
|
159
209
|
const commands = buildSkcTmuxRequiredProfileCommands(target, metadata);
|
|
160
210
|
if (envDisabled(env[SKC_TMUX_PROFILE_ENV])) return commands;
|
|
@@ -170,6 +220,40 @@ export function buildSkcTmuxProfileCommands(
|
|
|
170
220
|
description: "enable tmux mouse scrolling",
|
|
171
221
|
args: ["set-option", "-t", target, "mouse", "on"],
|
|
172
222
|
});
|
|
223
|
+
// psmux does not implement set-window-option and historically drops
|
|
224
|
+
// mouse / set-clipboard / mode-style. Filter the UX profile commands
|
|
225
|
+
// centrally so every code path that tags a session (skc --tmux planning,
|
|
226
|
+
// skc session create, skc team bootstrap) drops the same set. The
|
|
227
|
+
// SKC_PSMUX_PROFILE_FORCE override lets the operator opt back in when
|
|
228
|
+
// running on a psmux build that has caught up. The ownership-tag
|
|
229
|
+
// round-trip (set-option @skc-*) is never filtered, since skc session /
|
|
230
|
+
// skc team rely on it.
|
|
231
|
+
// The filter is opt-in: callers that explicitly pass `opts.tmuxCommand`
|
|
232
|
+
// name a psmux-class multiplexer (psmux / pmux) when they want the UX
|
|
233
|
+
// profile filtered. Auto-detect on Windows hosts where psmux happens
|
|
234
|
+
// to be on PATH would silently change the test output for every caller
|
|
235
|
+
// that does not pin the multiplexer, so we require the caller to opt
|
|
236
|
+
// in by naming the multiplexer. SKC_PSMUX_PROFILE_FORCE re-enables
|
|
237
|
+
// the UX profile commands when a psmux build catches up.
|
|
238
|
+
const tmuxName = (opts.tmuxCommand ?? "").toLowerCase();
|
|
239
|
+
const isPsmuxClass =
|
|
240
|
+
tmuxName === "psmux" ||
|
|
241
|
+
tmuxName === "pmux" ||
|
|
242
|
+
tmuxName.endsWith("/psmux") ||
|
|
243
|
+
tmuxName.endsWith("/pmux") ||
|
|
244
|
+
tmuxName.endsWith("\\psmux") ||
|
|
245
|
+
tmuxName.endsWith("\\pmux");
|
|
246
|
+
const dropUx = isPsmuxClass && !envDisabled(env[SKC_PSMUX_PROFILE_FORCE_ENV]);
|
|
247
|
+
if (dropUx) {
|
|
248
|
+
return commands.filter(command => {
|
|
249
|
+
const flag = command.args[0];
|
|
250
|
+
const key = command.args[command.args.length - 2];
|
|
251
|
+
return !(
|
|
252
|
+
PSMUX_UNSUPPORTED_PROFILE_KEYS.has(String(key)) &&
|
|
253
|
+
(flag === "set-option" || flag === "set-window-option")
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
173
257
|
return commands;
|
|
174
258
|
}
|
|
175
259
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveSkcTmuxBinary } from "./psmux-detect";
|
|
1
2
|
import {
|
|
2
3
|
buildSkcTmuxExactOptionTarget,
|
|
3
4
|
buildSkcTmuxProfileCommands,
|
|
@@ -128,10 +129,29 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
|
|
|
128
129
|
}
|
|
129
130
|
throw error;
|
|
130
131
|
}
|
|
131
|
-
|
|
132
|
+
const lines = output
|
|
132
133
|
.split("\n")
|
|
133
134
|
.map(line => line.trim())
|
|
134
135
|
.filter(Boolean);
|
|
136
|
+
// psmux 3.3.0 silently ignores the tmux `-F` format flag and returns its
|
|
137
|
+
// default `name: N windows (created ...)` shape. Detect that case and
|
|
138
|
+
// synthesize a tab-separated row so downstream parseSessionLine /
|
|
139
|
+
// hydrateSessionFromExactOptions can recover the @skc-* ownership tags
|
|
140
|
+
// via follow-up show-options calls. Without this fallback skc session
|
|
141
|
+
// list / status return an empty list on psmux even when sessions exist.
|
|
142
|
+
if (lines.length > 0 && !lines[0].includes("\t")) {
|
|
143
|
+
const binary = resolveSkcTmuxBinary({ env });
|
|
144
|
+
if (binary.isPsmux) {
|
|
145
|
+
return lines.map(line => {
|
|
146
|
+
const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
|
|
147
|
+
if (!match) return line;
|
|
148
|
+
const [, name, windows, created] = match;
|
|
149
|
+
const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
|
|
150
|
+
return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", ""].join("\t");
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
135
155
|
}
|
|
136
156
|
|
|
137
157
|
function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
@@ -221,7 +241,13 @@ export function statusSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
|
|
|
221
241
|
export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcTmuxSessionStatus {
|
|
222
242
|
const tmuxCommand = resolveSkcTmuxCommand(env);
|
|
223
243
|
const sessionName = buildSkcTmuxSessionName(env);
|
|
224
|
-
|
|
244
|
+
// Build a shell-bootstrap command appropriate for the host shell. Psmux on
|
|
245
|
+
// Windows runs the new-session command through PowerShell, so we use the
|
|
246
|
+
// $env:VAR = ... assignment form there. POSIX keeps the historical exec
|
|
247
|
+
// env form so the launched skc inherits SKC_TMUX_LAUNCHED without leaking
|
|
248
|
+
// into the parent tmux server.
|
|
249
|
+
const platform = process.platform;
|
|
250
|
+
const command = platform === "win32" ? "$env:SKC_TMUX_LAUNCHED = '1'; skc" : "exec env SKC_TMUX_LAUNCHED=1 skc";
|
|
225
251
|
const created = Bun.spawnSync([tmuxCommand, "new-session", "-d", "-s", sessionName, command], {
|
|
226
252
|
stdout: "pipe",
|
|
227
253
|
stderr: "pipe",
|
|
@@ -229,7 +255,14 @@ export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcT
|
|
|
229
255
|
});
|
|
230
256
|
if (created.exitCode !== 0) throw new Error(created.stderr.toString().trim() || "skc_tmux_session_create_failed");
|
|
231
257
|
try {
|
|
232
|
-
|
|
258
|
+
// psmux 3.3.0 rejects the tmux `=NAME` exact-session prefix for option
|
|
259
|
+
// commands, so the target is the bare session name on psmux and the
|
|
260
|
+
// window-qualified `=NAME:` on tmux. The ownership-tag round-trip
|
|
261
|
+
// (set-option @skc-*) is preserved on both; only the UX profile commands
|
|
262
|
+
// (mouse / set-clipboard / mode-style / set-window-option) get filtered
|
|
263
|
+
// by buildSkcTmuxProfileCommands when the active binary is psmux.
|
|
264
|
+
const target = buildSkcTmuxExactOptionTarget(sessionName, { env });
|
|
265
|
+
for (const profileCommand of buildSkcTmuxProfileCommands(target, env, {}, { tmuxCommand })) {
|
|
233
266
|
runTmux(profileCommand.args, env);
|
|
234
267
|
}
|
|
235
268
|
} catch (error) {
|
|
@@ -240,18 +273,43 @@ export function createSkcTmuxSession(env: NodeJS.ProcessEnv = process.env): SkcT
|
|
|
240
273
|
}
|
|
241
274
|
|
|
242
275
|
function readProfileForExactTarget(sessionName: string, env: NodeJS.ProcessEnv): string {
|
|
243
|
-
|
|
244
|
-
["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName), SKC_TMUX_PROFILE_OPTION],
|
|
276
|
+
const raw = runTmux(
|
|
277
|
+
["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName, { env }), SKC_TMUX_PROFILE_OPTION],
|
|
245
278
|
env,
|
|
246
279
|
).trim();
|
|
280
|
+
// tmux returns just the value; psmux returns `key value`. Strip the
|
|
281
|
+
// leading key on psmux so the SKC_TMUX_PROFILE_VALUE equality check
|
|
282
|
+
// against "1" works the same on both.
|
|
283
|
+
if (raw && resolveSkcTmuxBinary({ env }).isPsmux) {
|
|
284
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
285
|
+
return tokens[tokens.length - 1] ?? raw;
|
|
286
|
+
}
|
|
287
|
+
return raw;
|
|
247
288
|
}
|
|
248
289
|
|
|
249
290
|
function readExactOptionForGc(sessionName: string, option: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
250
291
|
try {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
292
|
+
const raw = runTmux(
|
|
293
|
+
["show-options", "-qv", "-t", buildSkcTmuxExactOptionTarget(sessionName, { env }), option],
|
|
294
|
+
env,
|
|
295
|
+
).trim();
|
|
296
|
+
if (!raw) return undefined;
|
|
297
|
+
// tmux returns just the option value (e.g. `1` for @skc-profile).
|
|
298
|
+
// psmux 3.3.0 returns `key value` (or `key "value with space"` for
|
|
299
|
+
// @skc-branch etc.). On psmux, parse the last token and strip any
|
|
300
|
+
// surrounding double quotes so both shapes resolve to the same value.
|
|
301
|
+
if (resolveSkcTmuxBinary({ env }).isPsmux) {
|
|
302
|
+
// Prefer the last whitespace-separated token. If the value is
|
|
303
|
+
// quoted, find the matching close-quote and slice.
|
|
304
|
+
const lastQuote = raw.lastIndexOf('"');
|
|
305
|
+
if (lastQuote > 0 && raw[lastQuote - 1] !== "\\") {
|
|
306
|
+
const firstQuote = raw.lastIndexOf('"', lastQuote - 1);
|
|
307
|
+
if (firstQuote > 0) return raw.slice(firstQuote + 1, lastQuote);
|
|
308
|
+
}
|
|
309
|
+
const tokens = raw.split(/\s+/).filter(Boolean);
|
|
310
|
+
return tokens[tokens.length - 1];
|
|
311
|
+
}
|
|
312
|
+
return raw;
|
|
255
313
|
} catch {
|
|
256
314
|
return undefined;
|
|
257
315
|
}
|
|
@@ -306,6 +364,50 @@ export function removeSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
|
|
|
306
364
|
return session;
|
|
307
365
|
}
|
|
308
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Force-close a SKC-managed tmux session, even if a live pane is attached.
|
|
369
|
+
*
|
|
370
|
+
* This is the lifecycle-control counterpart to {@link removeSkcTmuxSession}: it
|
|
371
|
+
* intentionally does NOT refuse live/attached panes (hard-kill is the contract),
|
|
372
|
+
* but it keeps every safety check so it can only ever kill a genuinely
|
|
373
|
+
* SKC-managed session:
|
|
374
|
+
* - re-reads the exact tmux profile immediately before kill (never a non-SKC
|
|
375
|
+
* session, even one that collides by name);
|
|
376
|
+
* - when `expectedSessionId` is given, requires the `@skc-session-id` tag match;
|
|
377
|
+
* - when `expectedStateFile` is given, requires the `@skc-session-state-file`
|
|
378
|
+
* tag match.
|
|
379
|
+
*
|
|
380
|
+
* Returns the prior status (for audit). Throws a tagged error otherwise:
|
|
381
|
+
* `skc_tmux_session_not_found`, `skc_tmux_session_not_managed`,
|
|
382
|
+
* `skc_tmux_session_id_mismatch`, or `skc_tmux_session_state_file_mismatch`.
|
|
383
|
+
*/
|
|
384
|
+
export function forceCloseSkcTmuxSession(
|
|
385
|
+
sessionName: string,
|
|
386
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
387
|
+
expectedSessionId?: string,
|
|
388
|
+
expectedStateFile?: string,
|
|
389
|
+
): SkcTmuxSessionStatus {
|
|
390
|
+
const session = statusSkcTmuxSession(sessionName, env);
|
|
391
|
+
if (readProfileForExactTarget(session.name, env) !== SKC_TMUX_PROFILE_VALUE) {
|
|
392
|
+
throw new Error(`skc_tmux_session_not_managed:${sessionName}`);
|
|
393
|
+
}
|
|
394
|
+
if (expectedSessionId !== undefined) {
|
|
395
|
+
const actual = readExactOptionForGc(session.name, SKC_TMUX_SESSION_ID_OPTION, env);
|
|
396
|
+
if (actual !== expectedSessionId) {
|
|
397
|
+
throw new Error(`skc_tmux_session_id_mismatch:${sessionName}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (expectedStateFile !== undefined) {
|
|
401
|
+
const actual = readExactOptionForGc(session.name, SKC_TMUX_SESSION_STATE_FILE_OPTION, env);
|
|
402
|
+
if (actual !== expectedStateFile) {
|
|
403
|
+
throw new Error(`skc_tmux_session_state_file_mismatch:${sessionName}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Intentionally NOT refusing live/attached panes — force-close is hard-kill.
|
|
407
|
+
runTmux(["kill-session", "-t", `=${session.name}`], env);
|
|
408
|
+
return session;
|
|
409
|
+
}
|
|
410
|
+
|
|
309
411
|
export function attachSkcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
|
|
310
412
|
const session = statusSkcTmuxSession(sessionName, env);
|
|
311
413
|
const tmuxCommand = resolveSkcTmuxCommand(env);
|
|
@@ -8,9 +8,13 @@ import {
|
|
|
8
8
|
hashStructuredValue,
|
|
9
9
|
readUltragoalLedger,
|
|
10
10
|
readUltragoalPlan,
|
|
11
|
+
recordUltragoalNudgeIfBudgetRemaining,
|
|
12
|
+
resolveUltragoalNudgeBudget,
|
|
13
|
+
selectUltragoalNudgeTarget,
|
|
11
14
|
type UltragoalCompletionVerification,
|
|
12
15
|
type UltragoalGoal,
|
|
13
16
|
type UltragoalLedgerEvent,
|
|
17
|
+
type UltragoalNudgeSurface,
|
|
14
18
|
type UltragoalPaths,
|
|
15
19
|
type UltragoalPlan,
|
|
16
20
|
type UltragoalReceiptKind,
|
|
@@ -65,15 +69,17 @@ function isKnownUltragoalObjective(currentObjective: string): boolean {
|
|
|
65
69
|
);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
|
|
72
|
+
async function ultragoalReadPaths(cwd: string): Promise<{ paths: UltragoalPaths; sessionId: string | null }> {
|
|
69
73
|
const envSessionId = process.env.SKC_SESSION_ID?.trim();
|
|
70
|
-
if (envSessionId) return getUltragoalPaths(cwd, envSessionId);
|
|
74
|
+
if (envSessionId) return { paths: getUltragoalPaths(cwd, envSessionId), sessionId: envSessionId };
|
|
71
75
|
try {
|
|
72
76
|
const session = await resolveSkcSessionForRead(cwd, { envSessionId: process.env.SKC_SESSION_ID });
|
|
73
|
-
return getUltragoalPaths(cwd, session.skcSessionId);
|
|
77
|
+
return { paths: getUltragoalPaths(cwd, session.skcSessionId), sessionId: session.skcSessionId };
|
|
74
78
|
} catch (error) {
|
|
75
79
|
if (error instanceof SessionResolutionError && error.code === "no_session") {
|
|
76
|
-
|
|
80
|
+
// No session could be resolved (no env, no auto-detectable active session).
|
|
81
|
+
// Surface the null session id so callers can decide; ask-guard treats it as inactive.
|
|
82
|
+
return { paths: getUltragoalPaths(cwd, null), sessionId: null };
|
|
77
83
|
}
|
|
78
84
|
throw error;
|
|
79
85
|
}
|
|
@@ -82,7 +88,7 @@ async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
|
|
|
82
88
|
async function hasDurableUltragoalState(cwd: string): Promise<boolean> {
|
|
83
89
|
let paths: UltragoalPaths;
|
|
84
90
|
try {
|
|
85
|
-
paths = await ultragoalReadPaths(cwd);
|
|
91
|
+
({ paths } = await ultragoalReadPaths(cwd));
|
|
86
92
|
} catch (error) {
|
|
87
93
|
if (error instanceof SessionResolutionError) return true;
|
|
88
94
|
throw error;
|
|
@@ -368,14 +374,26 @@ export async function readUltragoalVerificationState(input: {
|
|
|
368
374
|
|
|
369
375
|
export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBlockDiagnostic> {
|
|
370
376
|
let paths: UltragoalPaths;
|
|
377
|
+
let sessionId: string | null;
|
|
371
378
|
try {
|
|
372
|
-
paths = await ultragoalReadPaths(cwd);
|
|
379
|
+
({ paths, sessionId } = await ultragoalReadPaths(cwd));
|
|
373
380
|
} catch (error) {
|
|
374
381
|
return activeAskDiagnostic({
|
|
375
382
|
reason: `Unable to resolve durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
|
|
376
383
|
source: "durable_state_unreadable",
|
|
377
384
|
});
|
|
378
385
|
}
|
|
386
|
+
// Ultragoal state is session-scoped. When no session can be resolved (no env,
|
|
387
|
+
// no auto-detectable active session) there is no active run to protect, so the
|
|
388
|
+
// ask guard must fall open rather than block on legacy/global durable state.
|
|
389
|
+
if (sessionId === null) {
|
|
390
|
+
return inactiveAskDiagnostic({
|
|
391
|
+
reason: "No active SKC session resolved; ultragoal is inactive.",
|
|
392
|
+
source: "absent",
|
|
393
|
+
goalsPath: paths.goalsPath,
|
|
394
|
+
ledgerPath: paths.ledgerPath,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
379
397
|
try {
|
|
380
398
|
await fs.stat(paths.dir);
|
|
381
399
|
} catch (error) {
|
|
@@ -398,8 +416,8 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
|
|
|
398
416
|
let plan: UltragoalPlan | null;
|
|
399
417
|
let ledger: UltragoalLedgerEvent[];
|
|
400
418
|
try {
|
|
401
|
-
plan = await readUltragoalPlan(cwd);
|
|
402
|
-
ledger = await readUltragoalLedger(cwd);
|
|
419
|
+
plan = await readUltragoalPlan(cwd, sessionId);
|
|
420
|
+
ledger = await readUltragoalLedger(cwd, sessionId);
|
|
403
421
|
} catch (error) {
|
|
404
422
|
return activeAskDiagnostic({
|
|
405
423
|
reason: `Unable to read durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -409,6 +427,9 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
|
|
|
409
427
|
});
|
|
410
428
|
}
|
|
411
429
|
if (!plan) {
|
|
430
|
+
// goals.json absent or empty while the state dir exists is an inconsistent
|
|
431
|
+
// durable state, not a clean "no run". Fail closed so the pause guard (which
|
|
432
|
+
// relies on this `durable_state_unreadable` signal) keeps blocking give-ups.
|
|
412
433
|
return activeAskDiagnostic({
|
|
413
434
|
reason: "Durable .skc/ultragoal state exists but goals.json is missing or empty.",
|
|
414
435
|
source: "durable_state_unreadable",
|
|
@@ -477,6 +498,96 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
|
|
|
477
498
|
});
|
|
478
499
|
}
|
|
479
500
|
|
|
501
|
+
const NUDGE_SURFACE_LABEL: Record<UltragoalNudgeSurface, string> = {
|
|
502
|
+
pause: "pausing the goal",
|
|
503
|
+
drop: "dropping the run",
|
|
504
|
+
ask: "asking the user",
|
|
505
|
+
premature_complete: "finishing the story early",
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const NUDGE_SURFACE_REASON: Record<UltragoalNudgeSurface, string> = {
|
|
509
|
+
pause: "an active story still has resolvable work",
|
|
510
|
+
drop: "the aggregate run still has unfinished stories",
|
|
511
|
+
ask: "the decision can be resolved as durable story work",
|
|
512
|
+
premature_complete: "story verification is not yet satisfied",
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Escalating per-attempt refusal text. Deliberately avoids every
|
|
517
|
+
* `isUltragoalBypassPrompt` trigger: no `update_goal(`, no "skip/weaken verification",
|
|
518
|
+
* no "mark ... complete", no "--status complete", and the word "complete" never
|
|
519
|
+
* appears (so the `goal` ... `complete` proximity rule cannot match).
|
|
520
|
+
*/
|
|
521
|
+
export function formatUltragoalNudgeMessage(input: {
|
|
522
|
+
surface: UltragoalNudgeSurface;
|
|
523
|
+
attempt: number;
|
|
524
|
+
budget: number;
|
|
525
|
+
goalId: string;
|
|
526
|
+
}): string {
|
|
527
|
+
const label = NUDGE_SURFACE_LABEL[input.surface];
|
|
528
|
+
const reason = NUDGE_SURFACE_REASON[input.surface];
|
|
529
|
+
return [
|
|
530
|
+
`Ultragoal try-harder nudge (${input.attempt}/${input.budget}) for ${input.goalId}: ${label} was refused before the normal gate.`,
|
|
531
|
+
`Resolving this is part of the goal, not a reason to stop. Try a different approach first: inspect the failure, run a focused test or replay, find local credentials/config if access is the blocker, split the obstacle with \`skc ultragoal steer --kind add_subgoal\`, delegate an executor, or record concrete review blockers.`,
|
|
532
|
+
`Reason: ${reason}.`,
|
|
533
|
+
].join("\n");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Consume one nudge for a guarded give-up attempt. MUST only be called from assert/
|
|
538
|
+
* consume paths, never from read-style `is...Blocked` diagnostics. Returns a nudge
|
|
539
|
+
* message while the per-story budget remains; otherwise reports not-nudged so the
|
|
540
|
+
* caller falls through to today's gate.
|
|
541
|
+
*/
|
|
542
|
+
async function consumeUltragoalNudge(input: {
|
|
543
|
+
cwd: string;
|
|
544
|
+
surface: UltragoalNudgeSurface;
|
|
545
|
+
currentGoal?: CurrentGoalLike | null;
|
|
546
|
+
sessionId?: string | null;
|
|
547
|
+
}): Promise<{ nudged: true; message: string } | { nudged: false }> {
|
|
548
|
+
const sessionId = input.sessionId?.trim() || (await ultragoalReadPaths(input.cwd)).sessionId;
|
|
549
|
+
if (!sessionId) return { nudged: false };
|
|
550
|
+
const plan = await readUltragoalPlan(input.cwd, sessionId);
|
|
551
|
+
if (!plan) return { nudged: false };
|
|
552
|
+
const target = selectUltragoalNudgeTarget(plan, { currentGoalObjective: input.currentGoal?.objective });
|
|
553
|
+
if (!target) return { nudged: false };
|
|
554
|
+
const { budget } = await resolveUltragoalNudgeBudget(input.cwd);
|
|
555
|
+
const outcome = await recordUltragoalNudgeIfBudgetRemaining({
|
|
556
|
+
cwd: input.cwd,
|
|
557
|
+
sessionId,
|
|
558
|
+
target,
|
|
559
|
+
surface: input.surface,
|
|
560
|
+
budget,
|
|
561
|
+
reason: NUDGE_SURFACE_REASON[input.surface],
|
|
562
|
+
...(input.currentGoal?.objective ? { currentGoalObjective: input.currentGoal.objective } : {}),
|
|
563
|
+
});
|
|
564
|
+
if (outcome.nudged) {
|
|
565
|
+
return {
|
|
566
|
+
nudged: true,
|
|
567
|
+
message: formatUltragoalNudgeMessage({
|
|
568
|
+
surface: input.surface,
|
|
569
|
+
attempt: outcome.attempt,
|
|
570
|
+
budget: outcome.budget,
|
|
571
|
+
goalId: outcome.goalId,
|
|
572
|
+
}),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
return { nudged: false };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Assert-path entry for the `ask` surface (the ask guard lives in another module).
|
|
580
|
+
* Resolves the active (leader) Ultragoal session so subagent/headless asks consume
|
|
581
|
+
* the leader run's budget rather than a fresh child ledger.
|
|
582
|
+
*/
|
|
583
|
+
export async function consumeUltragoalAskNudge(
|
|
584
|
+
cwd: string,
|
|
585
|
+
sessionId?: string | null,
|
|
586
|
+
): Promise<{ nudged: true; message: string } | { nudged: false }> {
|
|
587
|
+
if (!cwd) return { nudged: false };
|
|
588
|
+
return consumeUltragoalNudge({ cwd, surface: "ask", sessionId });
|
|
589
|
+
}
|
|
590
|
+
|
|
480
591
|
export async function assertCanCompleteCurrentGoal(input: {
|
|
481
592
|
cwd: string;
|
|
482
593
|
currentGoal?: CurrentGoalLike | null;
|
|
@@ -485,6 +596,13 @@ export async function assertCanCompleteCurrentGoal(input: {
|
|
|
485
596
|
if (!input.cwd) return;
|
|
486
597
|
const diagnostic = await readUltragoalVerificationState(input);
|
|
487
598
|
if (["inactive", "unrelated_goal", "active_verified_complete"].includes(diagnostic.state)) return;
|
|
599
|
+
const nudge = await consumeUltragoalNudge({
|
|
600
|
+
cwd: input.cwd,
|
|
601
|
+
surface: "premature_complete",
|
|
602
|
+
currentGoal: input.currentGoal,
|
|
603
|
+
sessionId: input.sessionId,
|
|
604
|
+
});
|
|
605
|
+
if (nudge.nudged) throw new Error(nudge.message);
|
|
488
606
|
throw new Error(
|
|
489
607
|
`${diagnostic.message} Run strict \`skc ultragoal checkpoint --status complete --quality-gate-json <file> --skc-goal-json <file>\` first, or record review blockers and rerun verification.`,
|
|
490
608
|
);
|
|
@@ -541,6 +659,10 @@ export async function isUltragoalPauseBlocked(cwd: string): Promise<UltragoalPau
|
|
|
541
659
|
}
|
|
542
660
|
|
|
543
661
|
export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
|
|
662
|
+
if (cwd) {
|
|
663
|
+
const nudge = await consumeUltragoalNudge({ cwd, surface: "pause" });
|
|
664
|
+
if (nudge.nudged) throw new Error(nudge.message);
|
|
665
|
+
}
|
|
544
666
|
const diagnostic = await isUltragoalPauseBlocked(cwd);
|
|
545
667
|
if (!diagnostic.blocked) return;
|
|
546
668
|
throw new Error(
|
|
@@ -551,3 +673,65 @@ export async function assertUltragoalPauseAllowed(cwd: string): Promise<void> {
|
|
|
551
673
|
].join("\n"),
|
|
552
674
|
);
|
|
553
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Guard `goal({"op":"drop"})` during an active Ultragoal run. A *real give-up* (an
|
|
678
|
+
* aggregate run still mid-flight with incomplete required stories) is nudged while
|
|
679
|
+
* budget remains; once exhausted it falls through to today's drop behavior. Legitimate
|
|
680
|
+
* aggregate-reset drops — no durable run, unrelated goal, an already dropped/stale
|
|
681
|
+
* aggregate, or an all-stories-complete run — are never nudged. If durable state
|
|
682
|
+
* exists but cannot be read to classify the drop, fail closed.
|
|
683
|
+
*/
|
|
684
|
+
export async function assertUltragoalDropAllowed(input: {
|
|
685
|
+
cwd: string;
|
|
686
|
+
currentGoal?: CurrentGoalLike | null;
|
|
687
|
+
sessionId?: string | null;
|
|
688
|
+
}): Promise<void> {
|
|
689
|
+
if (!input.cwd) return;
|
|
690
|
+
let paths: UltragoalPaths;
|
|
691
|
+
let sessionId: string | null;
|
|
692
|
+
try {
|
|
693
|
+
({ paths, sessionId } = await ultragoalReadPaths(input.cwd));
|
|
694
|
+
} catch (error) {
|
|
695
|
+
throw new Error(
|
|
696
|
+
`Unable to classify Ultragoal drop (durable state unreadable): ${error instanceof Error ? error.message : String(error)}`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
if (sessionId === null) return;
|
|
700
|
+
try {
|
|
701
|
+
await fs.stat(paths.dir);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (isEnoent(error)) return;
|
|
704
|
+
throw new Error(
|
|
705
|
+
`Unable to classify Ultragoal drop (durable state present but unreadable): ${error instanceof Error ? error.message : String(error)}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
let plan: UltragoalPlan | null;
|
|
709
|
+
try {
|
|
710
|
+
plan = await readUltragoalPlan(input.cwd, sessionId);
|
|
711
|
+
} catch (error) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`Unable to classify Ultragoal drop (goals.json unreadable): ${error instanceof Error ? error.message : String(error)}`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
if (!plan) {
|
|
717
|
+
throw new Error("Unable to classify Ultragoal drop: durable state exists but goals.json is missing or empty.");
|
|
718
|
+
}
|
|
719
|
+
// Out of scope: per-story mode drops keep today's behavior.
|
|
720
|
+
if (plan.skcGoalMode !== "aggregate") return;
|
|
721
|
+
// A real give-up requires an active aggregate goal to actually be abandoned. With no
|
|
722
|
+
// current goal-mode goal (or a non-active one), `drop` is a no-op/reset before a fresh
|
|
723
|
+
// `create`, never a give-up — so it is left un-nudged.
|
|
724
|
+
if (!input.currentGoal) return;
|
|
725
|
+
if (input.currentGoal.status !== "active") return;
|
|
726
|
+
// Unrelated active goal: not this aggregate run.
|
|
727
|
+
if (!objectiveMatches(input.currentGoal.objective, plan)) return;
|
|
728
|
+
// All required stories complete: a legitimate reset, not a give-up.
|
|
729
|
+
if (getUltragoalRunCompletionState(plan).allComplete) return;
|
|
730
|
+
const nudge = await consumeUltragoalNudge({
|
|
731
|
+
cwd: input.cwd,
|
|
732
|
+
surface: "drop",
|
|
733
|
+
currentGoal: input.currentGoal,
|
|
734
|
+
sessionId,
|
|
735
|
+
});
|
|
736
|
+
if (nudge.nudged) throw new Error(nudge.message);
|
|
737
|
+
}
|