@sayknow-cli/coding-agent 0.5.1 → 0.5.2

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 (45) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/types/commands/session.d.ts +7 -0
  3. package/dist/types/config/telegram-autostart.d.ts +9 -1
  4. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  5. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  6. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  7. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  8. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  9. package/dist/types/session/agent-session.d.ts +1 -0
  10. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  11. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  12. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  13. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  14. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  15. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  16. package/dist/types/tools/ask.d.ts +164 -4
  17. package/package.json +10 -7
  18. package/src/commands/session.ts +88 -2
  19. package/src/config/model-registry.ts +12 -0
  20. package/src/config/telegram-autostart.ts +11 -4
  21. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  22. package/src/internal-urls/docs-index.generated.ts +1 -1
  23. package/src/main.ts +1 -1
  24. package/src/modes/components/pet-capability.ts +22 -13
  25. package/src/modes/components/pet-selector.ts +1 -1
  26. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  27. package/src/modes/controllers/event-controller.ts +1 -1
  28. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  29. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  30. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  31. package/src/prompts/system/eager-todo.md +2 -0
  32. package/src/prompts/system/plan-mode-approved.md +1 -1
  33. package/src/prompts/system/system-prompt.md +4 -2
  34. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  35. package/src/session/agent-session.ts +31 -11
  36. package/src/skc-runtime/boot-generation.ts +172 -0
  37. package/src/skc-runtime/launch-tmux.ts +219 -41
  38. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  39. package/src/skc-runtime/session-restore.ts +296 -0
  40. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  41. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  42. package/src/skc-runtime/tmux-sessions.ts +284 -108
  43. package/src/slash-commands/builtin-registry.ts +9 -4
  44. package/src/tools/ask.ts +183 -10
  45. package/src/tools/eval.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { type OwnerIsolationProbeSync, type PlanResponse, type TmuxOwnerIsolationExecutionDependencies, type TmuxOwnerIsolationExecutionResult } from "./tmux-owner-isolation";
