iosm-cli 0.1.3 → 0.2.0

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 (56) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +33 -2
  3. package/dist/core/blast.d.ts +62 -0
  4. package/dist/core/blast.d.ts.map +1 -0
  5. package/dist/core/blast.js +448 -0
  6. package/dist/core/blast.js.map +1 -0
  7. package/dist/core/contract.d.ts +54 -0
  8. package/dist/core/contract.d.ts.map +1 -0
  9. package/dist/core/contract.js +300 -0
  10. package/dist/core/contract.js.map +1 -0
  11. package/dist/core/semantic/config.d.ts.map +1 -1
  12. package/dist/core/semantic/config.js +5 -0
  13. package/dist/core/semantic/config.js.map +1 -1
  14. package/dist/core/semantic/index.d.ts +1 -1
  15. package/dist/core/semantic/index.d.ts.map +1 -1
  16. package/dist/core/semantic/index.js +1 -1
  17. package/dist/core/semantic/index.js.map +1 -1
  18. package/dist/core/semantic/runtime.d.ts.map +1 -1
  19. package/dist/core/semantic/runtime.js +12 -1
  20. package/dist/core/semantic/runtime.js.map +1 -1
  21. package/dist/core/semantic/types.d.ts +6 -0
  22. package/dist/core/semantic/types.d.ts.map +1 -1
  23. package/dist/core/semantic/types.js +6 -0
  24. package/dist/core/semantic/types.js.map +1 -1
  25. package/dist/core/shadow-guard.d.ts +30 -0
  26. package/dist/core/shadow-guard.d.ts.map +1 -0
  27. package/dist/core/shadow-guard.js +81 -0
  28. package/dist/core/shadow-guard.js.map +1 -0
  29. package/dist/core/singular.d.ts +73 -0
  30. package/dist/core/singular.d.ts.map +1 -0
  31. package/dist/core/singular.js +413 -0
  32. package/dist/core/singular.js.map +1 -0
  33. package/dist/core/slash-commands.d.ts.map +1 -1
  34. package/dist/core/slash-commands.js +9 -1
  35. package/dist/core/slash-commands.js.map +1 -1
  36. package/dist/core/system-prompt.d.ts.map +1 -1
  37. package/dist/core/system-prompt.js +3 -1
  38. package/dist/core/system-prompt.js.map +1 -1
  39. package/dist/core/tools/semantic-search.d.ts.map +1 -1
  40. package/dist/core/tools/semantic-search.js +1 -0
  41. package/dist/core/tools/semantic-search.js.map +1 -1
  42. package/dist/main.d.ts.map +1 -1
  43. package/dist/main.js +8 -1
  44. package/dist/main.js.map +1 -1
  45. package/dist/modes/interactive/components/custom-editor.d.ts +8 -0
  46. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  47. package/dist/modes/interactive/components/custom-editor.js +70 -1
  48. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  49. package/dist/modes/interactive/interactive-mode.d.ts +43 -0
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +1304 -24
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/docs/cli-reference.md +19 -1
  54. package/docs/configuration.md +5 -0
  55. package/docs/interactive-mode.md +131 -1
  56. package/package.json +1 -1
@@ -6,7 +6,7 @@ import * as crypto from "node:crypto";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
9
+ import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, truncateToWidth, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
10
10
  import { spawn, spawnSync } from "child_process";
11
11
  import { APP_NAME, CHANGELOG_URL, ENV_SESSION_TRACE, ENV_OFFLINE, ENV_SKIP_VERSION_CHECK, getAgentDir, getAuthPath, getDebugLogPath, getModelsPath, getSessionTracePath, getShareViewerUrl, getUpdateInstruction, isSessionTraceEnabled, PACKAGE_NAME, VERSION, } from "../../config.js";
12
12
  import { AuthStorage } from "../../core/auth-storage.js";
@@ -19,7 +19,9 @@ import { ModelRegistry } from "../../core/model-registry.js";
19
19
  import { resolveModelScope } from "../../core/model-resolver.js";
20
20
  import { getMcpCommandHelp, parseMcpAddCommand, parseMcpTargetCommand, } from "../../core/mcp/index.js";
21
21
  import { addMemoryEntry, getMemoryFilePath, readMemoryEntries, removeMemoryEntry, updateMemoryEntry, } from "../../core/memory.js";
22
- import { getDefaultSemanticSearchConfig, getSemanticConfigPath, getSemanticIndexDir, isLikelyEmbeddingModelId, listOllamaLocalModels, listOpenRouterEmbeddingModels, loadMergedSemanticConfig, SemanticConfigMissingError, SemanticRebuildRequiredError, SemanticSearchRuntime, upsertScopedSemanticSearchConfig, } from "../../core/semantic/index.js";
22
+ import { ContractService, normalizeEngineeringContract, } from "../../core/contract.js";
23
+ import { SingularService, } from "../../core/singular.js";
24
+ import { getDefaultSemanticSearchConfig, getSemanticConfigPath, getSemanticIndexDir, isLikelyEmbeddingModelId, listOllamaLocalModels, listOpenRouterEmbeddingModels, loadMergedSemanticConfig, readScopedSemanticConfig, SemanticConfigMissingError, SemanticIndexRequiredError, SemanticRebuildRequiredError, SemanticSearchRuntime, upsertScopedSemanticSearchConfig, } from "../../core/semantic/index.js";
23
25
  import { DefaultResourceLoader } from "../../core/resource-loader.js";
24
26
  import { createAgentSession } from "../../core/sdk.js";
25
27
  import { createTeamRun, getTeamRun, listTeamRuns } from "../../core/agent-teams.js";
@@ -71,6 +73,94 @@ function isExpandable(obj) {
71
73
  }
72
74
  const IOSM_PROFILE_ONLY_COMMANDS = new Set(["iosm", "cycle-list", "cycle-plan", "cycle-status", "cycle-report"]);
73
75
  const CHECKPOINT_LABEL_PREFIX = "checkpoint:";