1
+ import { type AbandonedIdentityCensus, type IdentityCreateKey, type IdentityCreateReservationResult, type OwnerIsolationProbeSync, type PlanResponse, type TmuxOwnerIsolationExecutionDependencies, type TmuxOwnerIsolationExecutionResult } from "./tmux-owner-isolation";
2
2
  export interface SkcTmuxSessionStatus {
3
3
  name: string;
4
4
  attached: boolean;
@@ -67,6 +67,17 @@ export interface ForceCloseOwnerDependencies {
67
67
  }
68
68
  export interface CreateSkcTmuxSessionOptions {
69
69
  platform?: NodeJS.Platform;
70
+ /**
71
+ * Restore only: run the child in the session's recorded directory instead of
72
+ * this process's cwd.
73
+ */
74
+ cwd?: string;
75
+ /**
76
+ * Restore only: argv appended to the `skc` child, e.g. `["--resume", "<id>"]`.
77
+ * Restore reuses this creator verbatim so it inherits the same identity fence
78
+ * and owner-isolation proof as an ordinary create.
79
+ */
80
+ childArgs?: string[];
70
81
  }
71
82
  export type CreateOwnerIsolationTestDependencies = {
72
83
  probe?: Partial<OwnerIsolationProbeSync>;
@@ -83,6 +94,20 @@ export declare function findSkcTmuxSessionByBranch(branch: string, env?: NodeJS.
83
94
  export declare function findSkcTmuxSessionByName(sessionName: string, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus | undefined;
84
95
  export declare function findSkcTmuxSessionByScope(project: string, branch: string | null | undefined, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus | undefined;
85
96
  export declare function statusSkcTmuxSession(sessionName: string, env?: NodeJS.ProcessEnv): SkcTmuxSessionStatus;
97
+ /**
98
+ * Live-tmux census for authority-first recovery of an abandoned reservation.
99
+ *
100
+ * Authority is the CURRENT published generation on disk compared against the
101
+ * live session's canonical tags — never the reservation's own `phase`, because a
102
+ * creator can publish and then die before recording that it published.
103
+ */
104
+ /**
105
+ * Resolves a reservation refused because its previous owner died after reaching
106
+ * the helper. Shared by every producer so none of them can brick an identity by
107
+ * treating an abandoned row as a permanent collision.
108
+ */
109
+ export declare function settleAbandonedIdentity(reserved: IdentityCreateReservationResult, identityKey: IdentityCreateKey, census: AbandonedIdentityCensus): IdentityCreateReservationResult;
110
+ export declare function abandonedIdentityCensus(tmuxCommand: string, env: NodeJS.ProcessEnv, stateFile: string): AbandonedIdentityCensus;
86
111
  export declare function createSkcTmuxSession(env?: NodeJS.ProcessEnv, options?: CreateSkcTmuxSessionOptions): SkcTmuxSessionStatus;
87
112
  /** Proves a managed reusable name still resolves to one immutable session on one server. */
88
113
  export declare function proveSkcTmuxSessionMutationTarget(sessionName: string, env?: NodeJS.ProcessEnv): ProvenTmuxSessionIdentity;
@@ -29,13 +29,92 @@ export declare const askSchema: z.ZodObject<{
29
29
  }, z.core.$strip>>;
30
30
  multi: z.ZodOptional<z.ZodBoolean>;
31
31
  recommended: z.ZodOptional<z.ZodNumber>;
32
- deepInterview: z.ZodOptional<z.ZodObject<{
32
+ workflowGate: z.ZodOptional<z.ZodObject<{
33
+ stage: z.ZodEnum<{
34
+ "deep-interview": "deep-interview";
35
+ ralplan: "ralplan";
36
+ ultragoal: "ultragoal";
37
+ }>;
38
+ kind: z.ZodEnum<{
39
+ question: "question";
40
+ approval: "approval";
41
+ execution: "execution";
42
+ }>;
43
+ }, z.core.$strip>>;
44
+ deepInterview: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
45
+ round: z.ZodLiteral<0>;
46
+ component: z.ZodLiteral<"review-topology">;
47
+ intent_contract: z.ZodObject<{
48
+ items: z.ZodArray<z.ZodObject<{
49
+ id: z.ZodString;
50
+ category: z.ZodEnum<{
51
+ surface: "surface";
52
+ artifact: "artifact";
53
+ integration: "integration";
54
+ constraint: "constraint";
55
+ }>;
56
+ statement: z.ZodString;
57
+ }, z.core.$strict>>;
58
+ confirmation_options: z.ZodArray<z.ZodString>;
59
+ }, z.core.$strict>;
33
60
  round_id: z.ZodOptional<z.ZodString>;
61
+ dimension: z.ZodString;
62
+ ambiguity: z.ZodNumber;
63
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
64
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
65
+ reference_id: z.ZodString;
66
+ label: z.ZodString;
67
+ origin: z.ZodString;
68
+ url: z.ZodOptional<z.ZodString>;
69
+ excerpt: z.ZodOptional<z.ZodString>;
70
+ }, z.core.$strict>>>;
71
+ }, z.core.$strict>, z.ZodObject<{
34
72
  round: z.ZodNumber;
73
+ intent_review: z.ZodObject<{
74
+ observed_items: z.ZodArray<z.ZodObject<{
75
+ id: z.ZodString;
76
+ category: z.ZodEnum<{
77
+ surface: "surface";
78
+ artifact: "artifact";
79
+ integration: "integration";
80
+ constraint: "constraint";
81
+ }>;
82
+ statement: z.ZodString;
83
+ }, z.core.$strict>>;
84
+ supporting_substitutions: z.ZodArray<z.ZodObject<{
85
+ removed_id: z.ZodString;
86
+ replacement_ids: z.ZodArray<z.ZodString>;
87
+ rationale: z.ZodString;
88
+ }, z.core.$strict>>;
89
+ approval_options: z.ZodArray<z.ZodString>;
90
+ }, z.core.$strict>;
91
+ round_id: z.ZodOptional<z.ZodString>;
35
92
  component: z.ZodString;
36
93
  dimension: z.ZodString;
37
94
  ambiguity: z.ZodNumber;
38
- }, z.core.$strip>>;
95
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
96
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
97
+ reference_id: z.ZodString;
98
+ label: z.ZodString;
99
+ origin: z.ZodString;
100
+ url: z.ZodOptional<z.ZodString>;
101
+ excerpt: z.ZodOptional<z.ZodString>;
102
+ }, z.core.$strict>>>;
103
+ }, z.core.$strict>, z.ZodObject<{
104
+ round_id: z.ZodOptional<z.ZodString>;
105
+ round: z.ZodNumber;
106
+ component: z.ZodString;
107
+ dimension: z.ZodString;
108
+ ambiguity: z.ZodNumber;
109
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
110
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
111
+ reference_id: z.ZodString;
112
+ label: z.ZodString;
113
+ origin: z.ZodString;
114
+ url: z.ZodOptional<z.ZodString>;
115
+ excerpt: z.ZodOptional<z.ZodString>;
116
+ }, z.core.$strict>>>;
117
+ }, z.core.$strict>]>>;
39
118
  }, z.core.$strip>>;
40
119
  }, z.core.$strip>;
41
120
  export type AskToolInput = z.infer<typeof askSchema>;
@@ -54,6 +133,8 @@ export interface AskToolDetails {
54
133
  multi?: boolean;
55
134
  selectedOptions?: string[];
56
135
  customInput?: string;
136
+ /** Set when the user answered with a clarification question instead of a choice. */
137
+ clarificationQuestion?: string;
57
138
  /** Multi-part question mode */
58
139
  results?: QuestionResult[];
59
140
  }
@@ -80,13 +161,92 @@ export declare class AskTool implements AgentTool<typeof askSchema, AskToolDetai
80
161
  }, z.core.$strip>>;
81
162
  multi: z.ZodOptional<z.ZodBoolean>;
82
163
  recommended: z.ZodOptional<z.ZodNumber>;
83
- deepInterview: z.ZodOptional<z.ZodObject<{
164
+ workflowGate: z.ZodOptional<z.ZodObject<{
165
+ stage: z.ZodEnum<{
166
+ "deep-interview": "deep-interview";
167
+ ralplan: "ralplan";
168
+ ultragoal: "ultragoal";
169
+ }>;
170
+ kind: z.ZodEnum<{
171
+ question: "question";
172
+ approval: "approval";
173
+ execution: "execution";
174
+ }>;
175
+ }, z.core.$strip>>;
176
+ deepInterview: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
177
+ round: z.ZodLiteral<0>;
178
+ component: z.ZodLiteral<"review-topology">;
179
+ intent_contract: z.ZodObject<{
180
+ items: z.ZodArray<z.ZodObject<{
181
+ id: z.ZodString;
182
+ category: z.ZodEnum<{
183
+ surface: "surface";
184
+ artifact: "artifact";
185
+ integration: "integration";
186
+ constraint: "constraint";
187
+ }>;
188
+ statement: z.ZodString;
189
+ }, z.core.$strict>>;
190
+ confirmation_options: z.ZodArray<z.ZodString>;
191
+ }, z.core.$strict>;
84
192
  round_id: z.ZodOptional<z.ZodString>;
193
+ dimension: z.ZodString;
194
+ ambiguity: z.ZodNumber;
195
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
196
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
197
+ reference_id: z.ZodString;
198
+ label: z.ZodString;
199
+ origin: z.ZodString;
200
+ url: z.ZodOptional<z.ZodString>;
201
+ excerpt: z.ZodOptional<z.ZodString>;
202
+ }, z.core.$strict>>>;
203
+ }, z.core.$strict>, z.ZodObject<{
85
204
  round: z.ZodNumber;
205
+ intent_review: z.ZodObject<{
206
+ observed_items: z.ZodArray<z.ZodObject<{
207
+ id: z.ZodString;
208
+ category: z.ZodEnum<{
209
+ surface: "surface";
210
+ artifact: "artifact";
211
+ integration: "integration";
212
+ constraint: "constraint";
213
+ }>;
214
+ statement: z.ZodString;
215
+ }, z.core.$strict>>;
216
+ supporting_substitutions: z.ZodArray<z.ZodObject<{
217
+ removed_id: z.ZodString;
218
+ replacement_ids: z.ZodArray<z.ZodString>;
219
+ rationale: z.ZodString;
220
+ }, z.core.$strict>>;
221
+ approval_options: z.ZodArray<z.ZodString>;
222
+ }, z.core.$strict>;
223
+ round_id: z.ZodOptional<z.ZodString>;
86
224
  component: z.ZodString;
87
225
  dimension: z.ZodString;
88
226
  ambiguity: z.ZodNumber;
89
- }, z.core.$strip>>;
227
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
228
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
229
+ reference_id: z.ZodString;
230
+ label: z.ZodString;
231
+ origin: z.ZodString;
232
+ url: z.ZodOptional<z.ZodString>;
233
+ excerpt: z.ZodOptional<z.ZodString>;
234
+ }, z.core.$strict>>>;
235
+ }, z.core.$strict>, z.ZodObject<{
236
+ round_id: z.ZodOptional<z.ZodString>;
237
+ round: z.ZodNumber;
238
+ component: z.ZodString;
239
+ dimension: z.ZodString;
240
+ ambiguity: z.ZodNumber;
241
+ confused_terms: z.ZodOptional<z.ZodArray<z.ZodString>>;
242
+ references: z.ZodOptional<z.ZodArray<z.ZodObject<{
243
+ reference_id: z.ZodString;
244
+ label: z.ZodString;
245
+ origin: z.ZodString;
246
+ url: z.ZodOptional<z.ZodString>;
247
+ excerpt: z.ZodOptional<z.ZodString>;
248
+ }, z.core.$strict>>>;
249
+ }, z.core.$strict>]>>;
90
250
  }, z.core.$strip>>;