76
+ const INTERRUPT_ABORT_TIMEOUT_MS = 8_000;
77
+ const CONTRACT_FIELD_DEFINITIONS = [
78
+ { key: "goal", kind: "text", placeholder: "Ship X with measurable impact", help: "Single primary objective." },
79
+ {
80
+ key: "scope_include",
81
+ kind: "list",
82
+ placeholder: "auth/*",
83
+ help: "What is explicitly in scope.",
84
+ },
85
+ {
86
+ key: "scope_exclude",
87
+ kind: "list",
88
+ placeholder: "billing/*",
89
+ help: "What must remain untouched for this change.",
90
+ },
91
+ {
92
+ key: "constraints",
93
+ kind: "list",
94
+ placeholder: "no breaking API changes",
95
+ help: "Hard constraints that cannot be violated.",
96
+ },
97
+ {
98
+ key: "quality_gates",
99
+ kind: "list",
100
+ placeholder: "tests pass",
101
+ help: "Objective quality checks before merge.",
102
+ },
103
+ {
104
+ key: "definition_of_done",
105
+ kind: "list",
106
+ placeholder: "docs updated",
107
+ help: "Completion criteria.",
108
+ },
109
+ {
110
+ key: "assumptions",
111
+ kind: "list",
112
+ placeholder: "API v2 stays backward compatible",
113
+ help: "Assumptions the plan depends on.",
114
+ },
115
+ {
116
+ key: "non_goals",
117
+ kind: "list",
118
+ placeholder: "no redesign in this cycle",
119
+ help: "Intentional exclusions.",
120
+ },
121
+ {
122
+ key: "risks",
123
+ kind: "list",
124
+ placeholder: "migration may affect legacy clients",
125
+ help: "Known delivery risks.",
126
+ },
127
+ {
128
+ key: "deliverables",
129
+ kind: "list",
130
+ placeholder: "CLI command + tests + docs",
131
+ help: "Expected artifacts.",
132
+ },
133
+ {
134
+ key: "success_metrics",
135
+ kind: "list",
136
+ placeholder: "p95 latency < 250ms",
137
+ help: "Measurable target outcomes.",
138
+ },
139
+ {
140
+ key: "stakeholders",
141
+ kind: "list",
142
+ placeholder: "backend team, QA",
143
+ help: "Who should review/accept the result.",
144
+ },
145
+ {
146
+ key: "owner",
147
+ kind: "text",
148
+ placeholder: "team/platform",
149
+ help: "Owner accountable for delivery.",
150
+ },
151
+ {
152
+ key: "timebox",
153
+ kind: "text",
154
+ placeholder: "this sprint",
155
+ help: "Deadline or delivery window.",
156
+ },
157
+ {
158
+ key: "notes",
159
+ kind: "text",
160
+ placeholder: "additional context",
161
+ help: "Free-form context for this contract.",
162
+ },
163
+ ];
74
164
  const DOCTOR_CLI_TOOL_SPECS = [
75
165
  {
76
166
  tool: "rg",
@@ -265,11 +355,13 @@ export class InteractiveMode {
265
355
  this.pendingWorkingMessage = undefined;
266
356
  this.defaultWorkingMessage = "Working...";
267
357
  this.activeProfileName = "full";
358
+ this.profilePromptSuffix = undefined;
268
359
  this.permissionMode = "ask";
269
360
  this.permissionAllowRules = [];
270
361
  this.permissionDenyRules = [];
271
362
  this.permissionPromptLock = Promise.resolve();
272
363
  this.sessionAllowedToolSignatures = new Set();
364
+ this.singularLastEffectiveContract = {};
273
365
  this.lastSigintTime = 0;
274
366
  this.lastEscapeTime = 0;
275
367
  this.changelogMarkdown = undefined;
@@ -310,6 +402,7 @@ export class InteractiveMode {
310
402
  // IOSM automation state
311
403
  this.iosmAutomationRun = undefined;
312
404
  this.iosmVerificationSession = undefined;
405
+ this.singularAnalysisSession = undefined;
313
406
  // Extension UI state
314
407
  this.extensionSelector = undefined;
315
408
  this.extensionInput = undefined;
@@ -354,14 +447,19 @@ export class InteractiveMode {
354
447
  this.footerDataProvider = new FooterDataProvider();
355
448
  this.footer = new FooterComponent(session, this.footerDataProvider);
356
449
  this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
357
- this.activeProfileName = getAgentProfile(options.profile).name;
450
+ const profile = getAgentProfile(options.profile);
451
+ this.activeProfileName = profile.name;
452
+ this.profilePromptSuffix = profile.systemPromptAppend || undefined;
358
453
  this.mcpRuntime = options.mcpRuntime;
454
+ this.contractService = new ContractService({ cwd: this.sessionManager.getCwd() });
455
+ this.singularService = new SingularService({ cwd: this.sessionManager.getCwd() });
359
456
  // Apply plan mode and profile badges immediately if set
360
457
  if (options.planMode || this.activeProfileName === "plan") {
361
458
  this.footer.setPlanMode(true);
362
459
  }
363
460
  this.footer.setActiveProfile(this.activeProfileName);
364
461
  this.session.setIosmAutopilotEnabled(this.activeProfileName === "iosm");
462
+ this.syncRuntimePromptSuffix();
365
463
  this.permissionMode = this.settingsManager.getPermissionMode();
366
464
  this.permissionAllowRules = this.settingsManager.getPermissionAllowRules();
367
465
  this.permissionDenyRules = this.settingsManager.getPermissionDenyRules();
@@ -373,6 +471,44 @@ export class InteractiveMode {
373
471
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
374
472
  initTheme(this.settingsManager.getTheme(), true);
375
473
  }
474
+ syncRuntimePromptSuffix() {
475
+ let contractSuffix;
476
+ try {
477
+ contractSuffix = this.contractService.buildPromptContext();
478
+ }
479
+ catch {
480
+ contractSuffix = undefined;
481
+ }
482
+ const suffix = [this.profilePromptSuffix, contractSuffix]
483
+ .map((entry) => entry?.trim())
484
+ .filter((entry) => !!entry && entry.length > 0)
485
+ .join("\n\n");
486
+ this.session.setSystemPromptSuffix(suffix || undefined);
487
+ }
488
+ getContractStateSafe() {
489
+ try {
490
+ return this.contractService.getState();
491
+ }
492
+ catch (error) {
493
+ const message = error instanceof Error ? error.message : String(error);
494
+ this.showWarning(`Contract load failed: ${message}`);
495
+ return undefined;
496
+ }
497
+ }
498
+ getProfileToolNames(profileName) {
499
+ const profile = getAgentProfile(profileName);
500
+ const availableTools = new Set(this.session.getAllTools().map((tool) => tool.name));
501
+ const nextActiveTools = [...profile.tools];
502
+ if (availableTools.has("task"))
503
+ nextActiveTools.push("task");
504
+ if (availableTools.has("todo_write"))
505
+ nextActiveTools.push("todo_write");
506
+ if (availableTools.has("todo_read"))
507
+ nextActiveTools.push("todo_read");
508
+ if (availableTools.has("ask_user"))
509
+ nextActiveTools.push("ask_user");
510
+ return [...new Set(nextActiveTools)];
511
+ }
376
512
  getToolPermissionSignature(request) {
377
513
  const summary = request.summary.trim().replace(/\s+/g, " ");
378
514
  return `${request.toolName}:${summary}`;
@@ -834,6 +970,46 @@ export class InteractiveMode {
834
970
  }
835
971
  return null;
836
972
  }
973
+ getContractArgumentCompletions(prefix) {
974
+ const subcommands = ["ui", "show", "edit", "clear", "help"];
975
+ const scopeFlags = ["--scope"];
976
+ const scopeValues = ["project", "session"];
977
+ const hasTrailingSpace = /\\s$/.test(prefix);
978
+ const tokens = this.parseSlashArgs(prefix);
979
+ const first = tokens[0]?.toLowerCase();
980
+ if (!first || (tokens.length === 1 && !hasTrailingSpace)) {
981
+ const query = first ?? "";
982
+ return [...subcommands, ...scopeFlags]
983
+ .filter((item) => item.includes(query))
984
+ .map((item) => ({ value: item, label: item }));
985
+ }
986
+ const scopeIndex = tokens.findIndex((token) => token === "--scope");
987
+ if (scopeIndex >= 0) {
988
+ const currentValue = tokens[scopeIndex + 1];
989
+ if (!currentValue) {
990
+ return scopeValues.map((value) => ({ value, label: value }));
991
+ }
992
+ return scopeValues
993
+ .filter((value) => value.startsWith(currentValue))
994
+ .map((value) => ({ value, label: value }));
995
+ }
996
+ const query = hasTrailingSpace ? "" : (tokens[tokens.length - 1] ?? "");
997
+ if (query.startsWith("--")) {
998
+ return scopeFlags.filter((flag) => flag.includes(query)).map((flag) => ({ value: flag, label: flag }));
999
+ }
1000
+ return null;
1001
+ }
1002
+ getSingularArgumentCompletions(prefix) {
1003
+ const subcommands = ["help", "last"];
1004
+ const hasTrailingSpace = /\\s$/.test(prefix);
1005
+ const tokens = this.parseSlashArgs(prefix);
1006
+ const first = tokens[0]?.toLowerCase();
1007
+ if (!first || (tokens.length === 1 && !hasTrailingSpace)) {
1008
+ const query = first ?? "";
1009
+ return subcommands.filter((item) => item.includes(query)).map((item) => ({ value: item, label: item }));
1010
+ }
1011
+ return null;
1012
+ }
837
1013
  setupAutocomplete(fdPath) {
838
1014
  // Define commands for autocomplete
839
1015
  const builtinCommands = BUILTIN_SLASH_COMMANDS.filter((command) => this.activeProfileName === "iosm" || !IOSM_PROFILE_ONLY_COMMANDS.has(command.name));
@@ -879,6 +1055,14 @@ export class InteractiveMode {
879
1055
  if (semanticCommand) {
880
1056
  semanticCommand.getArgumentCompletions = (prefix) => this.getSemanticArgumentCompletions(prefix);
881
1057
  }
1058
+ const contractCommand = slashCommands.find((command) => command.name === "contract");
1059
+ if (contractCommand) {
1060
+ contractCommand.getArgumentCompletions = (prefix) => this.getContractArgumentCompletions(prefix);
1061
+ }
1062
+ const singularCommand = slashCommands.find((command) => command.name === "singular");
1063
+ if (singularCommand) {
1064
+ singularCommand.getArgumentCompletions = (prefix) => this.getSingularArgumentCompletions(prefix);
1065
+ }
882
1066
  // Convert prompt templates to SlashCommand format for autocomplete
883
1067
  const templateCommands = this.session.promptTemplates.map((cmd) => ({
884
1068
  name: cmd.name,
@@ -1132,7 +1316,7 @@ export class InteractiveMode {
1132
1316
  }
1133
1317
  // Commands and keys
1134
1318
  const commandsLine = `${pad}${theme.fg("dim", "cmds")} ` +
1135
- ["model", "login", "mcp", "semantic", "memory", "doctor", "new"]
1319
+ ["model", "login", "contract", "singular", "semantic", "memory", "new"]
1136
1320
  .map((c) => theme.fg("accent", `/${c}`))
1137
1321
  .join(theme.fg("dim", " "));
1138
1322
  const keysLine = `${pad}${theme.fg("dim", "keys")} ` +
@@ -1562,8 +1746,10 @@ export class InteractiveMode {
1562
1746
  const names = extensionPaths.map((p) => theme.fg("dim", this.formatDisplayPath(p)));
1563
1747
  lines.push(` ${theme.fg("muted", "ext")} ${names.join(theme.fg("dim", ", "))}`);
1564
1748
  }
1749
+ const resourceWidth = Math.max(24, this.ui?.terminal?.columns ?? 120);
1750
+ const safeLines = lines.map((line) => visibleWidth(line) > resourceWidth ? truncateToWidth(line, resourceWidth, "") : line);
1565
1751
  this.chatContainer.addChild(new Spacer(1));
1566
- this.chatContainer.addChild(new Text(lines.join("\n"), 0, 0));
1752
+ this.chatContainer.addChild(new Text(safeLines.join("\n"), 0, 0));
1567
1753
  }
1568
1754
  }
1569
1755
  if (showDiagnostics) {
@@ -2287,6 +2473,7 @@ export class InteractiveMode {
2287
2473
  this.session.isRetrying ||
2288
2474
  this.iosmAutomationRun !== undefined ||
2289
2475
  this.iosmVerificationSession !== undefined ||
2476
+ this.singularAnalysisSession !== undefined ||
2290
2477
  queuedMessages.steering.length > 0 ||
2291
2478
  queuedMessages.followUp.length > 0 ||
2292
2479
  queuedMeta.length > 0;
@@ -2454,6 +2641,16 @@ export class InteractiveMode {
2454
2641
  await this.handleSemanticCommand(text);
2455
2642
  return;
2456
2643
  }
2644
+ if (text === "/contract" || text.startsWith("/contract ")) {
2645
+ this.editor.setText("");
2646
+ await this.handleContractCommand(text);
2647
+ return;
2648
+ }
2649
+ if (text === "/singular" || text.startsWith("/singular ")) {
2650
+ this.editor.setText("");
2651
+ await this.handleSingularCommand(text);
2652
+ return;
2653
+ }
2457
2654
  if (text === "/settings") {
2458
2655
  this.showSettingsSelector();
2459
2656
  this.editor.setText("");
@@ -3562,19 +3759,11 @@ export class InteractiveMode {
3562
3759
  }
3563
3760
  applyProfile(profileName) {
3564
3761
  const profile = getAgentProfile(profileName);
3565
- const availableTools = new Set(this.session.getAllTools().map((tool) => tool.name));
3566
- const nextActiveTools = [...profile.tools];
3567
- if (availableTools.has("task"))
3568
- nextActiveTools.push("task");
3569
- if (availableTools.has("todo_write"))
3570
- nextActiveTools.push("todo_write");
3571
- if (availableTools.has("todo_read"))
3572
- nextActiveTools.push("todo_read");
3573
- if (availableTools.has("ask_user"))
3574
- nextActiveTools.push("ask_user");
3575
- this.session.setActiveToolsByName([...new Set(nextActiveTools)]);
3762
+ const nextActiveTools = this.getProfileToolNames(profile.name);
3763
+ this.session.setActiveToolsByName(nextActiveTools);
3576
3764
  this.session.setThinkingLevel(profile.thinkingLevel);
3577
- this.session.setSystemPromptSuffix(profile.systemPromptAppend || undefined);
3765
+ this.profilePromptSuffix = profile.systemPromptAppend || undefined;
3766
+ this.syncRuntimePromptSuffix();
3578
3767
  this.session.setIosmAutopilotEnabled(profile.name === "iosm");
3579
3768
  this.activeProfileName = profile.name;
3580
3769
  this.footer.setActiveProfile(profile.name);
@@ -3819,6 +4008,8 @@ export class InteractiveMode {
3819
4008
  const hasAutomationWork = this.iosmAutomationRun !== undefined;
3820
4009
  const verificationSession = this.iosmVerificationSession;
3821
4010
  const hasVerificationWork = verificationSession !== undefined;
4011
+ const singularSession = this.singularAnalysisSession;
4012
+ const hasSingularWork = singularSession !== undefined;
3822
4013
  const hasMainStreaming = this.session.isStreaming;
3823
4014
  const hasRetryWork = this.session.isRetrying;
3824
4015
  const hasCompactionWork = this.session.isCompacting;
@@ -3826,6 +4017,7 @@ export class InteractiveMode {
3826
4017
  if (!hasPendingQueuedMessages &&
3827
4018
  !hasAutomationWork &&
3828
4019
  !hasVerificationWork &&
4020
+ !hasSingularWork &&
3829
4021
  !hasMainStreaming &&
3830
4022
  !hasRetryWork &&
3831
4023
  !hasCompactionWork &&
@@ -3858,7 +4050,9 @@ export class InteractiveMode {
3858
4050
  ? "Stopping IOSM automation..."
3859
4051
  : hasVerificationWork
3860
4052
  ? "Stopping IOSM verification..."
3861
- : "Stopping current run...");
4053
+ : hasSingularWork
4054
+ ? "Stopping /singular analysis..."
4055
+ : "Stopping current run...");
3862
4056
  const abortPromises = [];
3863
4057
  if (hasMainStreaming) {
3864
4058
  abortPromises.push(this.session.abort());
@@ -3866,8 +4060,30 @@ export class InteractiveMode {
3866
4060
  if (verificationSession) {
3867
4061
  abortPromises.push(verificationSession.abort());
3868
4062
  }
4063
+ if (singularSession) {
4064
+ abortPromises.push(singularSession.abort());
4065
+ }
3869
4066
  if (abortPromises.length > 0) {
3870
- await Promise.allSettled(abortPromises);
4067
+ const settleWithTimeout = (promise) => new Promise((resolve) => {
4068
+ let finished = false;
4069
+ const timeout = setTimeout(() => {
4070
+ if (finished)
4071
+ return;
4072
+ finished = true;
4073
+ resolve("timeout");
4074
+ }, INTERRUPT_ABORT_TIMEOUT_MS);
4075
+ promise.finally(() => {
4076
+ if (finished)
4077
+ return;
4078
+ finished = true;
4079
+ clearTimeout(timeout);
4080
+ resolve("done");
4081
+ });
4082
+ });
4083
+ const settled = await Promise.all(abortPromises.map((promise) => settleWithTimeout(promise)));
4084
+ if (settled.includes("timeout")) {
4085
+ this.showWarning(`Abort is taking longer than ${Math.round(INTERRUPT_ABORT_TIMEOUT_MS / 1000)}s. Try interrupt again if the run is still active.`);
4086
+ }
3871
4087
  }
3872
4088
  return true;
3873
4089
  }
@@ -5018,6 +5234,7 @@ export class InteractiveMode {
5018
5234
  });
5019
5235
  }
5020
5236
  async handleResumeSession(sessionPath) {
5237
+ this.contractService.clear("session");
5021
5238
  // Stop loading animation
5022
5239
  if (this.loadingAnimation) {
5023
5240
  this.loadingAnimation.stop();
@@ -5035,6 +5252,7 @@ export class InteractiveMode {
5035
5252
  // Clear and re-render the chat
5036
5253
  this.chatContainer.clear();
5037
5254
  this.renderInitialMessages();
5255
+ this.syncRuntimePromptSuffix();
5038
5256
  this.refreshBuiltInHeader();
5039
5257
  this.showStatus("Resumed session");
5040
5258
  }
@@ -5579,7 +5797,7 @@ export class InteractiveMode {
5579
5797
  authStorage: this.session.modelRegistry.authStorage,
5580
5798
  });
5581
5799
  }
5582
- parseSemanticScopeOptions(args) {
5800
+ parseSemanticScopeOptions(args, usage = "Usage: /semantic setup --scope <user|project>") {
5583
5801
  let scope;
5584
5802
  const rest = [];
5585
5803
  for (let index = 0; index < args.length; index++) {
@@ -5588,7 +5806,7 @@ export class InteractiveMode {
5588
5806
  if (normalized === "--scope") {
5589
5807
  const value = (args[index + 1] ?? "").toLowerCase();
5590
5808
  if (!value) {
5591
- return { scope, rest, error: "Usage: /semantic setup --scope <user|project>" };
5809
+ return { scope, rest, error: usage };
5592
5810
  }
5593
5811
  if (value !== "user" && value !== "project") {
5594
5812
  return { scope, rest, error: `Invalid semantic scope "${value}". Use user or project.` };
@@ -5628,6 +5846,7 @@ export class InteractiveMode {
5628
5846
  const lines = [
5629
5847
  `configured: ${status.configured ? "yes" : "no"}`,
5630
5848
  `enabled: ${status.enabled ? "yes" : "no"}`,
5849
+ `auto_index: ${status.autoIndex ? "on" : "off"}`,
5631
5850
  `indexed: ${status.indexed ? "yes" : "no"}`,
5632
5851
  `stale: ${status.stale ? `yes${status.staleReason ? ` (${status.staleReason})` : ""}` : "no"}`,
5633
5852
  ];
@@ -5912,10 +6131,42 @@ export class InteractiveMode {
5912
6131
  `scope: ${scope}`,
5913
6132
  `provider: ${this.getSemanticSetupProviderLabel(providerType)}`,
5914
6133
  `model: ${nextProvider.model}`,
6134
+ `auto_index: ${nextConfig.autoIndex ? "on" : "off"}`,
5915
6135
  `config: ${savedPath}`,
5916
6136
  `index_dir: ${getSemanticIndexDir(cwd, agentDir)}`,
5917
6137
  ].join("\n"));
5918
6138
  }
6139
+ resolveSemanticAutoIndexWriteScope(cwd, agentDir) {
6140
+ const project = readScopedSemanticConfig("project", cwd, agentDir);
6141
+ if (!project.error && project.file.semanticSearch) {
6142
+ return "project";
6143
+ }
6144
+ return "user";
6145
+ }
6146
+ async updateSemanticAutoIndexSetting(options) {
6147
+ const cwd = this.sessionManager.getCwd();
6148
+ const agentDir = getAgentDir();
6149
+ const merged = loadMergedSemanticConfig(cwd, agentDir);
6150
+ if (!merged.config) {
6151
+ this.showWarning("Semantic search is not configured. Run /semantic setup first.");
6152
+ return;
6153
+ }
6154
+ const scope = options?.scope ?? this.resolveSemanticAutoIndexWriteScope(cwd, agentDir);
6155
+ const value = options?.value ?? !merged.config.autoIndex;
6156
+ const nextConfig = {
6157
+ ...merged.config,
6158
+ autoIndex: value,
6159
+ };
6160
+ const savedPath = upsertScopedSemanticSearchConfig(scope, nextConfig, cwd, agentDir);
6161
+ const effective = await this.createSemanticRuntime().status().catch(() => undefined);
6162
+ this.showStatus(`Semantic auto-index ${value ? "enabled" : "disabled"} (${scope}).`);
6163
+ this.showCommandTextBlock("Semantic Auto-Index", [
6164
+ `scope: ${scope}`,
6165
+ `saved: ${value ? "on" : "off"}`,
6166
+ `effective: ${effective ? (effective.autoIndex ? "on" : "off") : "unknown"}`,
6167
+ `config: ${savedPath}`,
6168
+ ].join("\n"));
6169
+ }
5919
6170
  async runSemanticInteractiveMenu() {
5920
6171
  while (true) {
5921
6172
  let status;
@@ -5926,7 +6177,7 @@ export class InteractiveMode {
5926
6177
  this.reportSemanticError(error, "status");
5927
6178
  }
5928
6179
  const summary = status
5929
- ? `configured=${status.configured ? "yes" : "no"} indexed=${status.indexed ? "yes" : "no"} stale=${status.stale ? "yes" : "no"}`
6180
+ ? `configured=${status.configured ? "yes" : "no"} auto_index=${status.autoIndex ? "on" : "off"} indexed=${status.indexed ? "yes" : "no"} stale=${status.stale ? "yes" : "no"}`
5930
6181
  : "status unavailable";
5931
6182
  const selected = await this.showExtensionSelector(`/semantic manager\n${summary}`, [
5932
6183
  "Configure provider/model",
@@ -5934,6 +6185,7 @@ export class InteractiveMode {
5934
6185
  "Index now",
5935
6186
  "Rebuild index",
5936
6187
  "Query index",
6188
+ `Automatic indexing: ${status?.autoIndex ? "on" : "off"}`,
5937
6189
  "Show config/index paths",
5938
6190
  "Close",
5939
6191
  ]);
@@ -6007,6 +6259,10 @@ export class InteractiveMode {
6007
6259
  }
6008
6260
  continue;
6009
6261
  }
6262
+ if (selected.startsWith("Automatic indexing:")) {
6263
+ await this.updateSemanticAutoIndexSetting();
6264
+ continue;
6265
+ }
6010
6266
  if (selected === "Show config/index paths") {
6011
6267
  const runtime = this.createSemanticRuntime();
6012
6268
  const cwd = this.sessionManager.getCwd();
@@ -6036,6 +6292,11 @@ export class InteractiveMode {
6036
6292
  ].join("\n"));
6037
6293
  return;
6038
6294
  }
6295
+ if (error instanceof SemanticIndexRequiredError) {
6296
+ this.showWarning(error.message);
6297
+ this.showWarning("Run /semantic index (or enable automatic indexing in /semantic).");
6298
+ return;
6299
+ }
6039
6300
  if (error instanceof SemanticRebuildRequiredError) {
6040
6301
  this.showWarning(error.message);
6041
6302
  this.showWarning("Run /semantic rebuild.");
@@ -6058,6 +6319,7 @@ export class InteractiveMode {
6058
6319
  " /semantic",
6059
6320
  " /semantic ui",
6060
6321
  " /semantic setup [--scope user|project]",
6322
+ " /semantic auto-index [on|off] [--scope user|project]",
6061
6323
  " /semantic status",
6062
6324
  " /semantic index",
6063
6325
  " /semantic rebuild",
@@ -6079,6 +6341,34 @@ export class InteractiveMode {
6079
6341
  await this.runSemanticSetupWizard(parsedScope.scope);
6080
6342
  return;
6081
6343
  }
6344
+ if (subcommand === "auto-index" || subcommand === "autoindex") {
6345
+ const parsedScope = this.parseSemanticScopeOptions(rest, "Usage: /semantic auto-index [on|off] [--scope user|project]");
6346
+ if (parsedScope.error) {
6347
+ this.showWarning(parsedScope.error);
6348
+ return;
6349
+ }
6350
+ let value;
6351
+ if (parsedScope.rest.length > 1) {
6352
+ this.showWarning("Usage: /semantic auto-index [on|off] [--scope user|project]");
6353
+ return;
6354
+ }
6355
+ if (parsedScope.rest.length === 1) {
6356
+ const mode = parsedScope.rest[0]?.toLowerCase();
6357
+ if (mode === "on" || mode === "enable" || mode === "enabled")
6358
+ value = true;
6359
+ else if (mode === "off" || mode === "disable" || mode === "disabled")
6360
+ value = false;
6361
+ else {
6362
+ this.showWarning(`Unknown auto-index mode "${parsedScope.rest[0]}". Use on|off.`);
6363
+ return;
6364
+ }
6365
+ }
6366
+ await this.updateSemanticAutoIndexSetting({
6367
+ scope: parsedScope.scope,
6368
+ value,
6369
+ });
6370
+ return;
6371
+ }
6082
6372
  if (subcommand === "status") {
6083
6373
  try {
6084
6374
  const result = await this.runWithExtensionLoader("Checking semantic index status...", async () => this.createSemanticRuntime().status());
@@ -6133,6 +6423,962 @@ export class InteractiveMode {
6133
6423
  }
6134
6424
  this.showWarning(`Unknown /semantic subcommand "${subcommand}". Use /semantic help.`);
6135
6425
  }
6426
+ parseContractScopeOptions(args, usage = "Usage: /contract <edit|clear> --scope <project|session>") {
6427
+ let scope;
6428
+ const rest = [];
6429
+ for (let index = 0; index < args.length; index++) {
6430
+ const token = args[index] ?? "";
6431
+ const normalized = token.toLowerCase();
6432
+ if (normalized === "--scope") {
6433
+ const value = (args[index + 1] ?? "").toLowerCase().trim();
6434
+ if (!value) {
6435
+ return { scope, rest, error: usage };
6436
+ }
6437
+ if (value !== "project" && value !== "session") {
6438
+ return { scope, rest, error: `Invalid contract scope "${value}". Use project or session.` };
6439
+ }
6440
+ scope = value;
6441
+ index += 1;
6442
+ continue;
6443
+ }
6444
+ rest.push(token);
6445
+ }
6446
+ return { scope, rest };
6447
+ }
6448
+ cloneContract(contract) {
6449
+ return normalizeEngineeringContract({
6450
+ ...(contract.goal ? { goal: contract.goal } : {}),
6451
+ ...(contract.scope_include ? { scope_include: [...contract.scope_include] } : {}),
6452
+ ...(contract.scope_exclude ? { scope_exclude: [...contract.scope_exclude] } : {}),
6453
+ ...(contract.constraints ? { constraints: [...contract.constraints] } : {}),
6454
+ ...(contract.quality_gates ? { quality_gates: [...contract.quality_gates] } : {}),
6455
+ ...(contract.definition_of_done ? { definition_of_done: [...contract.definition_of_done] } : {}),
6456
+ ...(contract.assumptions ? { assumptions: [...contract.assumptions] } : {}),
6457
+ ...(contract.non_goals ? { non_goals: [...contract.non_goals] } : {}),
6458
+ ...(contract.risks ? { risks: [...contract.risks] } : {}),
6459
+ ...(contract.deliverables ? { deliverables: [...contract.deliverables] } : {}),
6460
+ ...(contract.success_metrics ? { success_metrics: [...contract.success_metrics] } : {}),
6461
+ ...(contract.stakeholders ? { stakeholders: [...contract.stakeholders] } : {}),
6462
+ ...(contract.owner ? { owner: contract.owner } : {}),
6463
+ ...(contract.timebox ? { timebox: contract.timebox } : {}),
6464
+ ...(contract.notes ? { notes: contract.notes } : {}),
6465
+ });
6466
+ }
6467
+ formatContractSection(title, contract) {
6468
+ const payload = Object.keys(contract).length > 0 ? contract : {};
6469
+ return `${title}:\n${JSON.stringify(payload, null, 2)}`;
6470
+ }
6471
+ formatContractFieldPreview(field, value) {
6472
+ if (field.kind === "text") {
6473
+ if (typeof value !== "string" || value.trim().length === 0)
6474
+ return "(empty)";
6475
+ return value.trim();
6476
+ }
6477
+ if (!Array.isArray(value) || value.length === 0)
6478
+ return "(empty)";
6479
+ const values = value.filter((item) => typeof item === "string" && item.trim().length > 0);
6480
+ if (values.length === 0)
6481
+ return "(empty)";
6482
+ const preview = values.slice(0, 2).join("; ");
6483
+ return values.length > 2 ? `${preview} (+${values.length - 2})` : preview;
6484
+ }
6485
+ showContractState(state) {
6486
+ this.showCommandTextBlock("Contract", [
6487
+ `project_path: ${state.projectPath}${state.hasProjectFile ? "" : " (missing)"}`,
6488
+ "",
6489
+ this.formatContractSection("Project", state.project),
6490
+ "",
6491
+ this.formatContractSection("Session overlay", state.sessionOverlay),
6492
+ "",
6493
+ this.formatContractSection("Effective", state.effective),
6494
+ ].join("\n"));
6495
+ }
6496
+ async editContractFieldValue(scope, field, draft) {
6497
+ const payload = draft;
6498
+ const current = payload[field.key];
6499
+ if (field.kind === "text") {
6500
+ const entered = await this.showExtensionInput(`/contract ${scope}: ${field.key}\n${field.help}\nEnter empty value to clear.`, typeof current === "string" && current.trim().length > 0 ? current : field.placeholder);
6501
+ if (entered === undefined)
6502
+ return undefined;
6503
+ const nextPayload = { ...payload };
6504
+ const normalized = entered.trim();
6505
+ if (normalized.length === 0) {
6506
+ delete nextPayload[field.key];
6507
+ }
6508
+ else {
6509
+ nextPayload[field.key] = normalized;
6510
+ }
6511
+ return normalizeEngineeringContract(nextPayload);
6512
+ }
6513
+ const prefill = Array.isArray(current)
6514
+ ? current.filter((item) => typeof item === "string").join("\n")
6515
+ : "";
6516
+ const edited = await this.showExtensionEditor(`/contract ${scope}: ${field.key}\n${field.help}\nOne item per line. Empty value clears the field.`, prefill);
6517
+ if (edited === undefined)
6518
+ return undefined;
6519
+ const values = edited
6520
+ .split(/\r?\n/)
6521
+ .map((line) => line.trim())
6522
+ .filter((line) => line.length > 0);
6523
+ const nextPayload = { ...payload };
6524
+ if (values.length === 0) {
6525
+ delete nextPayload[field.key];
6526
+ }
6527
+ else {
6528
+ nextPayload[field.key] = values;
6529
+ }
6530
+ return normalizeEngineeringContract(nextPayload);
6531
+ }
6532
+ formatContractEditorSummary(scope, draft) {
6533
+ const setCount = CONTRACT_FIELD_DEFINITIONS.filter((field) => {
6534
+ const value = draft[field.key];
6535
+ if (field.kind === "text")
6536
+ return typeof value === "string" && value.trim().length > 0;
6537
+ return Array.isArray(value) && value.length > 0;
6538
+ }).length;
6539
+ return `/contract editor (${scope})\nfilled=${setCount}/${CONTRACT_FIELD_DEFINITIONS.length}`;
6540
+ }
6541
+ async editContractScope(scope) {
6542
+ const state = this.getContractStateSafe();
6543
+ if (!state)
6544
+ return;
6545
+ let draft = this.cloneContract(scope === "project" ? state.project : state.sessionOverlay);
6546
+ while (true) {
6547
+ const fieldOptions = CONTRACT_FIELD_DEFINITIONS.map((field) => {
6548
+ const value = draft[field.key];
6549
+ return {
6550
+ field,
6551
+ label: `Edit ${field.key}: ${this.formatContractFieldPreview(field, value)}`,
6552
+ };
6553
+ });
6554
+ const selected = await this.showExtensionSelector(`${this.formatContractEditorSummary(scope, draft)}\nHow to use: select a field and press Enter to edit. Changes are auto-saved immediately.`, [
6555
+ ...fieldOptions.map((entry) => entry.label),
6556
+ "Open JSON preview",
6557
+ "Delete scope contract",
6558
+ "Cancel",
6559
+ ]);
6560
+ if (!selected || selected.startsWith("Cancel")) {
6561
+ this.showStatus("Contract edit cancelled.");
6562
+ return;
6563
+ }
6564
+ if (selected.startsWith("Open JSON preview")) {
6565
+ this.showCommandTextBlock(`Contract Draft (${scope})`, JSON.stringify(Object.keys(draft).length > 0 ? draft : {}, null, 2));
6566
+ continue;
6567
+ }
6568
+ if (selected.startsWith("Delete scope contract")) {
6569
+ if (scope === "project") {
6570
+ const confirm = await this.showExtensionConfirm("Delete project contract?", `${state.projectPath}\nThis removes .iosm/contract.json`);
6571
+ if (!confirm) {
6572
+ this.showStatus("Project contract delete cancelled.");
6573
+ continue;
6574
+ }
6575
+ }
6576
+ this.contractService.clear(scope);
6577
+ this.syncRuntimePromptSuffix();
6578
+ this.showStatus(`Contract cleared (${scope}).`);
6579
+ return;
6580
+ }
6581
+ const fieldEntry = fieldOptions.find((entry) => entry.label === selected);
6582
+ const field = fieldEntry?.field;
6583
+ if (!field) {
6584
+ this.showWarning("Unknown contract field selection.");
6585
+ continue;
6586
+ }
6587
+ const updated = await this.editContractFieldValue(scope, field, draft);
6588
+ if (!updated) {
6589
+ this.showStatus(`Field edit cancelled (${field.key}).`);
6590
+ continue;
6591
+ }
6592
+ draft = updated;
6593
+ try {
6594
+ this.contractService.save(scope, draft);
6595
+ this.syncRuntimePromptSuffix();
6596
+ this.showStatus(`Saved ${field.key} (${scope}).`);
6597
+ }
6598
+ catch (error) {
6599
+ this.showWarning(error instanceof Error ? error.message : String(error));
6600
+ }
6601
+ }
6602
+ }
6603
+ async runContractInteractiveMenu() {
6604
+ while (true) {
6605
+ const state = this.getContractStateSafe();
6606
+ if (!state)
6607
+ return;
6608
+ const selected = await this.showExtensionSelector([
6609
+ `/contract manager`,
6610
+ `project=${state.hasProjectFile ? "yes" : "no"} session_keys=${Object.keys(state.sessionOverlay).length} effective_keys=${Object.keys(state.effective).length}`,
6611
+ `How to use: open effective to inspect merged JSON, edit session for temporary changes, edit project for persistent changes.`,
6612
+ `Field edits are auto-saved right after Enter.`,
6613
+ ].join("\n"), [
6614
+ "Open effective contract",
6615
+ "Edit session contract",
6616
+ "Edit project contract",
6617
+ "Copy effective -> session",
6618
+ "Copy effective -> project",
6619
+ "Delete session contract",
6620
+ "Delete project contract",
6621
+ "Close",
6622
+ ]);
6623
+ if (!selected || selected.startsWith("Close")) {
6624
+ return;
6625
+ }
6626
+ if (selected === "Open effective contract") {
6627
+ this.showContractState(state);
6628
+ continue;
6629
+ }
6630
+ if (selected === "Edit session contract") {
6631
+ await this.editContractScope("session");
6632
+ continue;
6633
+ }
6634
+ if (selected === "Edit project contract") {
6635
+ await this.editContractScope("project");
6636
+ continue;
6637
+ }
6638
+ if (selected === "Copy effective -> session") {
6639
+ try {
6640
+ this.contractService.save("session", state.effective);
6641
+ this.syncRuntimePromptSuffix();
6642
+ this.showStatus("Effective contract copied to session overlay.");
6643
+ }
6644
+ catch (error) {
6645
+ this.showWarning(error instanceof Error ? error.message : String(error));
6646
+ }
6647
+ continue;
6648
+ }
6649
+ if (selected === "Copy effective -> project") {
6650
+ try {
6651
+ this.contractService.save("project", state.effective);
6652
+ this.syncRuntimePromptSuffix();
6653
+ this.showStatus("Effective contract copied to project.");
6654
+ }
6655
+ catch (error) {
6656
+ this.showWarning(error instanceof Error ? error.message : String(error));
6657
+ }
6658
+ continue;
6659
+ }
6660
+ if (selected === "Delete session contract") {
6661
+ this.contractService.clear("session");
6662
+ this.syncRuntimePromptSuffix();
6663
+ this.showStatus("Session contract deleted.");
6664
+ continue;
6665
+ }
6666
+ if (selected === "Delete project contract") {
6667
+ const confirm = await this.showExtensionConfirm("Delete project contract?", `${state.projectPath}\nThis removes .iosm/contract.json`);
6668
+ if (!confirm) {
6669
+ this.showStatus("Project contract delete cancelled.");
6670
+ continue;
6671
+ }
6672
+ this.contractService.clear("project");
6673
+ this.syncRuntimePromptSuffix();
6674
+ this.showStatus("Project contract deleted.");
6675
+ }
6676
+ }
6677
+ }
6678
+ async handleContractCommand(text) {
6679
+ const args = this.parseSlashArgs(text).slice(1);
6680
+ if (args.length === 0 || (args[0]?.toLowerCase() ?? "") === "ui") {
6681
+ await this.runContractInteractiveMenu();
6682
+ return;
6683
+ }
6684
+ const subcommand = (args[0] ?? "").toLowerCase();
6685
+ const rest = args.slice(1);
6686
+ if (subcommand === "help" || subcommand === "-h" || subcommand === "--help") {
6687
+ this.showCommandTextBlock("Contract Help", [
6688
+ "Usage:",
6689
+ " /contract",
6690
+ " /contract ui",
6691
+ " /contract show",
6692
+ " /contract edit --scope <project|session>",
6693
+ " /contract clear --scope <project|session>",
6694
+ " /contract help",
6695
+ "",
6696
+ "Editor model:",
6697
+ " - Fill fields interactively (goal, scope, constraints, quality gates, DoD, risks, etc.)",
6698
+ " - Each field is saved immediately after Enter (no extra Save step)",
6699
+ ].join("\n"));
6700
+ return;
6701
+ }
6702
+ if (subcommand === "show" || subcommand === "status" || subcommand === "open") {
6703
+ const state = this.getContractStateSafe();
6704
+ if (!state)
6705
+ return;
6706
+ this.showContractState(state);
6707
+ return;
6708
+ }
6709
+ if (subcommand === "edit") {
6710
+ const parsed = this.parseContractScopeOptions(rest, "Usage: /contract edit --scope <project|session>");
6711
+ if (parsed.error) {
6712
+ this.showWarning(parsed.error);
6713
+ return;
6714
+ }
6715
+ if (parsed.rest.length > 0) {
6716
+ this.showWarning(`Unexpected arguments for /contract edit: ${parsed.rest.join(" ")}`);
6717
+ return;
6718
+ }
6719
+ await this.editContractScope(parsed.scope ?? "session");
6720
+ return;
6721
+ }
6722
+ if (subcommand === "clear" || subcommand === "rm" || subcommand === "remove" || subcommand === "delete") {
6723
+ const parsed = this.parseContractScopeOptions(rest, "Usage: /contract clear --scope <project|session>");
6724
+ if (parsed.error) {
6725
+ this.showWarning(parsed.error);
6726
+ return;
6727
+ }
6728
+ if (!parsed.scope) {
6729
+ this.showWarning("Usage: /contract clear --scope <project|session>");
6730
+ return;
6731
+ }
6732
+ if (parsed.rest.length > 0) {
6733
+ this.showWarning(`Unexpected arguments for /contract clear: ${parsed.rest.join(" ")}`);
6734
+ return;
6735
+ }
6736
+ this.contractService.clear(parsed.scope);
6737
+ this.syncRuntimePromptSuffix();
6738
+ this.showStatus(`Contract cleared (${parsed.scope}).`);
6739
+ return;
6740
+ }
6741
+ this.showWarning(`Unknown /contract subcommand "${subcommand}". Use /contract help.`);
6742
+ }
6743
+ normalizeSingularComplexity(value, fallback) {
6744
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
6745
+ if (normalized === "low" || normalized === "medium" || normalized === "high") {
6746
+ return normalized;
6747
+ }
6748
+ return fallback;
6749
+ }
6750
+ normalizeSingularBlastRadius(value, fallback) {
6751
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
6752
+ if (normalized === "low" || normalized === "medium" || normalized === "high") {
6753
+ return normalized;
6754
+ }
6755
+ return fallback;
6756
+ }
6757
+ normalizeSingularRecommendation(value, fallback) {
6758
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
6759
+ if (normalized === "implement_now" || normalized === "implement_incrementally" || normalized === "defer") {
6760
+ return normalized;
6761
+ }
6762
+ return fallback;
6763
+ }
6764
+ normalizeSingularStageFit(value) {
6765
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
6766
+ if (normalized === "needed_now" || normalized === "optional_now" || normalized === "later") {
6767
+ return normalized;
6768
+ }
6769
+ if (normalized === "now")
6770
+ return "needed_now";
6771
+ if (normalized === "optional")
6772
+ return "optional_now";
6773
+ return undefined;
6774
+ }
6775
+ toTrimmedString(value, maxLength, fallback) {
6776
+ if (typeof value !== "string")
6777
+ return fallback;
6778
+ const compact = value.replace(/\s+/g, " ").trim();
6779
+ if (!compact)
6780
+ return fallback;
6781
+ if (compact.length <= maxLength)
6782
+ return compact;
6783
+ return compact.slice(0, maxLength).trim();
6784
+ }
6785
+ toTrimmedStringList(value, maxItems, maxLength = 220) {
6786
+ if (!Array.isArray(value))
6787
+ return [];
6788
+ const lines = [];
6789
+ for (const item of value) {
6790
+ const normalized = this.toTrimmedString(item, maxLength);
6791
+ if (!normalized)
6792
+ continue;
6793
+ lines.push(normalized);
6794
+ if (lines.length >= maxItems)
6795
+ break;
6796
+ }
6797
+ return lines;
6798
+ }
6799
+ normalizeSingularImpactAnalysis(value, fallback) {
6800
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
6801
+ return fallback;
6802
+ }
6803
+ const payload = value;
6804
+ const codebase = this.toTrimmedString(payload.codebase, 260, fallback?.codebase ?? "Unknown.");
6805
+ const delivery = this.toTrimmedString(payload.delivery, 260, fallback?.delivery ?? "Unknown.");
6806
+ const risks = this.toTrimmedString(payload.risks, 260, fallback?.risks ?? "Unknown.");
6807
+ const operations = this.toTrimmedString(payload.operations, 260, fallback?.operations ?? "Unknown.");
6808
+ if (!codebase || !delivery || !risks || !operations)
6809
+ return fallback;
6810
+ return {
6811
+ codebase,
6812
+ delivery,
6813
+ risks,
6814
+ operations,
6815
+ };
6816
+ }
6817
+ buildSingularContractPromptSection(contract) {
6818
+ const lines = [];
6819
+ if (contract.goal)
6820
+ lines.push(`- goal: ${contract.goal}`);
6821
+ if ((contract.scope_include ?? []).length > 0) {
6822
+ lines.push(`- scope_include: ${(contract.scope_include ?? []).slice(0, 8).join("; ")}`);
6823
+ }
6824
+ if ((contract.scope_exclude ?? []).length > 0) {
6825
+ lines.push(`- scope_exclude: ${(contract.scope_exclude ?? []).slice(0, 8).join("; ")}`);
6826
+ }
6827
+ if ((contract.constraints ?? []).length > 0) {
6828
+ lines.push(`- constraints: ${(contract.constraints ?? []).slice(0, 10).join("; ")}`);
6829
+ }
6830
+ if ((contract.quality_gates ?? []).length > 0) {
6831
+ lines.push(`- quality_gates: ${(contract.quality_gates ?? []).slice(0, 10).join("; ")}`);
6832
+ }
6833
+ if ((contract.definition_of_done ?? []).length > 0) {
6834
+ lines.push(`- definition_of_done: ${(contract.definition_of_done ?? []).slice(0, 10).join("; ")}`);
6835
+ }
6836
+ if ((contract.non_goals ?? []).length > 0) {
6837
+ lines.push(`- non_goals: ${(contract.non_goals ?? []).slice(0, 8).join("; ")}`);
6838
+ }
6839
+ return lines.length > 0 ? lines.join("\n") : "- none";
6840
+ }
6841
+ buildSingularAgentPrompt(request, baseline, contract) {
6842
+ const filesHint = baseline.matchedFiles.length > 0
6843
+ ? baseline.matchedFiles.slice(0, 12).map((item) => `- ${item}`).join("\n")
6844
+ : "- no direct file matches found in heuristic pass";
6845
+ return [
6846
+ "You are running a feasibility pass for `/singular`.",
6847
+ "Task: analyze the codebase for this request and decide whether to implement now, incrementally, or defer.",
6848
+ "",
6849
+ "Hard requirements:",
6850
+ "- Inspect repository files with tools before final output (at least one tool call).",
6851
+ "- Return a human-readable markdown report (no JSON).",
6852
+ "- Include exactly three options (Option 1, Option 2, Option 3).",
6853
+ "- Each option must contain concrete file paths when possible.",
6854
+ "",
6855
+ "Use this exact template:",
6856
+ "# Singular Feasibility",
6857
+ "Recommendation: implement_now|implement_incrementally|defer",
6858
+ "Reason: <one concise reason>",
6859
+ "Complexity: low|medium|high",
6860
+ "Blast Radius: low|medium|high",
6861
+ "Stage Fit: needed_now|optional_now|later",
6862
+ "Stage Fit Reason: <why this stage fit>",
6863
+ "Impact - Codebase: <impact>",
6864
+ "Impact - Delivery: <impact>",
6865
+ "Impact - Risks: <impact>",
6866
+ "Impact - Operations: <impact>",
6867
+ "",
6868
+ "## Option 1: <title>",
6869
+ "Summary: <summary>",
6870
+ "Complexity: low|medium|high",
6871
+ "Blast Radius: low|medium|high",
6872
+ "When to choose: <guidance>",
6873
+ "Suggested files:",
6874
+ "- <path>",
6875
+ "Plan:",
6876
+ "1. <step>",
6877
+ "Pros:",
6878
+ "- <pro>",
6879
+ "Cons:",
6880
+ "- <con>",
6881
+ "",
6882
+ "## Option 2: <title>",
6883
+ "... same fields ...",
6884
+ "",
6885
+ "## Option 3: <title>",
6886
+ "... same fields ...",
6887
+ "",
6888
+ `Feature request: ${request}`,
6889
+ "",
6890
+ "Baseline scan summary (heuristic pass):",
6891
+ `- scanned_files: ${baseline.scannedFiles}`,
6892
+ `- source_files: ${baseline.sourceFiles}`,
6893
+ `- test_files: ${baseline.testFiles}`,
6894
+ `- baseline_complexity: ${baseline.baselineComplexity}`,
6895
+ `- baseline_blast_radius: ${baseline.baselineBlastRadius}`,
6896
+ `- baseline_recommendation: ${baseline.recommendation}`,
6897
+ "",
6898
+ "Matched file hints from baseline (verify, do not assume blindly):",
6899
+ filesHint,
6900
+ "",
6901
+ "Active engineering contract:",
6902
+ this.buildSingularContractPromptSection(contract),
6903
+ ].join("\n");
6904
+ }
6905
+ extractLabeledValue(text, labels, maxLength = 320) {
6906
+ for (const label of labels) {
6907
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6908
+ const match = text.match(new RegExp(`^\\s*${escaped}\\s*:\\s*(.+?)\\s*$`, "im"));
6909
+ if (!match?.[1])
6910
+ continue;
6911
+ const normalized = this.toTrimmedString(match[1], maxLength);
6912
+ if (normalized)
6913
+ return normalized;
6914
+ }
6915
+ return undefined;
6916
+ }
6917
+ parseSingularListSection(block, heading, maxItems) {
6918
+ const headings = [
6919
+ "Summary",
6920
+ "Complexity",
6921
+ "Blast Radius",
6922
+ "When to choose",
6923
+ "Suggested files",
6924
+ "Plan",
6925
+ "Pros",
6926
+ "Cons",
6927
+ ];
6928
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6929
+ const nextHeadings = headings.filter((item) => item !== heading).map((item) => item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
6930
+ const sectionRegex = new RegExp(`(?:^|\\n)\\s*${escapedHeading}\\s*:\\s*([\\s\\S]*?)(?=\\n\\s*(?:${nextHeadings.join("|")})\\s*:|\\n\\s*##\\s*Option\\s*[123]\\s*:|$)`, "i");
6931
+ const sectionMatch = block.match(sectionRegex);
6932
+ if (!sectionMatch?.[1])
6933
+ return [];
6934
+ const result = [];
6935
+ const lines = sectionMatch[1].split(/\r?\n/);
6936
+ for (const rawLine of lines) {
6937
+ let line = rawLine.trim();
6938
+ if (!line)
6939
+ continue;
6940
+ line = line.replace(/^[-*]\s+/, "").replace(/^\d+[.)]\s+/, "").trim();
6941
+ if (!line)
6942
+ continue;
6943
+ result.push(line);
6944
+ if (result.length >= maxItems)
6945
+ break;
6946
+ }
6947
+ return result;
6948
+ }
6949
+ parseSingularOptionsFromText(text, baseline) {
6950
+ const optionRegex = /^##\s*Option\s*([123])\s*:\s*(.+?)\s*$/gim;
6951
+ const matches = [...text.matchAll(optionRegex)];
6952
+ const parsed = [];
6953
+ for (let index = 0; index < matches.length; index += 1) {
6954
+ const current = matches[index];
6955
+ const optionIndexRaw = Number.parseInt(current[1] ?? "", 10);
6956
+ const optionIndex = Number.isInteger(optionIndexRaw) ? Math.max(1, Math.min(3, optionIndexRaw)) - 1 : index;
6957
+ const fallback = baseline.options[optionIndex] ?? baseline.options[Math.min(index, baseline.options.length - 1)];
6958
+ if (!fallback)
6959
+ continue;
6960
+ const bodyStart = (current.index ?? 0) + current[0].length;
6961
+ const bodyEnd = index + 1 < matches.length ? (matches[index + 1].index ?? text.length) : text.length;
6962
+ const body = text.slice(bodyStart, bodyEnd);
6963
+ const title = this.toTrimmedString(current[2], 120, fallback.title) ?? fallback.title;
6964
+ const summary = this.extractLabeledValue(body, ["Summary"], 320) ?? fallback.summary;
6965
+ const complexity = this.normalizeSingularComplexity(this.extractLabeledValue(body, ["Complexity"], 24), fallback.complexity);
6966
+ const blastRadius = this.normalizeSingularBlastRadius(this.extractLabeledValue(body, ["Blast Radius", "Blast"], 24), fallback.blast_radius);
6967
+ const whenToChoose = this.extractLabeledValue(body, ["When to choose"], 220) ?? fallback.when_to_choose;
6968
+ const suggestedFiles = this.parseSingularListSection(body, "Suggested files", 8);
6969
+ const plan = this.parseSingularListSection(body, "Plan", 8);
6970
+ const pros = this.parseSingularListSection(body, "Pros", 6);
6971
+ const cons = this.parseSingularListSection(body, "Cons", 6);
6972
+ parsed.push({
6973
+ id: String(parsed.length + 1),
6974
+ title,
6975
+ summary,
6976
+ complexity,
6977
+ blast_radius: blastRadius,
6978
+ suggested_files: suggestedFiles.length > 0 ? suggestedFiles : fallback.suggested_files,
6979
+ plan: plan.length > 0 ? plan : fallback.plan,
6980
+ pros: pros.length > 0 ? pros : fallback.pros,
6981
+ cons: cons.length > 0 ? cons : fallback.cons,
6982
+ when_to_choose: whenToChoose,
6983
+ });
6984
+ }
6985
+ while (parsed.length < 3 && parsed.length < baseline.options.length) {
6986
+ const fallback = baseline.options[parsed.length];
6987
+ parsed.push({
6988
+ ...fallback,
6989
+ id: String(parsed.length + 1),
6990
+ });
6991
+ }
6992
+ return {
6993
+ options: parsed.slice(0, 3),
6994
+ parsedCount: matches.length,
6995
+ };
6996
+ }
6997
+ parseSingularAgentAnalysisFromText(baseline, rawText) {
6998
+ const recommendationRaw = this.extractLabeledValue(rawText, ["Recommendation"], 64);
6999
+ const recommendation = this.normalizeSingularRecommendation(recommendationRaw, baseline.recommendation);
7000
+ const recommendationReason = this.extractLabeledValue(rawText, ["Reason", "Recommendation Reason"], 320) ?? baseline.recommendationReason;
7001
+ const baselineComplexity = this.normalizeSingularComplexity(this.extractLabeledValue(rawText, ["Complexity"], 24), baseline.baselineComplexity);
7002
+ const baselineBlastRadius = this.normalizeSingularBlastRadius(this.extractLabeledValue(rawText, ["Blast Radius", "Blast"], 24), baseline.baselineBlastRadius);
7003
+ const stageFit = this.normalizeSingularStageFit(this.extractLabeledValue(rawText, ["Stage Fit"], 40)) ?? baseline.stageFit;
7004
+ const stageFitReason = this.extractLabeledValue(rawText, ["Stage Fit Reason"], 280) ?? baseline.stageFitReason;
7005
+ const impactAnalysis = {
7006
+ codebase: this.extractLabeledValue(rawText, ["Impact - Codebase", "Codebase Impact"], 260) ?? "Unknown.",
7007
+ delivery: this.extractLabeledValue(rawText, ["Impact - Delivery", "Delivery Impact"], 260) ?? "Unknown.",
7008
+ risks: this.extractLabeledValue(rawText, ["Impact - Risks", "Risk Impact"], 260) ?? "Unknown.",
7009
+ operations: this.extractLabeledValue(rawText, ["Impact - Operations", "Operations Impact"], 260) ?? "Unknown.",
7010
+ };
7011
+ const { options, parsedCount } = this.parseSingularOptionsFromText(rawText, baseline);
7012
+ return {
7013
+ result: {
7014
+ ...baseline,
7015
+ recommendation,
7016
+ recommendationReason,
7017
+ baselineComplexity,
7018
+ baselineBlastRadius,
7019
+ stageFit,
7020
+ stageFitReason,
7021
+ impactAnalysis,
7022
+ options,
7023
+ },
7024
+ parsedOptions: parsedCount,
7025
+ hasRecommendation: recommendationRaw !== undefined,
7026
+ };
7027
+ }
7028
+ async runSingularAgentFeasibilityPass(request, baseline, contract) {
7029
+ const model = this.session.model;
7030
+ if (!model) {
7031
+ return undefined;
7032
+ }
7033
+ const cwd = this.sessionManager.getCwd();
7034
+ const agentDir = getAgentDir();
7035
+ const settingsManager = SettingsManager.create(cwd, agentDir);
7036
+ const authStorage = AuthStorage.create(getAuthPath());
7037
+ const modelRegistry = new ModelRegistry(authStorage, getModelsPath());
7038
+ const resourceLoader = new DefaultResourceLoader({
7039
+ cwd,
7040
+ agentDir,
7041
+ settingsManager,
7042
+ noExtensions: true,
7043
+ noSkills: true,
7044
+ noPromptTemplates: true,
7045
+ });
7046
+ await resourceLoader.reload();
7047
+ const { session } = await createAgentSession({
7048
+ cwd,
7049
+ sessionManager: SessionManager.inMemory(),
7050
+ settingsManager,
7051
+ authStorage,
7052
+ modelRegistry,
7053
+ resourceLoader,
7054
+ model,
7055
+ profile: "plan",
7056
+ enableTaskTool: false,
7057
+ });
7058
+ let toolCallsStarted = 0;
7059
+ const chunks = [];
7060
+ const eventBridge = this.createIosmVerificationEventBridge({
7061
+ loaderMessage: `Running /singular feasibility analysis... (${appKey(this.keybindings, "interrupt")} to interrupt)`,
7062
+ });
7063
+ const unsubscribe = session.subscribe((event) => {
7064
+ eventBridge(event);
7065
+ if (event.type === "tool_execution_start") {
7066
+ toolCallsStarted += 1;
7067
+ return;
7068
+ }
7069
+ if (event.type === "message_end" && event.message.role === "assistant") {
7070
+ for (const part of event.message.content) {
7071
+ if (part.type === "text" && part.text.trim()) {
7072
+ chunks.push(part.text.trim());
7073
+ }
7074
+ }
7075
+ }
7076
+ });
7077
+ this.singularAnalysisSession = session;
7078
+ try {
7079
+ const primaryPrompt = this.buildSingularAgentPrompt(request, baseline, contract);
7080
+ const strictRetryPrompt = [
7081
+ "Retry strict mode:",
7082
+ "- Inspect repository files using tools first.",
7083
+ "- Return markdown report using the exact template from previous prompt.",
7084
+ "- Include Recommendation and Option 1/2/3 sections.",
7085
+ "- Do not return JSON.",
7086
+ ].join("\n");
7087
+ const runAttempt = async (promptText) => {
7088
+ const chunkStart = chunks.length;
7089
+ const toolStart = toolCallsStarted;
7090
+ await session.prompt(promptText, {
7091
+ expandPromptTemplates: false,
7092
+ skipIosmAutopilot: true,
7093
+ skipOrchestrationDirective: true,
7094
+ source: "interactive",
7095
+ });
7096
+ return {
7097
+ text: chunks.slice(chunkStart).join("\n\n").trim(),
7098
+ toolCalls: Math.max(0, toolCallsStarted - toolStart),
7099
+ };
7100
+ };
7101
+ let attempt = await runAttempt(primaryPrompt);
7102
+ let parsed = this.parseSingularAgentAnalysisFromText(baseline, attempt.text);
7103
+ if ((parsed.parsedOptions < 3 || !parsed.hasRecommendation || attempt.toolCalls === 0) && !session.isStreaming) {
7104
+ attempt = await runAttempt(strictRetryPrompt);
7105
+ parsed = this.parseSingularAgentAnalysisFromText(baseline, attempt.text);
7106
+ }
7107
+ if ((parsed.parsedOptions < 3 || !parsed.hasRecommendation) || toolCallsStarted === 0) {
7108
+ return undefined;
7109
+ }
7110
+ return parsed.result;
7111
+ }
7112
+ finally {
7113
+ if (session.isStreaming) {
7114
+ await session.abort().catch(() => {
7115
+ // best effort
7116
+ });
7117
+ }
7118
+ if (this.singularAnalysisSession === session) {
7119
+ this.singularAnalysisSession = undefined;
7120
+ }
7121
+ unsubscribe();
7122
+ session.dispose();
7123
+ }
7124
+ }
7125
+ formatSingularRunReport(result) {
7126
+ const recommendedId = this.resolveRecommendedSingularOptionId(result);
7127
+ const coverageLine = `${result.scannedFiles} scanned · ${result.sourceFiles} source · ${result.testFiles} tests`;
7128
+ const lines = [
7129
+ `run_id: ${result.runId}`,
7130
+ `request: ${result.request}`,
7131
+ `generated_at: ${result.generatedAt}`,
7132
+ "",
7133
+ "overview:",
7134
+ ` recommendation: ${result.recommendation}`,
7135
+ ` reason: ${result.recommendationReason}`,
7136
+ ` complexity: ${result.baselineComplexity}`,
7137
+ ` blast_radius: ${result.baselineBlastRadius}`,
7138
+ ` repository_coverage: ${coverageLine}`,
7139
+ ];
7140
+ if (result.stageFit) {
7141
+ lines.push(` stage_fit: ${result.stageFit}`);
7142
+ }
7143
+ if (result.stageFitReason) {
7144
+ lines.push(` stage_fit_reason: ${result.stageFitReason}`);
7145
+ }
7146
+ if (result.impactAnalysis) {
7147
+ lines.push("");
7148
+ lines.push("impact_analysis:");
7149
+ lines.push(` codebase: ${result.impactAnalysis.codebase}`);
7150
+ lines.push(` delivery: ${result.impactAnalysis.delivery}`);
7151
+ lines.push(` risks: ${result.impactAnalysis.risks}`);
7152
+ lines.push(` operations: ${result.impactAnalysis.operations}`);
7153
+ }
7154
+ if (result.matchedFiles.length > 0) {
7155
+ lines.push("");
7156
+ lines.push(`matched_files: ${result.matchedFiles.slice(0, 8).join(", ")}`);
7157
+ }
7158
+ if (result.contractSignals.length > 0) {
7159
+ lines.push(`contract_signals: ${result.contractSignals.join(", ")}`);
7160
+ }
7161
+ lines.push("");
7162
+ lines.push("implementation_options:");
7163
+ for (const option of result.options) {
7164
+ const recommendedMark = option.id === recommendedId ? " [recommended]" : "";
7165
+ lines.push(`${option.id}. ${option.title}${recommendedMark} [complexity=${option.complexity}, blast=${option.blast_radius}]`);
7166
+ lines.push(` ${option.summary}`);
7167
+ if (option.when_to_choose) {
7168
+ lines.push(` when_to_choose: ${option.when_to_choose}`);
7169
+ }
7170
+ if (option.suggested_files.length > 0) {
7171
+ lines.push(` files: ${option.suggested_files.slice(0, 8).join(", ")}`);
7172
+ }
7173
+ if (option.plan.length > 0) {
7174
+ lines.push(` first_step: ${option.plan[0]}`);
7175
+ }
7176
+ }
7177
+ lines.push("");
7178
+ lines.push("next_action:");
7179
+ lines.push(" choose option 1/2/3 to generate a detailed execution draft in editor");
7180
+ return lines.join("\n");
7181
+ }
7182
+ buildSingularExecutionDraft(result, option, contract) {
7183
+ const effectiveContract = contract ?? this.singularLastEffectiveContract ?? {};
7184
+ const defaultQualityGates = [
7185
+ "Targeted tests for changed flows pass.",
7186
+ "No regressions in adjacent user paths.",
7187
+ "Logs/metrics updated for the new behavior.",
7188
+ ];
7189
+ const defaultDoD = [
7190
+ "Core behavior implemented and manually validated.",
7191
+ "Automated coverage added for critical path.",
7192
+ "Documentation/changelog updated for user-visible changes.",
7193
+ ];
7194
+ const qualityGates = (effectiveContract.quality_gates ?? []).length > 0
7195
+ ? (effectiveContract.quality_gates ?? []).slice(0, 10)
7196
+ : defaultQualityGates;
7197
+ const definitionOfDone = (effectiveContract.definition_of_done ?? []).length > 0
7198
+ ? (effectiveContract.definition_of_done ?? []).slice(0, 10)
7199
+ : defaultDoD;
7200
+ const constraints = (effectiveContract.constraints ?? []).slice(0, 10);
7201
+ const scopeInclude = (effectiveContract.scope_include ?? []).slice(0, 10);
7202
+ const scopeExclude = (effectiveContract.scope_exclude ?? []).slice(0, 10);
7203
+ const risksFromOption = option.cons.slice(0, 6);
7204
+ const files = option.suggested_files.slice(0, 14);
7205
+ const planSteps = option.plan.length > 0 ? option.plan : ["Implement minimal working path for selected option."];
7206
+ const lines = [
7207
+ "# Singular Execution Plan",
7208
+ "",
7209
+ `Request: ${result.request}`,
7210
+ `Selected option: ${option.id}. ${option.title}`,
7211
+ `Decision context: recommendation=${result.recommendation}, complexity=${option.complexity}, blast_radius=${option.blast_radius}`,
7212
+ ...(result.stageFit ? [`Stage fit: ${result.stageFit}`] : []),
7213
+ ...(result.stageFitReason ? [`Stage fit reason: ${result.stageFitReason}`] : []),
7214
+ ...(option.when_to_choose ? [`When to choose: ${option.when_to_choose}`] : []),
7215
+ "",
7216
+ "## 1) Scope and Boundaries",
7217
+ "In scope:",
7218
+ ...(scopeInclude.length > 0 ? scopeInclude.map((item) => `- ${item}`) : ["- Deliver selected option with minimal blast radius."]),
7219
+ "Out of scope:",
7220
+ ...(scopeExclude.length > 0 ? scopeExclude.map((item) => `- ${item}`) : ["- Broad refactors outside touched modules."]),
7221
+ "Hard constraints:",
7222
+ ...(constraints.length > 0 ? constraints.map((item) => `- ${item}`) : ["- Keep backward compatibility for existing behavior."]),
7223
+ "",
7224
+ "## 2) Implementation Phases",
7225
+ "Phase A - Preparation",
7226
+ "1. Confirm acceptance criteria and edge cases for the selected option.",
7227
+ "2. Lock touched modules and define rollback strategy before coding.",
7228
+ "Phase B - Implementation",
7229
+ ...planSteps.map((step, index) => `${index + 1}. ${step}`),
7230
+ "Phase C - Hardening",
7231
+ "1. Run targeted regression checks on impacted flows.",
7232
+ "2. Address review findings and update docs if behavior changed.",
7233
+ "",
7234
+ "## 3) Priority Files",
7235
+ ];
7236
+ lines.push(...(files.length > 0 ? files.map((filePath) => `- ${filePath}`) : ["- Determine target files during code scan."]));
7237
+ lines.push("", "## 4) Validation and Quality Gates", "Functional checks:", "- Validate main user flow end-to-end.", "- Validate failure/edge path handling.", "Quality gates:", ...qualityGates.map((gate) => `- [ ] ${gate}`), "", "## 5) Risk Controls and Rollout", ...(result.impactAnalysis?.risks ? [`- Risk focus: ${result.impactAnalysis.risks}`] : ["- Risk focus: maintain safe rollout with quick rollback."]), ...(risksFromOption.length > 0 ? risksFromOption.map((risk) => `- [ ] Mitigate: ${risk}`) : ["- [ ] Track and mitigate newly discovered risks during implementation."]), "- [ ] Prepare rollback checkpoint before merge.", "- [ ] Use incremental rollout/feature-flag if blast radius is medium or high.", "", "## 6) Definition of Done", ...definitionOfDone.map((item) => `- [ ] ${item}`), "", "## 7) Delivery Notes", "- Keep commits scoped by phase (prep -> impl -> hardening).", "- Include tests and docs in the same delivery stream.");
7238
+ return lines.join("\n");
7239
+ }
7240
+ resolveRecommendedSingularOptionId(result) {
7241
+ if (result.recommendation === "defer") {
7242
+ return "3";
7243
+ }
7244
+ if (result.recommendation === "implement_incrementally") {
7245
+ const incremental = result.options.find((option) => /increment|mvp|phased/i.test(`${option.title} ${option.summary}`));
7246
+ return incremental?.id ?? "1";
7247
+ }
7248
+ const nonDefer = result.options.find((option) => !/defer|later|postpone/i.test(`${option.title} ${option.summary}`));
7249
+ return nonDefer?.id ?? "1";
7250
+ }
7251
+ async promptSingularDecision(result) {
7252
+ const recommendedId = this.resolveRecommendedSingularOptionId(result);
7253
+ const options = result.options.map((option) => {
7254
+ const recommendedSuffix = option.id === recommendedId ? " (Recommended)" : "";
7255
+ return `Option ${option.id}${recommendedSuffix}: ${option.title} [risk=${option.blast_radius}]`;
7256
+ });
7257
+ options.push("Close without decision");
7258
+ const selected = await this.showExtensionSelector("/singular: choose next step", options);
7259
+ if (!selected || selected === "Close without decision")
7260
+ return;
7261
+ const match = selected.match(/^Option\s+(\d+)/);
7262
+ if (!match)
7263
+ return;
7264
+ const picked = result.options.find((option) => option.id === match[1]);
7265
+ if (!picked)
7266
+ return;
7267
+ this.showCommandTextBlock("Singular Decision", [
7268
+ `selected: ${picked.id}. ${picked.title}`,
7269
+ `summary: ${picked.summary}`,
7270
+ `complexity: ${picked.complexity}`,
7271
+ `blast_radius: ${picked.blast_radius}`,
7272
+ ...(picked.when_to_choose ? [`when_to_choose: ${picked.when_to_choose}`] : []),
7273
+ "",
7274
+ "plan:",
7275
+ ...picked.plan.map((step, index) => `${index + 1}. ${step}`),
7276
+ "",
7277
+ "pros:",
7278
+ ...picked.pros.map((item) => `- ${item}`),
7279
+ "",
7280
+ "cons:",
7281
+ ...picked.cons.map((item) => `- ${item}`),
7282
+ ].join("\n"));
7283
+ if (picked.id === "3" || result.recommendation === "defer") {
7284
+ this.showStatus("Singular: defer option selected, implementation postponed.");
7285
+ return;
7286
+ }
7287
+ this.editor.setText(this.buildSingularExecutionDraft(result, picked, this.singularLastEffectiveContract));
7288
+ this.showStatus("Singular: detailed execution draft generated in editor.");
7289
+ }
7290
+ showSingularLastSummary() {
7291
+ const last = this.singularService.getLastRun();
7292
+ if (!last) {
7293
+ this.showWarning("No /singular analyses found yet.");
7294
+ return;
7295
+ }
7296
+ this.showCommandTextBlock("Singular Last Run", [
7297
+ `run_id: ${last.runId}`,
7298
+ `generated_at: ${last.generatedAt ?? "unknown"}`,
7299
+ `recommendation: ${last.recommendation ?? "unknown"}`,
7300
+ `request: ${last.request ?? "unknown"}`,
7301
+ `analysis_path: ${last.analysisPath}`,
7302
+ ...(last.metaPath ? [`meta_path: ${last.metaPath}`] : []),
7303
+ ].join("\n"));
7304
+ }
7305
+ async runSingularAnalysis(request) {
7306
+ let effectiveContract = {};
7307
+ try {
7308
+ effectiveContract = this.contractService.getState().effective;
7309
+ }
7310
+ catch (error) {
7311
+ const message = error instanceof Error ? error.message : String(error);
7312
+ this.showWarning(`Contract unavailable, continuing /singular without contract overlay: ${message}`);
7313
+ }
7314
+ this.singularLastEffectiveContract = effectiveContract;
7315
+ try {
7316
+ this.showStatus("Singular: preparing baseline scan...");
7317
+ const baseline = await this.singularService.analyze({
7318
+ request,
7319
+ autosave: false,
7320
+ contract: effectiveContract,
7321
+ });
7322
+ let result = baseline;
7323
+ if (!this.session.model) {
7324
+ this.showWarning("No model selected. /singular used heuristic analysis only. Use /model to enable agent feasibility pass.");
7325
+ }
7326
+ else {
7327
+ try {
7328
+ const enriched = await this.runSingularAgentFeasibilityPass(request, baseline, effectiveContract);
7329
+ if (enriched) {
7330
+ result = enriched;
7331
+ }
7332
+ else {
7333
+ this.showWarning("Agent feasibility pass returned incomplete output. Showing heuristic fallback.");
7334
+ }
7335
+ }
7336
+ catch (error) {
7337
+ const message = error instanceof Error ? error.message : String(error);
7338
+ this.showWarning(`Agent feasibility pass failed. Showing heuristic fallback: ${message}`);
7339
+ }
7340
+ }
7341
+ this.singularService.saveAnalysis(result);
7342
+ this.showStatus(`Singular analysis complete: ${result.runId}`);
7343
+ this.showCommandTextBlock("Singular Analysis", this.formatSingularRunReport(result));
7344
+ await this.promptSingularDecision(result);
7345
+ }
7346
+ catch (error) {
7347
+ const message = error instanceof Error ? error.message : String(error);
7348
+ this.showError(`Singular analysis failed: ${message}`);
7349
+ }
7350
+ }
7351
+ async handleSingularCommand(text) {
7352
+ const args = this.parseSlashArgs(text).slice(1);
7353
+ if (args.length === 0) {
7354
+ this.showWarning("Usage: /singular <feature request>");
7355
+ return;
7356
+ }
7357
+ const subcommand = (args[0] ?? "").toLowerCase();
7358
+ if (subcommand === "help" || subcommand === "-h" || subcommand === "--help") {
7359
+ this.showCommandTextBlock("Singular Help", [
7360
+ "Usage:",
7361
+ " /singular <feature request>",
7362
+ " /singular last",
7363
+ " /singular help",
7364
+ "",
7365
+ "Examples:",
7366
+ " /singular add account dashboard",
7367
+ " /singular introduce RBAC for API",
7368
+ ].join("\n"));
7369
+ return;
7370
+ }
7371
+ if (subcommand === "last" && args.length === 1) {
7372
+ this.showSingularLastSummary();
7373
+ return;
7374
+ }
7375
+ const request = args.join(" ").trim();
7376
+ if (!request) {
7377
+ this.showWarning("Usage: /singular <feature request>");
7378
+ return;
7379
+ }
7380
+ await this.runSingularAnalysis(request);
7381
+ }
6136
7382
  parseCheckpointNameFromLabel(label) {
6137
7383
  if (!label)
6138
7384
  return undefined;
@@ -6315,6 +7561,7 @@ export class InteractiveMode {
6315
7561
  check.level === "fail");
6316
7562
  const hasMcpIssues = checks.some((check) => check.label === "MCP servers" && check.level !== "ok");
6317
7563
  const hasSemanticIssues = checks.some((check) => check.label === "Semantic index" && check.level !== "ok");
7564
+ const hasContractIssues = checks.some((check) => check.label === "Contract state" && check.level !== "ok");
6318
7565
  const hasResourceIssues = checks.some((check) => check.label === "Resources" && check.level !== "ok");
6319
7566
  while (true) {
6320
7567
  const options = [];
@@ -6331,6 +7578,9 @@ export class InteractiveMode {
6331
7578
  if (hasSemanticIssues) {
6332
7579
  options.push("Open semantic manager");
6333
7580
  }
7581
+ if (hasContractIssues) {
7582
+ options.push("Open contract manager");
7583
+ }
6334
7584
  if (this.permissionMode === "yolo") {
6335
7585
  options.push("Set permissions mode to ask");
6336
7586
  }
@@ -6365,6 +7615,10 @@ export class InteractiveMode {
6365
7615
  await this.handleSemanticCommand("/semantic");
6366
7616
  return;
6367
7617
  }
7618
+ if (selected === "Open contract manager") {
7619
+ await this.handleContractCommand("/contract");
7620
+ return;
7621
+ }
6368
7622
  if (selected === "Set permissions mode to ask") {
6369
7623
  this.permissionMode = "ask";
6370
7624
  this.settingsManager.setPermissionMode("ask");
@@ -6381,6 +7635,8 @@ export class InteractiveMode {
6381
7635
  this.showCommandTextBlock("Runtime Paths", [
6382
7636
  `auth.json: ${getAuthPath()}`,
6383
7637
  `models.json: ${getModelsPath()}`,
7638
+ `contract(project): ${this.contractService.getProjectPath()}`,
7639
+ `singular(analyses): ${this.singularService.getAnalysesRoot()}`,
6384
7640
  `semantic(user): ${getSemanticConfigPath("user", cwd, agentDir)}`,
6385
7641
  `semantic(project): ${getSemanticConfigPath("project", cwd, agentDir)}`,
6386
7642
  `semantic(index): ${getSemanticIndexDir(cwd, agentDir)}`,
@@ -6419,6 +7675,14 @@ export class InteractiveMode {
6419
7675
  catch (error) {
6420
7676
  semanticStatusError = error instanceof Error ? error.message : String(error);
6421
7677
  }
7678
+ let contractState;
7679
+ let contractStateError;
7680
+ try {
7681
+ contractState = this.contractService.getState();
7682
+ }
7683
+ catch (error) {
7684
+ contractStateError = error instanceof Error ? error.message : String(error);
7685
+ }
6422
7686
  const checks = [];
6423
7687
  const addCheck = (level, label, detail, fix) => checks.push({ level, label, detail, fix });
6424
7688
  if (!model) {
@@ -6483,8 +7747,21 @@ export class InteractiveMode {
6483
7747
  addCheck("warn", "Semantic index", `Indexed but stale${semanticStatus.staleReason ? ` (${semanticStatus.staleReason})` : ""}`, requiresRebuild ? "Run /semantic rebuild." : "Run /semantic index.");
6484
7748
  }
6485
7749
  else {
6486
- addCheck("ok", "Semantic index", `${semanticStatus.provider}/${semanticStatus.model} · files=${semanticStatus.files} chunks=${semanticStatus.chunks}`);
7750
+ addCheck("ok", "Semantic index", `${semanticStatus.provider}/${semanticStatus.model} · auto_index=${semanticStatus.autoIndex ? "on" : "off"} · files=${semanticStatus.files} chunks=${semanticStatus.chunks}`);
7751
+ }
7752
+ if (contractStateError) {
7753
+ addCheck("fail", "Contract state", contractStateError, "Fix .iosm/contract.json or run /contract clear --scope project.");
7754
+ }
7755
+ else if (!contractState) {
7756
+ addCheck("warn", "Contract state", "Unavailable", "Run /contract show to inspect state.");
7757
+ }
7758
+ else if (!contractState.hasProjectFile && Object.keys(contractState.sessionOverlay).length === 0) {
7759
+ addCheck("warn", "Contract state", "No project/session contract active", "Run /contract to define constraints and quality gates.");
7760
+ }
7761
+ else {
7762
+ addCheck("ok", "Contract state", `project=${contractState.hasProjectFile ? "yes" : "no"} session_keys=${Object.keys(contractState.sessionOverlay).length} effective_keys=${Object.keys(contractState.effective).length}`);
6487
7763
  }
7764
+ addCheck("ok", "Singular analyzer", "Available via /singular <request>");
6488
7765
  if (missingCliTools.length > 0) {
6489
7766
  addCheck("warn", "CLI toolchain", `${cliToolStatuses.length - missingCliTools.length}/${cliToolStatuses.length} available (missing: ${missingCliTools.join(", ")})`, `Install missing CLI tools: ${missingCliTools.join(", ")}.`);
6490
7767
  }
@@ -6948,6 +8225,7 @@ export class InteractiveMode {
6948
8225
  }
6949
8226
  createIosmVerificationEventBridge(options) {
6950
8227
  const loaderMessage = options?.loaderMessage ?? `Verifying workspace... (${appKey(this.keybindings, "interrupt")} to interrupt)`;
8228
+ const hideAssistantText = options?.hideAssistantText === true;
6951
8229
  let verifyStreamingComponent;
6952
8230
  let verifyStreamingMessage;
6953
8231
  const verifyPendingTools = new Map();
@@ -6962,7 +8240,7 @@ export class InteractiveMode {
6962
8240
  this.statusContainer.addChild(this.loadingAnimation);
6963
8241
  break;
6964
8242
  case "message_start":
6965
- if (event.message.role === "assistant") {
8243
+ if (event.message.role === "assistant" && !hideAssistantText) {
6966
8244
  verifyStreamingComponent = new AssistantMessageComponent(undefined, false, this.getMarkdownThemeWithSettings());
6967
8245
  verifyStreamingMessage = event.message;
6968
8246
  this.chatContainer.addChild(verifyStreamingComponent);
@@ -9281,6 +10559,7 @@ The agent will automatically receive IOSM context on every turn.`;
9281
10559
  this.ui.requestRender();
9282
10560
  }
9283
10561
  async handleClearCommand() {
10562
+ this.contractService.clear("session");
9284
10563
  // Stop loading animation
9285
10564
  if (this.loadingAnimation) {
9286
10565
  this.loadingAnimation.stop();
@@ -9296,6 +10575,7 @@ The agent will automatically receive IOSM context on every turn.`;
9296
10575
  this.streamingComponent = undefined;
9297
10576
  this.streamingMessage = undefined;
9298
10577
  this.pendingTools.clear();
10578
+ this.syncRuntimePromptSuffix();
9299
10579
  this.chatContainer.addChild(new Spacer(1));
9300
10580
  this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
9301
10581
  this.refreshBuiltInHeader();