91
251
  }, z.core.$strip>;
92
252
  readonly strict = true;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/coding-agent",
4
- "version": "0.5.1",
4
+ "version": "0.5.2",
5
5
  "description": "Sayknow-CLI CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -52,12 +52,12 @@
52
52
  "@agentclientprotocol/sdk": "1.3.0",
53
53
  "@babel/parser": "^7.29.3",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@sayknow-cli/stats": "0.5.1",
56
- "@sayknow-cli/agent-core": "0.5.1",
57
- "@sayknow-cli/ai": "0.5.1",
58
- "@sayknow-cli/natives": "0.5.1",
59
- "@sayknow-cli/tui": "0.5.1",
60
- "@sayknow-cli/utils": "0.5.1",
55
+ "@sayknow-cli/stats": "0.5.2",
56
+ "@sayknow-cli/agent-core": "0.5.2",
57
+ "@sayknow-cli/ai": "0.5.2",
58
+ "@sayknow-cli/natives": "0.5.2",
59
+ "@sayknow-cli/tui": "0.5.2",
60
+ "@sayknow-cli/utils": "0.5.2",
61
61
  "@puppeteer/browsers": "^2.13.0",
62
62
  "@types/turndown": "5.0.6",
63
63
  "@xterm/headless": "^6.0.0",
@@ -124,6 +124,9 @@
124
124
  "./commands/skc-runtime-bridge": null,
125
125
  "./capability/mcp": null,
126
126
  "./config/mcp-schema": null,
127
+ "./config/model-auth": null,
128
+ "./config/model-bindings-applier": null,
129
+ "./config/model-discovery-manager": null,
127
130
  "./discovery/mcp-json": null,
128
131
  "./exa": null,
129
132
  "./exa/*": null,
@@ -1,4 +1,13 @@
1
1
  import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
2
+ import { readBootGeneration } from "../skc-runtime/boot-generation";
3
+ import {
4
+ decodeRestoreReference,
5
+ encodeRestoreReference,
6
+ evaluateRestoreCandidates,
7
+ listRestorePointers,
8
+ type RestoreCandidateVerdict,
9
+ } from "../skc-runtime/session-restore";
10
+ import { buildRestoreCandidateDeps, restoreSession } from "../skc-runtime/session-restore-runtime";
2
11
  import {
3
12
  attachSkcTmuxSession,
4
13
  createSkcTmuxSession,
@@ -45,12 +54,13 @@ function sessionJson(session: SessionJsonDto): SessionJsonDto {
45
54
  }
46
55
 
47
56
  export default class Session extends Command {
48
- static description = "List, inspect, attach, and remove tagged SKC-managed tmux sessions";
57
+ static description =
58
+ "List, inspect, attach, and remove tagged SKC-managed tmux sessions. `restore` is a manual, opt-in recovery step that only ever restores sessions this machine can prove died in a REBOOT: nothing starts automatically at login, a session that is still live is never duplicated, and running tools or background work are not recovered — only the tmux session, its directory, and its conversation.";
49
59
  static strict = false;
50
60
 
51
61
  static args = {
52
62
  action: Args.string({
53
- description: "list (default), status, create, attach, or remove",
63
+ description: "list (default), status, create, attach, remove, or restore",
54
64
  required: false,
55
65
  }),
56
66
  session: Args.string({
@@ -67,6 +77,15 @@ export default class Session extends Command {
67
77
  "state-file": Flags.string({
68
78
  description: "Expected @skc-session-state-file tag for force-close (defense-in-depth match)",
69
79
  }),
80
+ "dry-run": Flags.boolean({
81
+ description:
82
+ "restore: list candidates and their verdicts. Changes nothing at all (no tmux command, no lock, no database, no pointer write). This is the default when no --reference is given.",
83
+ default: false,
84
+ }),
85
+ reference: Flags.string({
86
+ description:
87
+ "restore: restore exactly one candidate by the opaque reference shown in --dry-run output. Refused unless that candidate is still reboot-eligible at this moment.",
88
+ }),
70
89
  };
71
90
 
72
91
  static examples = [
@@ -76,6 +95,8 @@ export default class Session extends Command {
76
95
  "skc session attach <session>",
77
96
  "skc session remove <session>",
78
97
  "skc session force-close <session> --session-id <id>",
98
+ "skc session restore --dry-run # list what a reboot left behind",
99
+ "skc session restore --reference <ref> # restore exactly that one",
79
100
  ];
80
101
 
81
102
  async run(): Promise<void> {
@@ -105,6 +126,71 @@ export default class Session extends Command {
105
126
  return;
106
127
  }
107
128
 
129
+ if (action === "restore") {
130
+ // Manual, reboot-proven, opt-in. Nothing here starts automatically at
131
+ // login, and a session is only ever restored when this host can prove
132
+ // the machine rebooted since that session was recorded.
133
+ const reference = flags.reference;
134
+ const decoded = reference ? decodeRestoreReference(reference) : null;
135
+ if (reference && !decoded) throw new Error("invalid_restore_reference");
136
+ const pointers = listRestorePointers().filter(
137
+ pointer =>
138
+ !decoded ||
139
+ (pointer.coordinator_session_id === decoded.coordinatorSessionId &&
140
+ pointer.state_file === decoded.stateFile),
141
+ );
142
+ if (reference && pointers.length === 0) throw new Error("restore_reference_not_found");
143
+ const verdicts = evaluateRestoreCandidates(pointers, buildRestoreCandidateDeps(readBootGeneration()));
144
+ const describe = (verdict: RestoreCandidateVerdict) => ({
145
+ reference: encodeRestoreReference(verdict.pointer.coordinator_session_id, verdict.pointer.state_file),
146
+ sessionId: verdict.pointer.skc_session_id,
147
+ cwd: verdict.pointer.cwd,
148
+ branch: verdict.pointer.branch,
149
+ eligible: verdict.eligible,
150
+ ...(verdict.eligible ? {} : { reason: verdict.reason }),
151
+ });
152
+ // Dry run is the default reporting surface and performs zero mutation:
153
+ // no pointer write, no lock, no SQLite, no tmux command.
154
+ if (flags["dry-run"] || !reference) {
155
+ const rows = verdicts.map(describe);
156
+ if (json) {
157
+ writeJson({ ok: true, dryRun: true, candidates: rows });
158
+ return;
159
+ }
160
+ writeText(
161
+ rows.length === 0
162
+ ? ["no restore candidates"]
163
+ : rows.map(row =>
164
+ [
165
+ row.eligible ? "restorable" : `skipped(${row.reason})`,
166
+ row.sessionId,
167
+ row.cwd,
168
+ row.reference,
169
+ ].join("\t"),
170
+ ),
171
+ );
172
+ return;
173
+ }
174
+ const verdict = verdicts[0];
175
+ if (!verdict) throw new Error("restore_reference_not_found");
176
+ if (!verdict.eligible) throw new Error(`restore_ineligible_${verdict.reason}`);
177
+ const outcome = restoreSession(verdict.pointer);
178
+ if (!outcome.ok) throw new Error(outcome.detail);
179
+ if (json) {
180
+ writeJson({
181
+ ok: true,
182
+ restored: {
183
+ sessionId: outcome.pointer.skc_session_id,
184
+ cwd: outcome.pointer.cwd,
185
+ tmuxSession: outcome.tmuxSession,
186
+ },
187
+ });
188
+ return;
189
+ }
190
+ writeText([`restored: ${outcome.tmuxSession} (${outcome.pointer.skc_session_id})`]);
191
+ return;
192
+ }
193
+
108
194
  if (action === "create") {
109
195
  const session = createSkcTmuxSession();
110
196
  if (json) {
@@ -1032,6 +1032,13 @@ export class ModelRegistry {
1032
1032
  #lastStaticLoadMtime: number | null = null;
1033
1033
  #registeredProviderSources: Set<string> = new Set();
1034
1034
  #providerDiscoveryStates: Map<string, ProviderDiscoveryState> = new Map();
1035
+ /**
1036
+ * Monotonic per-provider discovery sequence. Overlapping refreshes for the
1037
+ * same provider can complete out of order; only the newest-started refresh
1038
+ * may publish discovery state or contribute models, so a slow stale fetch
1039
+ * never overwrites a fresher result.
1040
+ */
1041
+ #providerDiscoverySequences: Map<string, number> = new Map();
1035
1042
  #cacheDbPath?: string;
1036
1043
  #suppressedSelectors: Map<string, number> = new Map();
1037
1044
  #backgroundRefresh?: Promise<void>;
@@ -1815,11 +1822,15 @@ export class ModelRegistry {
1815
1822
  providerConfig: DiscoveryProviderConfig,
1816
1823
  strategy: ModelRefreshStrategy,
1817
1824
  ): Promise<Model<Api>[]> {
1825
+ const sequence = (this.#providerDiscoverySequences.get(providerConfig.provider) ?? 0) + 1;
1826
+ this.#providerDiscoverySequences.set(providerConfig.provider, sequence);
1827
+ const isCurrentDiscovery = () => this.#providerDiscoverySequences.get(providerConfig.provider) === sequence;
1818
1828
  const cached = readModelCache<Api>(providerConfig.provider, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1819
1829
  const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
1820
1830
  if (requiresAuth) {
1821
1831
  const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
1822
1832
  if (!isAuthenticated(apiKey)) {
1833
+ if (!isCurrentDiscovery()) return [];
1823
1834
  this.#providerDiscoveryStates.set(providerConfig.provider, {
1824
1835
  provider: providerConfig.provider,
1825
1836
  status: "unauthenticated",
@@ -1865,6 +1876,7 @@ export class ModelRegistry {
1865
1876
  : result.models.length > 0
1866
1877
  ? "ok"
1867
1878
  : "empty";
1879
+ if (!isCurrentDiscovery()) return [];
1868
1880
  this.#providerDiscoveryStates.set(providerId, {
1869
1881
  provider: providerId,
1870
1882
  status,
@@ -10,11 +10,18 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { getConfigRootDir, isCompiledBinary, logger } from "@sayknow-cli/utils";
13
- import { settings } from "./settings";
13
+ import { settings as globalSettings, type Settings } from "./settings";
14
14
  import type { TelegramSettings } from "./settings-schema";
15
15
  import { telegramSettingsToEnv, validateTelegramSettings } from "./telegram-env-bridge";
16
16
 
17
- function readTelegramSettings(): TelegramSettings {
17
+ /**
18
+ * The startup command runs against an explicit Settings instance (tests and
19
+ * embedders inject one), so read through it instead of the global proxy —
20
+ * which may not be initialized at all on those paths.
21
+ */
22
+ type TelegramSettingsSource = Pick<Settings, "get">;
23
+
24
+ function readTelegramSettings(settings: TelegramSettingsSource): TelegramSettings {
18
25
  return {
19
26
  enabled: settings.get("telegram.enabled"),
20
27
  botToken: settings.get("telegram.botToken"),
@@ -69,10 +76,10 @@ function isAlreadyRunning(pidFile: string): boolean {
69
76
  * it in the background. Never throws — failures are logged as warnings so the
70
77
  * main skc session is unaffected.
71
78
  */
72
- export async function maybeAutostartTelegramRemote(): Promise<void> {
79
+ export async function maybeAutostartTelegramRemote(settings: TelegramSettingsSource = globalSettings): Promise<void> {
73
80
  if (!settings.get("telegram.enabled")) return;
74
81
 
75
- const config = readTelegramSettings();
82
+ const config = readTelegramSettings(settings);
76
83
  const errors = validateTelegramSettings(config);
77
84
  if (errors.length > 0) {
78
85
  logger.warn("Telegram Remote enabled but misconfigured, skipping autostart", { errors });
@@ -26,7 +26,7 @@ Deep Interview implements Ouroboros-inspired Socratic questioning with mathemati
26
26
  <Do_Not_Use_When>
27
27
  - User has a detailed, specific request with file paths, function names, or acceptance criteria -- execute directly
28
28
  - User wants to explore options or brainstorm -- use `ralplan` skill instead
29
- - User wants a quick fix or single change -- delegate to executor or execution
29
+ - User wants a quick fix or single change -- delegate to executor or direct execution
30
30
  - User says "just do it" or "skip the questions" without an explicit execution path -- respect their intent by ending interview and writing a `pending approval` spec, not by mutating files or delegating execution
31
31
  - User already has a PRD or plan file and explicitly asks to execute it -- use the requested execution skill with that plan
32
32
  </Do_Not_Use_When>
@@ -128,6 +128,12 @@ If the user request appended after this skill as the final `User:` line is alrea
128
128
 
129
129
  This gate exists to prevent deep-interview from making easy problems harder. A small verification need does not make a request interview-worthy.
130
130
 
131
+ **Implementation wording is not execution approval.** When the user says `implementation`, "implementation plan", Korean `구현`, or "구현 계획", they are describing the eventual target, not permission to implement now. Interpreting that wording as approval is the single most likely way this skill breaks its own boundary.
132
+
133
+ On that wording: do not implement, edit/write code, launch implementation workers, or start task/skill/ultragoal implementation. Say plainly — "I can interview for an implementation plan, but I won't implement during deep-interview." — and continue clarifying scope, risks, acceptance criteria, and unknowns.
134
+
135
+ Implementation requires an explicit phase transition/approval after the interview: the workflow phase must explicitly transition out of deep-interview, and execution approval must be captured by a downstream execution path (Phase 5's bridge into `/skill:ralplan`, `/skill:ultragoal`, or `/skill:team`). Wanting an implementation plan and approving implementation are two different consents.
136
+
131
137
  ## Phase 0.75: Optional Trace Pre-Step
132
138
 
133
139
  Run this phase only when the active deep-interview state or invocation indicates `--trace` / `state.trace.enabled === true`. It is a pre-interview research step, not an implementation phase.
@@ -326,6 +332,20 @@ If any prompt input is too large, summarize it first and then continue from the
326
332
  | Context Clarity (brownfield) | "How does this fit?" | "I found JWT auth middleware in `src/auth/` (pattern: passport + JWT). Should this feature extend that path or intentionally diverge from it?" |
327
333
  | Scope-fuzzy / ontology stress | "What IS the core thing here?" | "You have named Tasks, Projects, and Workspaces across the last rounds. Which one is the core entity, and which are supporting views or containers?" |
328
334
 
335
+ **Attach your own guess to every question.** The question exposes the *user's* assumptions; the guess exposes *yours*. Without it the interview only ever audits one side, and the agent's unexamined read of the ask silently steers the next round's targeting.
336
+
337
+ Every generated question MUST carry a one-line `GUESS:` — your hypothesis for the answer, the reason you hold it, and what changes if it is wrong:
338
+
339
+ ```
340
+ {question}
341
+ GUESS: {your hypothesis} — {why you hold it}. If it is {the other reading} instead, {what changes}.
342
+ ```
343
+
344
+ - Reacting is cheaper than generating: a terse "no, the second one" still carries a full correction, so low-effort answers stop being low-information rounds.
345
+ - A visible guess is falsifiable. Being wrong out loud is the point; guess in the direction you expect pushback when the reading is genuinely contested.
346
+ - A guess is NOT an auto-answer. It never resolves the round, never feeds scoring, and never increments `auto_answer_streak` — only the user's answer does.
347
+ - Skip only when a guess would be pure noise: Round 0 topology confirmation, the Phase 4b restate gate, and clarification re-asks.
348
+
329
349
  ### Step 2a′: Auto-Research Greenfield Questions
330
350
 
331
351
  When the next question is for a greenfield interview and is tagged `research: true`, load `auto-research-greenfield.md` as an internal `kind: "skill-fragment"` prompt for a fork-context architect before Step 2b. Pass only the tagged question, locked topology summary, prompt-safe initial idea, trimmed prior decisions/gaps, and relevant constraints. The architect must return 2-3 ranked candidates with rationale, confidence, and fallback notes. Validate the shape before use; if valid, incorporate the candidates as concise answer options or context for the single user-facing question and append the round number to `auto_researched_rounds`. If invalid or unavailable, fall back silently to the normal generated question and increment `architect_failures`.
@@ -340,14 +360,19 @@ Use the `ask` tool with the generated question. When a question has options, you
340
360
  Round {n} | Component: {target_component_name} | Targeting: {weakest_dimension} | Why now: {one_sentence_targeting_rationale} | Ambiguity: {score}%
341
361
 
342
362
  {question}
363
+ GUESS: {your hypothesis} — {why you hold it}. If it is {the other reading} instead, {what changes}.
343
364
  ```
344
365
 
345
366
  Options should include contextually relevant choices plus free-text, translated/localized according to `language.instruction` when present.
346
367
 
347
- After applying `language.instruction` to the visible question, options, and generated rationale, apply the self-proofread once to new prose only; preserve only the Round/Component/Targeting/Ambiguity line structure, fixed labels, numeric ambiguity value, component/target identifiers, and `deepInterview.*` metadata keys. Do not exempt generated natural-language rationale such as Why now.
368
+ After applying `language.instruction` to the visible question, options, and generated rationale, apply the self-proofread once to new prose only; preserve only the Round/Component/Targeting/Ambiguity line structure, fixed labels (including the `GUESS:` marker), numeric ambiguity value, component/target identifiers, and `deepInterview.*` metadata keys. Do not exempt generated natural-language rationale such as Why now or the guess text that follows the `GUESS:` marker.
348
369
 
349
370
  When calling `ask`, SHOULD include optional structured metadata so the runtime can record the round without manual state writes: `deepInterview.round_id?`, `deepInterview.round`, `deepInterview.component`, `deepInterview.dimension`, and `deepInterview.ambiguity`. Keep this metadata aligned with the visible Round/Component/Targeting/Ambiguity line; if metadata cannot be supplied, the legacy formatted question text remains the fallback.
350
371
 
372
+ **Non-behavioral adapter context (`confused_terms`, `references`).** `deepInterview.confused_terms` and `deepInterview.references` carry vocabulary and citations queued at interview start. They are inert: they MUST NOT alter the first question, are never inferred from vocabulary density, and a reference's `url`/`excerpt` are strings that are **never auto-fetched**. Bounds enforced at the ask boundary: at most 32 terms and 32 references, adapter strings ≤ 256 characters, `url`/`excerpt` ≤ 2048, and core round metadata (`round_id`, `component`, `dimension`) ≤ 128 — all counted in Unicode code points, so emoji-padded input hits the same ceiling as ASCII.
373
+
374
+ **Free-text vs structural input (`FREETEXT_FIELDS`).** The runtime keeps an allowlist of fields that legitimately carry prose — `initial_context`, `initial_idea`, `initial_context_summary`, `user_response`, `answer`, `goal`, `objective`, `prompt`, `description`, `statement`, `restated_goal`, `evidence`, `excerpt`. Inside those fields, shell metacharacters (`;`, `|`, `&`, backticks, `$()`) are valid prose and MUST NOT be rejected as structural injection; a user is allowed to describe a shell command. Structural fields (ids, categories, hashes) stay strictly validated by their own guards. Size is capped by character-count, not byte length: 50,000 for initial context and 10,000 for a single user response.
375
+
351
376
  If the `ask` tool returns `clarificationQuestion`, treat it as a non-answer about the displayed choices. Answer the clarification briefly from the current interview context, then call `ask` again with the exact original question, options, and `deepInterview.*` metadata. A clarification bypasses Step 2b′ auto-answer, Step 2b″ free-text refine, Step 2c ambiguity scoring, Step 2d progress reporting, and Step 2e state updates; it must not be recorded as a round answer. This does not violate the one-question-per-round rule because the round remains unresolved until the user submits a real listed option or `Other` answer.
352
377
 
353
378
  ### Step 2b′: Auto-Answer Opted-Out Questions
@@ -504,7 +529,7 @@ Round {n} complete.
504
529
 
505
530
  Apply `language.instruction` when present before showing this progress report so status text, gaps, and next-target phrasing stay in the preserved session language.
506
531
 
507
- Then apply the self-proofread once (DIPP-5) to narrative status text, generated prose cells, gaps, and next-target phrasing; preserve only table structure, fixed status labels, scores, weights, component ids, and trigger tokens.
532
+ Then apply the self-proofread once to narrative status text, generated prose cells, gaps, and next-target phrasing; preserve only table structure, fixed status labels, scores, weights, component ids, and trigger tokens.
508
533
 
509
534
  ### Step 2e: Update State
510
535
 
@@ -927,6 +952,7 @@ Why bad: 45% ambiguity means nearly half the requirements are unclear. The mathe
927
952
  - [ ] Oversized initial context/history summarized before scoring, question generation, spec generation, or handoff
928
953
  - [ ] Round 0 topology gate completed before scoring; `topology.confirmed_at` persisted
929
954
  - [ ] Ambiguity scored and displayed every round, naming the weakest component/dimension target (rotating across active components when N > 1)
955
+ - [ ] Every asked question carried a `GUESS:` line (except Round 0 topology, the Restate gate, and clarification re-asks), and no guess was recorded as an answer or counted toward `auto_answer_streak`
930
956
  - [ ] Lateral panel convened at milestone transitions (and before synthesizing agent-supplied answers) with parallel read-only personas
931
957
  - [ ] Free-text answers passed the Refine gate; dialectic rhythm guard forced a user question after 3 agent-resolved answers; any auto-answer threshold crossing explicitly confirmed
932
958
  - [ ] Closure / Acceptance Guard and the one-sentence Restate gate both passed before crystallization