iranti 0.2.21 → 0.2.23

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 (62) hide show
  1. package/README.md +24 -23
  2. package/dist/scripts/codex-setup.js +11 -5
  3. package/dist/scripts/iranti-cli.js +855 -120
  4. package/dist/scripts/iranti-mcp.js +1 -1
  5. package/dist/src/api/middleware/auth.d.ts +13 -0
  6. package/dist/src/api/middleware/auth.d.ts.map +1 -1
  7. package/dist/src/api/middleware/auth.js.map +1 -1
  8. package/dist/src/api/middleware/authorization.d.ts.map +1 -1
  9. package/dist/src/api/middleware/authorization.js +6 -3
  10. package/dist/src/api/middleware/authorization.js.map +1 -1
  11. package/dist/src/api/middleware/validation.d.ts +109 -0
  12. package/dist/src/api/middleware/validation.d.ts.map +1 -1
  13. package/dist/src/api/middleware/validation.js +97 -5
  14. package/dist/src/api/middleware/validation.js.map +1 -1
  15. package/dist/src/api/routes/knowledge.d.ts.map +1 -1
  16. package/dist/src/api/routes/knowledge.js +2 -1
  17. package/dist/src/api/routes/knowledge.js.map +1 -1
  18. package/dist/src/api/routes/memory.d.ts.map +1 -1
  19. package/dist/src/api/routes/memory.js +74 -11
  20. package/dist/src/api/routes/memory.js.map +1 -1
  21. package/dist/src/api/server.js +32 -3
  22. package/dist/src/api/server.js.map +1 -1
  23. package/dist/src/attendant/AttendantInstance.d.ts +43 -0
  24. package/dist/src/attendant/AttendantInstance.d.ts.map +1 -1
  25. package/dist/src/attendant/AttendantInstance.js +87 -11
  26. package/dist/src/attendant/AttendantInstance.js.map +1 -1
  27. package/dist/src/attendant/index.d.ts +2 -1
  28. package/dist/src/attendant/index.d.ts.map +1 -1
  29. package/dist/src/attendant/index.js +4 -1
  30. package/dist/src/attendant/index.js.map +1 -1
  31. package/dist/src/lib/dockerCliParsing.d.ts +3 -0
  32. package/dist/src/lib/dockerCliParsing.d.ts.map +1 -0
  33. package/dist/src/lib/dockerCliParsing.js +23 -0
  34. package/dist/src/lib/dockerCliParsing.js.map +1 -0
  35. package/dist/src/lib/runtimeEnv.d.ts.map +1 -1
  36. package/dist/src/lib/runtimeEnv.js +27 -11
  37. package/dist/src/lib/runtimeEnv.js.map +1 -1
  38. package/dist/src/lib/runtimeLifecycle.d.ts +21 -0
  39. package/dist/src/lib/runtimeLifecycle.d.ts.map +1 -1
  40. package/dist/src/lib/runtimeLifecycle.js +120 -0
  41. package/dist/src/lib/runtimeLifecycle.js.map +1 -1
  42. package/dist/src/librarian/index.d.ts.map +1 -1
  43. package/dist/src/librarian/index.js +154 -116
  44. package/dist/src/librarian/index.js.map +1 -1
  45. package/dist/src/library/entity-resolution.d.ts.map +1 -1
  46. package/dist/src/library/entity-resolution.js +14 -4
  47. package/dist/src/library/entity-resolution.js.map +1 -1
  48. package/dist/src/library/locks.d.ts.map +1 -1
  49. package/dist/src/library/locks.js +35 -8
  50. package/dist/src/library/locks.js.map +1 -1
  51. package/dist/src/library/queries.d.ts +14 -20
  52. package/dist/src/library/queries.d.ts.map +1 -1
  53. package/dist/src/library/queries.js +90 -22
  54. package/dist/src/library/queries.js.map +1 -1
  55. package/dist/src/sdk/index.d.ts +39 -6
  56. package/dist/src/sdk/index.d.ts.map +1 -1
  57. package/dist/src/sdk/index.js +84 -13
  58. package/dist/src/sdk/index.js.map +1 -1
  59. package/dist/src/security/scopes.d.ts.map +1 -1
  60. package/dist/src/security/scopes.js +13 -1
  61. package/dist/src/security/scopes.js.map +1 -1
  62. package/package.json +2 -1
@@ -6,16 +6,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
- const https_1 = __importDefault(require("https"));
10
9
  const os_1 = __importDefault(require("os"));
11
10
  const path_1 = __importDefault(require("path"));
12
11
  const child_process_1 = require("child_process");
12
+ const https_1 = __importDefault(require("https"));
13
13
  const promises_2 = __importDefault(require("readline/promises"));
14
14
  const stream_1 = require("stream");
15
15
  const net_1 = __importDefault(require("net"));
16
16
  const client_1 = require("../src/library/client");
17
17
  const apiKeys_1 = require("../src/security/apiKeys");
18
18
  const escalationPaths_1 = require("../src/lib/escalationPaths");
19
+ const dockerCliParsing_1 = require("../src/lib/dockerCliParsing");
19
20
  const runtimeEnv_1 = require("../src/lib/runtimeEnv");
20
21
  const resolutionist_1 = require("../src/resolutionist");
21
22
  const chat_1 = require("../src/chat");
@@ -55,6 +56,31 @@ const ANSI = {
55
56
  };
56
57
  let CLI_DEBUG = process.argv.includes('--debug') || process.env.IRANTI_DEBUG === '1';
57
58
  let CLI_VERBOSE = CLI_DEBUG || process.argv.includes('--verbose') || process.env.IRANTI_VERBOSE === '1';
59
+ // H-7: Cleanup/rollback stack — LIFO handlers run on SIGINT/SIGTERM to undo partial multi-step operations
60
+ const _cleanupStack = [];
61
+ function pushCleanup(fn) {
62
+ _cleanupStack.push(fn);
63
+ }
64
+ function popCleanup() {
65
+ _cleanupStack.pop();
66
+ }
67
+ async function runCleanupStack() {
68
+ while (_cleanupStack.length > 0) {
69
+ const fn = _cleanupStack.pop();
70
+ try {
71
+ await fn();
72
+ }
73
+ catch (err) {
74
+ const msg = err instanceof Error ? err.message : String(err);
75
+ process.stderr.write(`[cleanup] Error during rollback: ${msg}\n`);
76
+ }
77
+ }
78
+ }
79
+ for (const sig of ['SIGINT', 'SIGTERM']) {
80
+ process.on(sig, () => {
81
+ void runCleanupStack().finally(() => process.exit(130));
82
+ });
83
+ }
58
84
  function useColor() {
59
85
  return Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
60
86
  }
@@ -173,20 +199,172 @@ function defaultInstallRoot(scope) {
173
199
  return path_1.default.join(os_1.default.homedir(), '.local', 'share', 'iranti');
174
200
  }
175
201
  function resolveInstallRoot(args, scope) {
202
+ return resolveInstallRootDetails(args, scope).root;
203
+ }
204
+ function walkAncestorPaths(startDir) {
205
+ const dirs = [];
206
+ let current = path_1.default.resolve(startDir);
207
+ while (true) {
208
+ dirs.push(current);
209
+ const parent = path_1.default.dirname(current);
210
+ if (parent === current)
211
+ break;
212
+ current = parent;
213
+ }
214
+ return dirs;
215
+ }
216
+ function findClosestAncestorFile(startDir, fileName) {
217
+ for (const dir of walkAncestorPaths(startDir)) {
218
+ const candidate = path_1.default.join(dir, fileName);
219
+ if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isFile()) {
220
+ return candidate;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+ function findClosestAncestorRuntimeRoot(startDir) {
226
+ for (const dir of walkAncestorPaths(startDir)) {
227
+ for (const runtimeDirName of ['.iranti-runtime', '.iranti']) {
228
+ const candidate = path_1.default.join(dir, runtimeDirName);
229
+ if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isDirectory()) {
230
+ return path_1.default.resolve(candidate);
231
+ }
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+ function resolveInstallRootDetails(args, scope) {
176
237
  const explicit = getFlag(args, 'root') ?? process.env.IRANTI_HOME;
177
- if (explicit)
178
- return path_1.default.resolve(explicit);
238
+ if (explicit) {
239
+ return {
240
+ root: path_1.default.resolve(explicit),
241
+ source: getFlag(args, 'root') ? 'flag' : 'env',
242
+ userRoot: defaultInstallRoot('user'),
243
+ systemRoot: defaultInstallRoot('system'),
244
+ installMetaPath: path_1.default.join(path_1.default.resolve(explicit), 'install.json'),
245
+ };
246
+ }
179
247
  const userRoot = defaultInstallRoot('user');
180
248
  const systemRoot = defaultInstallRoot('system');
181
249
  const userMeta = path_1.default.join(userRoot, 'install.json');
182
250
  const systemMeta = path_1.default.join(systemRoot, 'install.json');
183
- if (scope === 'system')
184
- return systemRoot;
185
- if (fs_1.default.existsSync(userMeta))
186
- return userRoot;
187
- if (fs_1.default.existsSync(systemMeta))
188
- return systemRoot;
189
- return userRoot;
251
+ const cwd = process.cwd();
252
+ const projectBindingFile = findClosestAncestorFile(cwd, '.env.iranti');
253
+ const localRuntimeRoot = findClosestAncestorRuntimeRoot(cwd);
254
+ if (scope === 'system') {
255
+ return {
256
+ root: systemRoot,
257
+ source: 'default-system',
258
+ userRoot,
259
+ systemRoot,
260
+ installMetaPath: systemMeta,
261
+ };
262
+ }
263
+ if (projectBindingFile && fs_1.default.existsSync(projectBindingFile)) {
264
+ try {
265
+ const raw = fs_1.default.readFileSync(projectBindingFile, 'utf-8');
266
+ const match = raw.match(/^\s*IRANTI_INSTANCE_ENV\s*=\s*(.+)\s*$/m);
267
+ const value = match?.[1]?.trim().replace(/^['"]|['"]$/g, '');
268
+ const boundRoot = inferRuntimeRootFromInstanceEnv(value);
269
+ if (boundRoot && fs_1.default.existsSync(boundRoot)) {
270
+ return {
271
+ root: boundRoot,
272
+ source: 'project-binding',
273
+ userRoot,
274
+ systemRoot,
275
+ installMetaPath: path_1.default.join(boundRoot, 'install.json'),
276
+ };
277
+ }
278
+ }
279
+ catch {
280
+ // Fall through to other resolution strategies.
281
+ }
282
+ }
283
+ if (localRuntimeRoot) {
284
+ return {
285
+ root: localRuntimeRoot,
286
+ source: 'cwd-runtime',
287
+ userRoot,
288
+ systemRoot,
289
+ installMetaPath: path_1.default.join(localRuntimeRoot, 'install.json'),
290
+ };
291
+ }
292
+ if (fs_1.default.existsSync(userMeta)) {
293
+ return {
294
+ root: userRoot,
295
+ source: 'user-install-meta',
296
+ userRoot,
297
+ systemRoot,
298
+ installMetaPath: userMeta,
299
+ };
300
+ }
301
+ if (fs_1.default.existsSync(systemMeta)) {
302
+ return {
303
+ root: systemRoot,
304
+ source: 'system-install-meta',
305
+ userRoot,
306
+ systemRoot,
307
+ installMetaPath: systemMeta,
308
+ };
309
+ }
310
+ return {
311
+ root: userRoot,
312
+ source: 'default-user',
313
+ userRoot,
314
+ systemRoot,
315
+ installMetaPath: userMeta,
316
+ };
317
+ }
318
+ function describeRuntimeRootSource(source) {
319
+ switch (source) {
320
+ case 'flag':
321
+ return '--root flag';
322
+ case 'env':
323
+ return 'IRANTI_HOME';
324
+ case 'project-binding':
325
+ return 'project binding';
326
+ case 'cwd-runtime':
327
+ return 'cwd runtime root';
328
+ case 'user-install-meta':
329
+ return 'user install metadata';
330
+ case 'system-install-meta':
331
+ return 'system install metadata';
332
+ case 'default-system':
333
+ return 'system default';
334
+ case 'default-user':
335
+ default:
336
+ return 'user default';
337
+ }
338
+ }
339
+ function inferRuntimeRootFromInstanceEnv(instanceEnvFile) {
340
+ if (!instanceEnvFile)
341
+ return null;
342
+ const normalized = path_1.default.resolve(instanceEnvFile);
343
+ if (path_1.default.basename(normalized).toLowerCase() !== '.env')
344
+ return null;
345
+ const instanceDir = path_1.default.dirname(normalized);
346
+ const instancesDir = path_1.default.dirname(instanceDir);
347
+ if (path_1.default.basename(instancesDir).toLowerCase() !== 'instances')
348
+ return null;
349
+ return path_1.default.dirname(instancesDir);
350
+ }
351
+ async function inspectProjectBinding(projectEnvFile) {
352
+ try {
353
+ const env = await readEnvFile(projectEnvFile);
354
+ const instanceEnvFile = env.IRANTI_INSTANCE_ENV?.trim() || null;
355
+ return {
356
+ bindingFile: projectEnvFile,
357
+ instanceEnvFile,
358
+ runtimeRoot: inferRuntimeRootFromInstanceEnv(instanceEnvFile ?? undefined),
359
+ };
360
+ }
361
+ catch {
362
+ return {
363
+ bindingFile: projectEnvFile,
364
+ instanceEnvFile: null,
365
+ runtimeRoot: null,
366
+ };
367
+ }
190
368
  }
191
369
  function getPackageVersion() {
192
370
  const pkgPath = path_1.default.join(packageRoot(), 'package.json');
@@ -345,10 +523,24 @@ async function writeJson(filePath, value) {
345
523
  await promises_1.default.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
346
524
  }
347
525
  async function writeText(filePath, content) {
348
- await promises_1.default.writeFile(filePath, content, 'utf-8');
526
+ // Atomic write: write to temp file then rename to avoid partial writes on crash
527
+ const tmpPath = `${filePath}.tmp${process.pid}`;
528
+ try {
529
+ await promises_1.default.writeFile(tmpPath, content, { encoding: 'utf-8', flag: 'w' });
530
+ await promises_1.default.rename(tmpPath, filePath);
531
+ }
532
+ catch (err) {
533
+ await promises_1.default.unlink(tmpPath).catch(() => undefined);
534
+ throw err;
535
+ }
349
536
  }
537
+ const MAX_ENV_FILE_BYTES = 1048576; // 1 MiB
350
538
  async function readEnvFile(filePath) {
351
539
  const out = {};
540
+ const stat = await promises_1.default.stat(filePath).catch(() => null);
541
+ if (stat && stat.size > MAX_ENV_FILE_BYTES) {
542
+ throw new Error(`Env file too large (${stat.size} bytes): ${filePath}. Maximum is ${MAX_ENV_FILE_BYTES} bytes.`);
543
+ }
352
544
  const raw = await promises_1.default.readFile(filePath, 'utf-8');
353
545
  for (const line of raw.split(/\r?\n/)) {
354
546
  const trimmed = line.trim();
@@ -448,9 +640,8 @@ async function upsertEnvFile(filePath, updates) {
448
640
  }
449
641
  const finalLines = nextLines
450
642
  .join('\n')
451
- .replace(/\n{3,}/g, '\n\n')
452
- .replace(/^\n+/, '')
453
- .trimEnd();
643
+ .replace(/^\n+/, '') // strip leading blank lines only
644
+ .trimEnd(); // strip trailing whitespace only — preserving internal blank line groups
454
645
  await writeText(filePath, `${finalLines}\n`);
455
646
  }
456
647
  function redactSecret(value) {
@@ -469,44 +660,169 @@ function instancePaths(root, name) {
469
660
  runtimeFile: path_1.default.join(instanceDir, 'runtime.json'),
470
661
  };
471
662
  }
472
- async function loadInstanceEnv(root, name) {
663
+ function instanceRepairNextSteps(name) {
664
+ return [
665
+ `Run \`iranti configure instance ${name} --interactive\` to repair the instance files.`,
666
+ 'Run `iranti status --json` to inspect the current config classification.',
667
+ ];
668
+ }
669
+ async function loadInstanceEnv(root, name, options = {}) {
473
670
  const paths = instancePaths(root, name);
474
- if (!fs_1.default.existsSync(paths.envFile)) {
671
+ const config = await inspectInstanceConfig(root, name);
672
+ if (!config.state.metaPresent && !config.state.envPresent) {
475
673
  throw cliError('IRANTI_INSTANCE_NOT_FOUND', `Instance '${name}' not found at ${paths.instanceDir}`, [
476
674
  'Run `iranti instance list` to see known instances.',
477
675
  `Run \`iranti setup\` or \`iranti instance create ${name}\` if this instance does not exist yet.`,
478
676
  ], { instance: name, root, instanceDir: paths.instanceDir });
479
677
  }
678
+ if (!options.allowRepair && config.classification !== 'complete') {
679
+ throw cliError(config.classification === 'partial' ? 'IRANTI_INSTANCE_INCOMPLETE' : 'IRANTI_INSTANCE_INVALID', `Instance '${name}' is ${config.classification}: ${config.detail}.`, instanceRepairNextSteps(name), {
680
+ instance: name,
681
+ root,
682
+ instanceDir: paths.instanceDir,
683
+ config: config.classification,
684
+ metaFile: paths.metaFile,
685
+ envFile: paths.envFile,
686
+ });
687
+ }
688
+ let env = {};
689
+ if (config.state.envPresent && config.state.envReadable) {
690
+ env = await readEnvFile(paths.envFile);
691
+ }
692
+ else if (!options.allowRepair) {
693
+ throw cliError('IRANTI_INSTANCE_ENV_UNREADABLE', `Instance '${name}' env file is missing or unreadable: ${paths.envFile}`, instanceRepairNextSteps(name), { instance: name, root, envFile: paths.envFile, config: config.classification });
694
+ }
480
695
  debugLog('Loaded instance env target.', { instance: name, envFile: paths.envFile });
481
696
  return {
482
697
  ...paths,
483
- env: await readEnvFile(paths.envFile),
698
+ env,
699
+ config,
484
700
  };
485
701
  }
486
- async function readInstanceRuntimeSummary(root, name) {
487
- const { runtimeFile } = instancePaths(root, name);
488
- const state = await (0, runtimeLifecycle_1.readRuntimeState)(runtimeFile);
489
- if (!state) {
490
- return { state: null, running: false, stale: false };
702
+ async function inspectInstanceConfig(root, name) {
703
+ const { instanceDir, envFile, metaFile } = instancePaths(root, name);
704
+ const metaPresent = fs_1.default.existsSync(metaFile);
705
+ const envPresent = fs_1.default.existsSync(envFile);
706
+ let metaReadable = false;
707
+ let envReadable = false;
708
+ const ownershipIssues = [];
709
+ if (metaPresent) {
710
+ try {
711
+ const raw = await promises_1.default.readFile(metaFile, 'utf8');
712
+ const parsed = JSON.parse(raw);
713
+ metaReadable = typeof parsed.name === 'string' && parsed.name.trim().length > 0;
714
+ if (metaReadable) {
715
+ if (parsed.name?.trim() !== name) {
716
+ ownershipIssues.push(`instance.json name is ${parsed.name}`);
717
+ }
718
+ if (typeof parsed.instanceDir === 'string' && path_1.default.resolve(parsed.instanceDir) !== path_1.default.resolve(instanceDir)) {
719
+ ownershipIssues.push(`instance.json points to ${parsed.instanceDir}`);
720
+ }
721
+ if (typeof parsed.envFile === 'string' && path_1.default.resolve(parsed.envFile) !== path_1.default.resolve(envFile)) {
722
+ ownershipIssues.push(`instance.json envFile points to ${parsed.envFile}`);
723
+ }
724
+ }
725
+ }
726
+ catch {
727
+ metaReadable = false;
728
+ }
729
+ }
730
+ if (envPresent) {
731
+ try {
732
+ await readEnvFile(envFile);
733
+ envReadable = true;
734
+ }
735
+ catch {
736
+ envReadable = false;
737
+ }
738
+ }
739
+ let classification;
740
+ let detail;
741
+ if (ownershipIssues.length > 0) {
742
+ classification = 'invalid';
743
+ detail = ownershipIssues.join('; ');
744
+ }
745
+ else if (metaPresent && envPresent && metaReadable && envReadable) {
746
+ classification = 'complete';
747
+ detail = 'instance metadata and env are present';
748
+ }
749
+ else if ((metaPresent && !metaReadable) || (envPresent && !envReadable)) {
750
+ classification = 'invalid';
751
+ detail = [
752
+ !metaReadable && metaPresent ? 'instance metadata unreadable' : null,
753
+ !envReadable && envPresent ? 'env unreadable' : null,
754
+ ].filter((value) => Boolean(value)).join('; ');
755
+ }
756
+ else {
757
+ classification = 'partial';
758
+ detail = [
759
+ !metaPresent ? 'missing instance.json' : null,
760
+ !envPresent ? 'missing .env' : null,
761
+ ].filter((value) => Boolean(value)).join('; ') || 'instance directory incomplete';
491
762
  }
492
- const running = (0, runtimeLifecycle_1.isPidRunning)(state.pid);
493
763
  return {
494
- state,
495
- running,
496
- stale: !running && state.status !== 'stopped',
764
+ classification,
765
+ detail,
766
+ metaFile,
767
+ envFile,
768
+ state: {
769
+ metaPresent,
770
+ envPresent,
771
+ metaReadable,
772
+ envReadable,
773
+ },
497
774
  };
498
775
  }
776
+ async function readInstanceRuntimeSummary(root, name) {
777
+ const { runtimeFile } = instancePaths(root, name);
778
+ return (0, runtimeLifecycle_1.inspectRuntimeState)(runtimeFile);
779
+ }
499
780
  function describeInstanceRuntime(summary) {
500
- if (!summary.state) {
501
- return `${warnLabel('STOPPED')} no runtime metadata`;
781
+ switch (summary.classification) {
782
+ case 'running':
783
+ return `${okLabel('RUNNING')} ${summary.detail}`;
784
+ case 'unhealthy':
785
+ return `${failLabel('UNHEALTHY')} ${summary.detail}`;
786
+ case 'stale':
787
+ return `${warnLabel('STALE')} ${summary.detail}`;
788
+ case 'stopped':
789
+ return `${warnLabel('STOPPED')} ${summary.detail}`;
790
+ case 'invalid':
791
+ return `${failLabel('INVALID')} ${summary.detail}`;
792
+ case 'missing':
793
+ default:
794
+ return `${warnLabel('STOPPED')} ${summary.detail}`;
795
+ }
796
+ }
797
+ function describeInstanceConfig(summary) {
798
+ switch (summary.classification) {
799
+ case 'complete':
800
+ return `${okLabel('COMPLETE')} ${summary.detail}`;
801
+ case 'partial':
802
+ return `${warnLabel('PARTIAL')} ${summary.detail}`;
803
+ case 'invalid':
804
+ default:
805
+ return `${failLabel('INVALID')} ${summary.detail}`;
806
+ }
807
+ }
808
+ function buildInstanceRepairHints(name, config, runtime) {
809
+ const hints = [];
810
+ if (config.classification === 'partial') {
811
+ hints.push(`Run \`iranti configure instance ${name} --interactive\` to finish the missing instance files.`);
502
812
  }
503
- if (summary.running) {
504
- return `${okLabel('RUNNING')} pid=${summary.state.pid} version=${summary.state.version}`;
813
+ if (config.classification === 'invalid') {
814
+ hints.push(`Run \`iranti configure instance ${name} --interactive\` to repair invalid instance metadata or env values.`);
505
815
  }
506
- if (summary.stale) {
507
- return `${warnLabel('STALE')} last_pid=${summary.state.pid} version=${summary.state.version}`;
816
+ if (runtime.classification === 'stale') {
817
+ hints.push(`Run \`iranti instance restart ${name}\` if this instance should still be running.`);
508
818
  }
509
- return `${warnLabel('STOPPED')} version=${summary.state.version}`;
819
+ if (runtime.classification === 'unhealthy') {
820
+ hints.push(`Run \`iranti doctor --instance ${name}\` and inspect ${runtime.state?.healthUrl ?? `http://localhost:${runtime.state?.port ?? 3001}/health`} before restarting.`);
821
+ }
822
+ if (runtime.classification === 'invalid') {
823
+ hints.push(`Inspect ${runtime.state?.runtimeFile ?? `instances/${name}/runtime.json`} for copied or foreign runtime metadata.`);
824
+ }
825
+ return Array.from(new Set(hints));
510
826
  }
511
827
  async function startInstanceRuntime(name, instanceDir, envFile, runtimeFile) {
512
828
  process.env.IRANTI_INSTANCE_NAME = name;
@@ -676,8 +992,9 @@ async function withPromptSession(run) {
676
992
  secret: async (prompt, currentValue) => {
677
993
  const placeholder = currentValue ? `${redactSecret(currentValue)} (enter new value to replace)` : 'leave blank to skip';
678
994
  const suffix = placeholder ? ` [${placeholder}]` : '';
995
+ process.stdout.write(`${prompt}${suffix}: `);
679
996
  muted = true;
680
- const answer = (await rl.question(`${prompt}${suffix}: `)).trim();
997
+ const answer = (await rl.question('')).trim();
681
998
  muted = false;
682
999
  process.stdout.write('\n');
683
1000
  if (!answer || answer === placeholder)
@@ -689,8 +1006,9 @@ async function withPromptSession(run) {
689
1006
  secretRequired: async (prompt, currentValue) => {
690
1007
  const placeholder = currentValue ? `${redactSecret(currentValue)} (enter new value to replace)` : 'required';
691
1008
  const suffix = placeholder ? ` [${placeholder}]` : '';
1009
+ process.stdout.write(`${prompt}${suffix}: `);
692
1010
  muted = true;
693
- const answer = (await rl.question(`${prompt}${suffix}: `)).trim();
1011
+ const answer = (await rl.question('')).trim();
694
1012
  muted = false;
695
1013
  process.stdout.write('\n');
696
1014
  if (!answer || answer === placeholder)
@@ -709,13 +1027,30 @@ function detectPlaceholder(value) {
709
1027
  if (!value)
710
1028
  return true;
711
1029
  const normalized = value.trim().toLowerCase();
712
- return normalized.length === 0
713
- || normalized.includes('yourpassword')
714
- || normalized.includes('replace_me')
715
- || normalized.includes('your_secret')
716
- || normalized.includes('your_key_here')
717
- || normalized.includes('your_api_key')
718
- || normalized === 'changeme';
1030
+ if (normalized.length === 0)
1031
+ return true;
1032
+ const exactWeakValues = new Set([
1033
+ 'changeme',
1034
+ 'placeholder',
1035
+ 'example',
1036
+ 'todo',
1037
+ 'fixme',
1038
+ 'none',
1039
+ 'null',
1040
+ 'undefined',
1041
+ ]);
1042
+ if (exactWeakValues.has(normalized))
1043
+ return true;
1044
+ const weakFragments = [
1045
+ 'yourpassword',
1046
+ 'replace_me',
1047
+ 'your_secret',
1048
+ 'your_key_here',
1049
+ 'your_api_key',
1050
+ 'insert_key_here',
1051
+ 'add_your_key',
1052
+ ];
1053
+ return weakFragments.some((p) => normalized.includes(p));
719
1054
  }
720
1055
  function quoteSqlLiteral(value) {
721
1056
  return `'${value.replace(/'/g, "''")}'`;
@@ -750,6 +1085,9 @@ function isLocalPostgresHost(hostname) {
750
1085
  }
751
1086
  function sanitizeIdentifier(input, fallback) {
752
1087
  const value = input.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
1088
+ if (!value && input.trim()) {
1089
+ verboseLog(`sanitizeIdentifier: input "${input}" normalized to empty — using fallback "${fallback}"`);
1090
+ }
753
1091
  return value || fallback;
754
1092
  }
755
1093
  function projectAgentDefault(projectPath) {
@@ -840,6 +1178,18 @@ async function promptRequiredSecret(session, prompt, currentValue) {
840
1178
  console.log(`${warnLabel()} ${prompt} is required.`);
841
1179
  }
842
1180
  }
1181
+ async function promptSecretWithDefault(session, prompt, defaultValue) {
1182
+ while (true) {
1183
+ const value = (await session.secret(`${prompt} (blank uses local-dev default)`, undefined) ?? '').trim();
1184
+ if (!value) {
1185
+ console.log(`${infoLabel()} Using the local development default for ${prompt}.`);
1186
+ return defaultValue;
1187
+ }
1188
+ if (!detectPlaceholder(value))
1189
+ return value;
1190
+ console.log(`${warnLabel()} ${prompt} still looks like a placeholder. Enter a real value or leave it blank to use the local-dev default.`);
1191
+ }
1192
+ }
843
1193
  function makeLegacyInstanceApiKey(instanceName) {
844
1194
  const keyId = sanitizeIdentifier(`${instanceName}_${os_1.default.userInfo().username}`, 'iranti');
845
1195
  return (0, apiKeys_1.formatApiKeyToken)(keyId, (0, apiKeys_1.generateApiKeySecret)());
@@ -883,6 +1233,7 @@ async function ensureInstanceConfigured(root, name, config) {
883
1233
  LLM_PROVIDER: config.provider,
884
1234
  ...config.providerKeys,
885
1235
  });
1236
+ await syncInstanceMeta(root, name, config.port);
886
1237
  return { envFile, instanceDir, created };
887
1238
  }
888
1239
  function makeIrantiMcpServerConfig() {
@@ -990,6 +1341,76 @@ function resolveAttendMessage(args) {
990
1341
  return fromPositionals;
991
1342
  throw new Error('Missing latest message. Usage: iranti attend [message] [--message <text>] [--context <text>] [--json]');
992
1343
  }
1344
+ function parseDelimitedList(raw) {
1345
+ if (!raw?.trim())
1346
+ return [];
1347
+ const delimiter = raw.includes('||') ? '||' : ',';
1348
+ return raw
1349
+ .split(delimiter)
1350
+ .map((item) => item.trim())
1351
+ .filter(Boolean);
1352
+ }
1353
+ function resolveTaskEntity(args) {
1354
+ const entity = (args.subcommand ?? args.positionals[0] ?? getFlag(args, 'entity') ?? '').trim();
1355
+ if (!entity) {
1356
+ throw new Error('Missing task entity. Usage: iranti handoff task/<task_id> --next-step <text> [--json]');
1357
+ }
1358
+ if (!entity.includes('/')) {
1359
+ throw new Error('task entity must use entityType/entityId format.');
1360
+ }
1361
+ return entity;
1362
+ }
1363
+ function buildHandoffSummary(key, value) {
1364
+ switch (key) {
1365
+ case 'status': {
1366
+ const state = typeof value === 'object' && value && 'state' in value ? String(value.state) : 'updated';
1367
+ return `Shared task status is ${state}.`;
1368
+ }
1369
+ case 'current_owner': {
1370
+ const agentId = typeof value === 'object' && value && 'agentId' in value ? String(value.agentId) : 'unassigned';
1371
+ return `Current owner is ${agentId}.`;
1372
+ }
1373
+ case 'next_step': {
1374
+ const instruction = typeof value === 'object' && value && 'instruction' in value ? String(value.instruction) : 'Next step updated.';
1375
+ return truncateText(`Next step: ${instruction}`, 140);
1376
+ }
1377
+ case 'blockers': {
1378
+ const count = typeof value === 'object' && value && 'items' in value && Array.isArray(value.items)
1379
+ ? value.items.length
1380
+ : 0;
1381
+ return count === 0 ? 'No blockers recorded.' : `${count} blocker${count === 1 ? '' : 's'} recorded for the shared task.`;
1382
+ }
1383
+ case 'artifacts': {
1384
+ const count = typeof value === 'object' && value && 'files' in value && Array.isArray(value.files)
1385
+ ? value.files.length
1386
+ : 0;
1387
+ return count === 0 ? 'No artifacts recorded.' : `${count} artifact${count === 1 ? '' : 's'} recorded for the shared task.`;
1388
+ }
1389
+ case 'notes': {
1390
+ const text = typeof value === 'object' && value && 'text' in value ? String(value.text) : 'Shared handoff notes updated.';
1391
+ return truncateText(`Notes: ${text}`, 140);
1392
+ }
1393
+ case 'active_handoff_task': {
1394
+ const taskEntity = typeof value === 'object' && value && 'taskEntity' in value ? String(value.taskEntity) : 'task';
1395
+ return `Project now points to active handoff ${taskEntity}.`;
1396
+ }
1397
+ default:
1398
+ return 'Shared handoff state updated.';
1399
+ }
1400
+ }
1401
+ function printHandoffResult(target, taskEntity, writes) {
1402
+ console.log(bold('Iranti handoff'));
1403
+ console.log(` agent ${target.agentId}`);
1404
+ console.log(` env source ${target.envSource}`);
1405
+ if (target.envFile)
1406
+ console.log(` env file ${target.envFile}`);
1407
+ console.log(` task entity ${taskEntity}`);
1408
+ console.log(` writes ${writes.length}`);
1409
+ console.log('');
1410
+ for (const write of writes) {
1411
+ console.log(`- ${write.entity} :: ${write.key} | ${write.summary}`);
1412
+ }
1413
+ }
993
1414
  function printHandshakeResult(target, task, result) {
994
1415
  console.log(bold('Iranti handshake'));
995
1416
  console.log(` agent ${target.agentId}`);
@@ -1220,7 +1641,7 @@ function inspectDockerAvailability() {
1220
1641
  detail: `Docker CLI is installed, but the daemon is not reachable. ${reason}`,
1221
1642
  };
1222
1643
  }
1223
- async function isPortAvailable(port, host = '127.0.0.1') {
1644
+ async function isPortAvailable(port, host = '0.0.0.0') {
1224
1645
  return await new Promise((resolve) => {
1225
1646
  const server = net_1.default.createServer();
1226
1647
  server.unref();
@@ -1230,18 +1651,58 @@ async function isPortAvailable(port, host = '127.0.0.1') {
1230
1651
  });
1231
1652
  });
1232
1653
  }
1233
- async function findNextAvailablePort(start, host = '127.0.0.1', maxSteps = 50) {
1654
+ function listPublishedDockerHostPorts() {
1655
+ const docker = inspectDockerAvailability();
1656
+ if (!docker.daemonReachable)
1657
+ return new Set();
1658
+ const inspect = runCommandCapture('docker', ['ps', '--format', '{{.Ports}}']);
1659
+ if (inspect.status !== 0)
1660
+ return new Set();
1661
+ return (0, dockerCliParsing_1.parsePublishedDockerHostPorts)(inspect.stdout ?? '');
1662
+ }
1663
+ async function isPortUsable(port, host = '0.0.0.0', dockerPublishedPorts = new Set()) {
1664
+ if (dockerPublishedPorts.has(port))
1665
+ return false;
1666
+ return isPortAvailable(port, host);
1667
+ }
1668
+ async function findNextAvailablePort(start, host = '0.0.0.0', maxSteps = 50, dockerPublishedPorts = new Set()) {
1234
1669
  for (let port = start; port < start + maxSteps; port += 1) {
1235
- if (await isPortAvailable(port, host)) {
1670
+ if (await isPortUsable(port, host, dockerPublishedPorts)) {
1236
1671
  return port;
1237
1672
  }
1238
1673
  }
1239
1674
  throw new Error(`No available port found in range ${start}-${start + maxSteps - 1}.`);
1240
1675
  }
1241
- async function chooseAvailablePort(session, promptText, preferredPort, allowOccupiedCurrent = false) {
1676
+ async function readAllInstancePorts(root) {
1677
+ const ports = new Set();
1678
+ const instancesDir = path_1.default.join(root, 'instances');
1679
+ if (!fs_1.default.existsSync(instancesDir))
1680
+ return ports;
1681
+ try {
1682
+ const entries = await promises_1.default.readdir(instancesDir, { withFileTypes: true });
1683
+ for (const entry of entries) {
1684
+ if (!entry.isDirectory())
1685
+ continue;
1686
+ const metaPath = path_1.default.join(instancesDir, entry.name, 'instance.json');
1687
+ try {
1688
+ const raw = await promises_1.default.readFile(metaPath, 'utf-8');
1689
+ const meta = JSON.parse(raw);
1690
+ const port = Number(meta.port);
1691
+ if (Number.isFinite(port) && port > 0)
1692
+ ports.add(port);
1693
+ }
1694
+ catch { /* ignore unreadable meta files */ }
1695
+ }
1696
+ }
1697
+ catch { /* ignore unreadable instances dir */ }
1698
+ return ports;
1699
+ }
1700
+ async function chooseAvailablePort(session, promptText, preferredPort, allowOccupiedCurrent = false, reservedPorts = new Set()) {
1701
+ const dockerPublishedPorts = listPublishedDockerHostPorts();
1702
+ const allReserved = new Set([...dockerPublishedPorts, ...reservedPorts]);
1242
1703
  let suggested = preferredPort;
1243
- if (!allowOccupiedCurrent && !(await isPortAvailable(preferredPort))) {
1244
- suggested = await findNextAvailablePort(preferredPort + 1);
1704
+ if (!allowOccupiedCurrent && !(await isPortUsable(preferredPort, '0.0.0.0', allReserved))) {
1705
+ suggested = await findNextAvailablePort(preferredPort + 1, '0.0.0.0', 50, allReserved);
1245
1706
  console.log(`${warnLabel()} Port ${preferredPort} is already in use. A good next option is ${suggested}.`);
1246
1707
  }
1247
1708
  while (true) {
@@ -1254,14 +1715,66 @@ async function chooseAvailablePort(session, promptText, preferredPort, allowOccu
1254
1715
  if (allowOccupiedCurrent && parsed === preferredPort) {
1255
1716
  return parsed;
1256
1717
  }
1257
- if (await isPortAvailable(parsed)) {
1718
+ if (await isPortUsable(parsed, '0.0.0.0', allReserved)) {
1258
1719
  return parsed;
1259
1720
  }
1260
- const next = await findNextAvailablePort(parsed + 1);
1721
+ const next = await findNextAvailablePort(parsed + 1, '0.0.0.0', 50, allReserved);
1261
1722
  console.log(`${warnLabel()} Port ${parsed} is already in use. Try ${next} instead.`);
1262
1723
  suggested = next;
1263
1724
  }
1264
1725
  }
1726
+ async function syncInstanceMeta(root, name, port) {
1727
+ const { instanceDir, envFile, metaFile } = instancePaths(root, name);
1728
+ const existingCreatedAt = fs_1.default.existsSync(metaFile)
1729
+ ? await promises_1.default.readFile(metaFile, 'utf-8')
1730
+ .then((raw) => {
1731
+ try {
1732
+ const parsed = JSON.parse(raw);
1733
+ return typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
1734
+ }
1735
+ catch {
1736
+ return undefined;
1737
+ }
1738
+ })
1739
+ : undefined;
1740
+ const meta = {
1741
+ name,
1742
+ createdAt: existingCreatedAt ?? new Date().toISOString(),
1743
+ port,
1744
+ envFile,
1745
+ instanceDir,
1746
+ };
1747
+ await writeJson(metaFile, meta);
1748
+ }
1749
+ async function assertPortAssignable(root, port, currentInstanceName) {
1750
+ const reservedPorts = await readAllInstancePorts(root);
1751
+ let allowCurrentRunningPort = false;
1752
+ if (currentInstanceName) {
1753
+ const { envFile } = instancePaths(root, currentInstanceName);
1754
+ if (fs_1.default.existsSync(envFile)) {
1755
+ try {
1756
+ const env = await readEnvFile(envFile);
1757
+ const currentPort = Number.parseInt(env.IRANTI_PORT ?? '', 10);
1758
+ if (Number.isFinite(currentPort) && currentPort > 0) {
1759
+ reservedPorts.delete(currentPort);
1760
+ if (currentPort === port) {
1761
+ const runtime = await readInstanceRuntimeSummary(root, currentInstanceName);
1762
+ allowCurrentRunningPort = runtime.running && runtime.state?.port === port;
1763
+ }
1764
+ }
1765
+ }
1766
+ catch {
1767
+ // Ignore unreadable current instance env and fall back to stricter validation.
1768
+ }
1769
+ }
1770
+ }
1771
+ if (reservedPorts.has(port)) {
1772
+ throw new Error(`Port ${port} is already assigned to another Iranti instance.`);
1773
+ }
1774
+ if (!allowCurrentRunningPort && !(await isPortUsable(port, '0.0.0.0', listPublishedDockerHostPorts()))) {
1775
+ throw new Error(`Port ${port} is already in use.`);
1776
+ }
1777
+ }
1265
1778
  async function waitForTcpPort(host, port, timeoutMs) {
1266
1779
  const deadline = Date.now() + timeoutMs;
1267
1780
  while (Date.now() < deadline) {
@@ -1290,16 +1803,14 @@ async function runDockerPostgresContainer(options) {
1290
1803
  if (!docker.daemonReachable) {
1291
1804
  throw new Error(`Docker daemon is not reachable. Start Docker Desktop or Docker Engine, then retry. ${docker.detail}`);
1292
1805
  }
1293
- const inspect = process.platform === 'win32'
1294
- ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', `docker ps -a --format "{{.Names}}"`], { encoding: 'utf8' })
1295
- : (0, child_process_1.spawnSync)('docker', ['ps', '-a', '--format', '{{.Names}}'], { encoding: 'utf8' });
1806
+ const inspect = runCommandCapture('docker', ['ps', '-a', '--format', '{{.Names}}']);
1296
1807
  if (inspect.status !== 0) {
1297
1808
  throw new Error(`Failed to inspect Docker containers. ${(inspect.stderr ?? inspect.stdout ?? '').trim() || 'docker ps returned a non-zero exit code.'}`);
1298
1809
  }
1299
- const names = (inspect.stdout ?? '').split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
1810
+ const names = (0, dockerCliParsing_1.parseDockerContainerNames)(inspect.stdout ?? '');
1300
1811
  if (names.includes(options.containerName)) {
1301
1812
  const start = process.platform === 'win32'
1302
- ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', `docker start ${options.containerName}`], { stdio: 'inherit' })
1813
+ ? (0, child_process_1.spawnSync)(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', ['docker', 'start', options.containerName].map(quoteForCmd).join(' ')], { stdio: 'inherit' })
1303
1814
  : (0, child_process_1.spawnSync)('docker', ['start', options.containerName], { stdio: 'inherit' });
1304
1815
  if (start.status !== 0) {
1305
1816
  throw new Error(`Failed to start existing Docker container '${options.containerName}'.`);
@@ -1800,9 +2311,12 @@ function printDependencyChecks(checks) {
1800
2311
  function quoteForCmd(arg) {
1801
2312
  if (arg.length === 0)
1802
2313
  return '""';
1803
- if (!/[ \t"&()<>|^]/.test(arg))
1804
- return arg;
1805
- return `"${arg.replace(/"/g, '\\"')}"`;
2314
+ // Escape % to prevent CMD variable expansion (%VAR%)
2315
+ const pctEscaped = arg.replace(/%/g, '%%');
2316
+ if (!/[ \t"&()<>|^%!]/.test(arg))
2317
+ return pctEscaped;
2318
+ // Use "" for inner double quotes (CMD convention, not Unix \")
2319
+ return `"${pctEscaped.replace(/"/g, '""')}"`;
1806
2320
  }
1807
2321
  function runCommandCapture(executable, args, cwd, extraEnv) {
1808
2322
  verboseLog('Running subprocess (capture).', {
@@ -1895,7 +2409,11 @@ function spawnDetachedCli(args, cwd) {
1895
2409
  env: process.env,
1896
2410
  });
1897
2411
  child.unref();
1898
- return child.pid ?? 0;
2412
+ const pid = child.pid;
2413
+ if (!pid) {
2414
+ throw new Error(`Failed to spawn detached CLI process (executable: ${invocation.executable}). The process did not start.`);
2415
+ }
2416
+ return pid;
1899
2417
  }
1900
2418
  async function restartInstanceRuntime(args, instanceName, scope, root) {
1901
2419
  const runtimeBefore = await readInstanceRuntimeSummary(root, instanceName);
@@ -2103,14 +2621,20 @@ function readJsonFile(filePath) {
2103
2621
  return null;
2104
2622
  }
2105
2623
  }
2106
- function httpsJson(url, headers = {}) {
2624
+ function httpsJson(url, headers = {}, _redirectDepth = 0) {
2625
+ const MAX_REDIRECTS = 5;
2626
+ const REQUEST_TIMEOUT_MS = 10000;
2107
2627
  return new Promise((resolve, reject) => {
2108
2628
  const request = https_1.default.get(url, { headers }, (response) => {
2109
2629
  const statusCode = response.statusCode ?? 0;
2110
2630
  if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
2111
2631
  response.resume();
2632
+ if (_redirectDepth >= MAX_REDIRECTS) {
2633
+ reject(new Error(`Too many redirects fetching ${url}`));
2634
+ return;
2635
+ }
2112
2636
  const redirect = new URL(response.headers.location, url).toString();
2113
- httpsJson(redirect, headers).then(resolve).catch(reject);
2637
+ httpsJson(redirect, headers, _redirectDepth + 1).then(resolve).catch(reject);
2114
2638
  return;
2115
2639
  }
2116
2640
  if (statusCode < 200 || statusCode >= 300) {
@@ -2132,7 +2656,7 @@ function httpsJson(url, headers = {}) {
2132
2656
  }
2133
2657
  });
2134
2658
  });
2135
- request.setTimeout(5000, () => {
2659
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => {
2136
2660
  request.destroy(new Error(`Timed out fetching ${url}`));
2137
2661
  });
2138
2662
  request.on('error', reject);
@@ -2371,6 +2895,10 @@ function escapeForSingleQuotedPowerShell(value) {
2371
2895
  return value.replace(/'/g, "''");
2372
2896
  }
2373
2897
  function resolveWindowsDetachedExecutable(executable) {
2898
+ // H-5: Validate executable name — reject empty strings or strings with shell metacharacters
2899
+ if (!executable || /[;&|<>\n\r`$(){}[\]\\/"']/.test(executable)) {
2900
+ throw new Error(`Invalid executable name: "${executable}"`);
2901
+ }
2374
2902
  if (path_1.default.isAbsolute(executable)) {
2375
2903
  return executable;
2376
2904
  }
@@ -2427,7 +2955,19 @@ function launchDetachedWindowsPowerShellFile(scriptPath, cwd) {
2427
2955
  throw new Error(`Failed to schedule detached PowerShell handoff. ${(proc.stderr || proc.stdout).trim() || 'powershell returned a non-zero exit code.'}`);
2428
2956
  }
2429
2957
  }
2958
+ // C-5: postCommand must be a pre-escaped PowerShell snippet produced internally (never from raw user input).
2959
+ // Validate it against a strict allowlist pattern to prevent future injection if the call site changes.
2960
+ function validateDetachedPostCommand(postCommand) {
2961
+ // Allow only: alphanumeric, spaces, single-quotes, hyphens, underscores, dots, slashes,
2962
+ // backslashes, colons, and & for PS call operator.
2963
+ if (!/^[a-zA-Z0-9 '&_\-./:\\]+$/.test(postCommand)) {
2964
+ throw new Error(`Unsafe characters in detached post-command. Only pre-escaped PowerShell call expressions are permitted.`);
2965
+ }
2966
+ }
2430
2967
  function scheduleDetachedWindowsGlobalNpmUpgrade(command, postCommand) {
2968
+ if (postCommand !== undefined) {
2969
+ validateDetachedPostCommand(postCommand);
2970
+ }
2431
2971
  const neutralCwd = resolveDetachedUpgradeCwd(command);
2432
2972
  const parentPid = process.pid;
2433
2973
  const escapedCwd = escapeForSingleQuotedPowerShell(neutralCwd);
@@ -3322,7 +3862,11 @@ async function setupCommand(args) {
3322
3862
  console.log(`${infoLabel()} Creating new instance '${instanceName}'.`);
3323
3863
  }
3324
3864
  const existingPort = Number.parseInt(existingInstance?.env.IRANTI_PORT ?? '3001', 10);
3325
- const port = await chooseAvailablePort(prompt, 'API port', existingPort, Boolean(existingInstance));
3865
+ const existingInstancePorts = await readAllInstancePorts(finalRoot);
3866
+ // Exclude the current instance's own port from the reserved set when updating
3867
+ if (existingInstance)
3868
+ existingInstancePorts.delete(existingPort);
3869
+ const port = await chooseAvailablePort(prompt, 'API port', existingPort, Boolean(existingInstance), existingInstancePorts);
3326
3870
  const dockerStatus = inspectDockerAvailability();
3327
3871
  const dockerAvailable = dockerStatus.daemonReachable;
3328
3872
  const psqlAvailable = hasCommandInstalled('psql');
@@ -3364,7 +3908,7 @@ async function setupCommand(args) {
3364
3908
  }
3365
3909
  const dbHostPort = await chooseAvailablePort(prompt, 'Docker PostgreSQL host port', 5432, false);
3366
3910
  const dbName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker PostgreSQL database name', `iranti_${instanceName}`), `iranti_${instanceName}`);
3367
- const dbPassword = await promptRequiredSecret(prompt, 'Docker PostgreSQL password');
3911
+ const dbPassword = await promptSecretWithDefault(prompt, 'Docker PostgreSQL password', 'postgres');
3368
3912
  const containerName = sanitizeIdentifier(await promptNonEmpty(prompt, 'Docker container name', `iranti_${instanceName}_db`), `iranti_${instanceName}_db`);
3369
3913
  dockerContainerName = containerName;
3370
3914
  dbUrl = `postgresql://postgres:${dbPassword}@localhost:${dbHostPort}/${dbName}`;
@@ -3742,51 +4286,67 @@ async function doctorCommand(args) {
3742
4286
  }
3743
4287
  async function statusCommand(args) {
3744
4288
  const scope = normalizeScope(getFlag(args, 'scope'));
3745
- const root = resolveInstallRoot(args, scope);
4289
+ const resolution = resolveInstallRootDetails(args, scope);
4290
+ const root = resolution.root;
3746
4291
  const json = hasFlag(args, 'json');
3747
4292
  const cwd = process.cwd();
3748
- const repoEnv = path_1.default.join(cwd, '.env');
3749
- const projectEnv = path_1.default.join(cwd, '.env.iranti');
3750
- const installMetaPath = path_1.default.join(root, 'install.json');
3751
- const instancesDir = path_1.default.join(root, 'instances');
4293
+ const repoEnv = findClosestAncestorFile(cwd, '.env');
4294
+ const projectEnv = findClosestAncestorFile(cwd, '.env.iranti');
4295
+ const localRuntimeRoot = findClosestAncestorRuntimeRoot(cwd);
4296
+ const installMetaPath = resolution.installMetaPath;
4297
+ const binding = projectEnv && fs_1.default.existsSync(projectEnv) ? await inspectProjectBinding(projectEnv) : null;
4298
+ const boundRuntimeRoot = binding?.runtimeRoot ?? null;
4299
+ const boundInstanceEnv = binding?.instanceEnvFile ?? null;
4300
+ const rootMismatch = Boolean(boundRuntimeRoot && path_1.default.resolve(boundRuntimeRoot) !== path_1.default.resolve(root));
4301
+ const userInstallRuntimeRoot = fs_1.default.existsSync(path_1.default.join(resolution.userRoot, 'install.json')) ? resolution.userRoot : null;
4302
+ const systemInstallRuntimeRoot = fs_1.default.existsSync(path_1.default.join(resolution.systemRoot, 'install.json')) ? resolution.systemRoot : null;
4303
+ const otherRuntimeRoots = Array.from(new Set([boundRuntimeRoot, localRuntimeRoot]
4304
+ .filter((candidate) => Boolean(candidate))
4305
+ .map((candidate) => path_1.default.resolve(candidate))
4306
+ .filter((candidate) => candidate !== path_1.default.resolve(root) && fs_1.default.existsSync(candidate))));
3752
4307
  const rows = [];
3753
4308
  rows.push({ label: 'version', value: getPackageVersion() });
3754
4309
  rows.push({ label: 'scope', value: scope });
3755
4310
  rows.push({ label: 'runtime_root', value: root });
3756
- rows.push({ label: 'repo_env', value: fs_1.default.existsSync(repoEnv) ? repoEnv : '(missing)' });
3757
- rows.push({ label: 'project_binding', value: fs_1.default.existsSync(projectEnv) ? projectEnv : '(missing)' });
4311
+ rows.push({ label: 'root_source', value: describeRuntimeRootSource(resolution.source) });
4312
+ if (boundRuntimeRoot)
4313
+ rows.push({ label: 'bound_root', value: boundRuntimeRoot });
4314
+ rows.push({ label: 'repo_env', value: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : '(missing)' });
4315
+ rows.push({ label: 'project_binding', value: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : '(missing)' });
3758
4316
  rows.push({ label: 'install_meta', value: fs_1.default.existsSync(installMetaPath) ? installMetaPath : '(not initialized)' });
3759
- const instances = [];
3760
- if (fs_1.default.existsSync(instancesDir)) {
3761
- const entries = await promises_1.default.readdir(instancesDir, { withFileTypes: true });
3762
- for (const entry of entries.filter((value) => value.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
3763
- const envFile = path_1.default.join(instancesDir, entry.name, '.env');
3764
- let port = '(unknown)';
3765
- if (fs_1.default.existsSync(envFile)) {
3766
- try {
3767
- const env = await readEnvFile(envFile);
3768
- port = env.IRANTI_PORT ?? '(unknown)';
3769
- }
3770
- catch {
3771
- port = '(unreadable)';
3772
- }
3773
- }
3774
- instances.push({
3775
- name: entry.name,
3776
- port,
3777
- envFile: fs_1.default.existsSync(envFile) ? envFile : '(missing)',
3778
- runtime: await readInstanceRuntimeSummary(root, entry.name),
3779
- });
3780
- }
3781
- }
4317
+ if (rootMismatch)
4318
+ rows.push({ label: 'root_mismatch', value: 'project binding points at a different runtime root' });
4319
+ const instances = await collectRuntimeInstanceSummaries(root);
4320
+ const recommendedActions = Array.from(new Set(instances.flatMap((instance) => instance.repairHints)));
3782
4321
  if (json) {
3783
4322
  console.log(JSON.stringify({
3784
4323
  version: getPackageVersion(),
3785
4324
  scope,
3786
4325
  runtimeRoot: root,
3787
- repoEnv: fs_1.default.existsSync(repoEnv) ? repoEnv : null,
3788
- projectBinding: fs_1.default.existsSync(projectEnv) ? projectEnv : null,
4326
+ runtimeRootSource: resolution.source,
4327
+ discovery: {
4328
+ selectedRuntimeRoot: root,
4329
+ selectionSource: resolution.source,
4330
+ selectionReason: describeRuntimeRootSource(resolution.source),
4331
+ boundRuntimeRoot,
4332
+ boundInstanceEnv,
4333
+ ancestorRuntimeRoot: localRuntimeRoot,
4334
+ userInstallRuntimeRoot,
4335
+ systemInstallRuntimeRoot,
4336
+ projectBindingFile: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : null,
4337
+ projectBindingSource: projectEnv && fs_1.default.existsSync(projectEnv) ? 'ancestor-project-binding' : null,
4338
+ repoEnvFile: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : null,
4339
+ rootMismatch,
4340
+ otherRuntimeRoots,
4341
+ },
4342
+ boundRuntimeRoot,
4343
+ boundInstanceEnv,
4344
+ rootMismatch,
4345
+ otherRuntimeRoots,
4346
+ repoEnv: repoEnv && fs_1.default.existsSync(repoEnv) ? repoEnv : null,
4347
+ projectBinding: projectEnv && fs_1.default.existsSync(projectEnv) ? projectEnv : null,
3789
4348
  installMeta: fs_1.default.existsSync(installMetaPath) ? installMetaPath : null,
4349
+ recommendedActions,
3790
4350
  instances,
3791
4351
  }, null, 2));
3792
4352
  return;
@@ -3795,6 +4355,12 @@ async function statusCommand(args) {
3795
4355
  for (const row of rows) {
3796
4356
  console.log(` ${row.label.padEnd(15)} ${row.value}`);
3797
4357
  }
4358
+ if (otherRuntimeRoots.length > 0) {
4359
+ console.log(' other_roots');
4360
+ for (const runtimeRoot of otherRuntimeRoots) {
4361
+ console.log(` - ${runtimeRoot}`);
4362
+ }
4363
+ }
3798
4364
  console.log('');
3799
4365
  if (instances.length === 0) {
3800
4366
  console.log('Instances: none');
@@ -3804,17 +4370,24 @@ async function statusCommand(args) {
3804
4370
  for (const instance of instances) {
3805
4371
  console.log(` - ${instance.name} (port ${instance.port})`);
3806
4372
  console.log(` env: ${instance.envFile}`);
3807
- if (instance.runtime.state) {
3808
- const runtimeLabel = instance.runtime.running
3809
- ? `${okLabel('RUNNING')} pid=${instance.runtime.state.pid} version=${instance.runtime.state.version}`
3810
- : instance.runtime.stale
3811
- ? `${warnLabel('STALE')} last_pid=${instance.runtime.state.pid} version=${instance.runtime.state.version}`
3812
- : `${warnLabel('STOPPED')} version=${instance.runtime.state.version}`;
3813
- console.log(` runtime: ${runtimeLabel}`);
4373
+ console.log(` meta: ${instance.metaFile}`);
4374
+ console.log(` config: ${describeInstanceConfig(instance.config)}`);
4375
+ console.log(` runtime: ${describeInstanceRuntime(instance.runtime)}`);
4376
+ if (instance.repairHints.length > 0) {
4377
+ console.log(' hints:');
4378
+ for (const hint of instance.repairHints) {
4379
+ console.log(` - ${hint}`);
4380
+ }
4381
+ }
4382
+ if (instance.runtime.state?.healthUrl) {
3814
4383
  console.log(` health: ${instance.runtime.state.healthUrl}`);
3815
4384
  }
3816
- else {
3817
- console.log(` runtime: ${warnLabel('STOPPED')} no runtime metadata`);
4385
+ }
4386
+ if (recommendedActions.length > 0) {
4387
+ console.log('');
4388
+ console.log('Suggested fixes:');
4389
+ for (const action of recommendedActions) {
4390
+ console.log(` - ${action}`);
3818
4391
  }
3819
4392
  }
3820
4393
  }
@@ -3827,9 +4400,10 @@ async function collectRuntimeInstanceSummaries(root) {
3827
4400
  }
3828
4401
  const entries = await promises_1.default.readdir(instancesDir, { withFileTypes: true });
3829
4402
  for (const entry of entries.filter((value) => value.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
3830
- const envFile = path_1.default.join(instancesDir, entry.name, '.env');
4403
+ const { envFile, metaFile } = instancePaths(root, entry.name);
4404
+ const config = await inspectInstanceConfig(root, entry.name);
3831
4405
  let port = '(unknown)';
3832
- if (fs_1.default.existsSync(envFile)) {
4406
+ if (config.state.envPresent && config.state.envReadable) {
3833
4407
  try {
3834
4408
  const env = await readEnvFile(envFile);
3835
4409
  port = env.IRANTI_PORT ?? '(unknown)';
@@ -3838,11 +4412,15 @@ async function collectRuntimeInstanceSummaries(root) {
3838
4412
  port = '(unreadable)';
3839
4413
  }
3840
4414
  }
4415
+ const runtime = await readInstanceRuntimeSummary(root, entry.name);
3841
4416
  instances.push({
3842
4417
  name: entry.name,
3843
4418
  port,
3844
- envFile: fs_1.default.existsSync(envFile) ? envFile : '(missing)',
3845
- runtime: await readInstanceRuntimeSummary(root, entry.name),
4419
+ envFile: config.state.envPresent ? envFile : '(missing)',
4420
+ metaFile: config.state.metaPresent ? metaFile : '(missing)',
4421
+ config,
4422
+ runtime,
4423
+ repairHints: buildInstanceRepairHints(entry.name, config, runtime),
3846
4424
  });
3847
4425
  }
3848
4426
  return instances;
@@ -4108,9 +4686,20 @@ async function createInstanceCommand(args) {
4108
4686
  throw new Error(`Provider '${provider}' does not use a remote API key.`);
4109
4687
  }
4110
4688
  const { instanceDir, envFile, metaFile } = instancePaths(root, name);
4111
- if (fs_1.default.existsSync(instanceDir) && !hasFlag(args, 'force')) {
4689
+ const instanceAlreadyExisted = fs_1.default.existsSync(instanceDir);
4690
+ if (instanceAlreadyExisted && !hasFlag(args, 'force')) {
4112
4691
  throw new Error(`Instance '${name}' already exists at ${instanceDir}. Use --force to overwrite.`);
4113
4692
  }
4693
+ await assertPortAssignable(root, port, instanceAlreadyExisted ? name : undefined);
4694
+ // H-7: Register rollback if the instance dir is new (so SIGINT cleans up partial state)
4695
+ if (!instanceAlreadyExisted) {
4696
+ pushCleanup(async () => {
4697
+ try {
4698
+ await promises_1.default.rm(instanceDir, { recursive: true, force: true });
4699
+ }
4700
+ catch { }
4701
+ });
4702
+ }
4114
4703
  await ensureDir(instanceDir);
4115
4704
  await ensureDir(path_1.default.join(instanceDir, 'logs'));
4116
4705
  await ensureDir(path_1.default.join(instanceDir, 'escalation', 'active'));
@@ -4129,6 +4718,9 @@ async function createInstanceCommand(args) {
4129
4718
  instanceDir,
4130
4719
  };
4131
4720
  await writeJson(metaFile, meta);
4721
+ // Instance fully created — pop the rollback so it doesn't run on normal exit
4722
+ if (!instanceAlreadyExisted)
4723
+ popCleanup();
4132
4724
  console.log(sectionTitle('Instance Created'));
4133
4725
  console.log(` status ${okLabel()}`);
4134
4726
  console.log(` dir : ${instanceDir}`);
@@ -4183,15 +4775,18 @@ async function showInstanceCommand(args) {
4183
4775
  throw new Error('Missing instance name. Usage: iranti instance show <name>');
4184
4776
  const scope = normalizeScope(getFlag(args, 'scope'));
4185
4777
  const root = resolveInstallRoot(args, scope);
4186
- const instanceDir = path_1.default.join(root, 'instances', name);
4187
- const envFile = path_1.default.join(instanceDir, '.env');
4188
- if (!fs_1.default.existsSync(envFile))
4778
+ const { instanceDir, envFile } = instancePaths(root, name);
4779
+ const config = await inspectInstanceConfig(root, name);
4780
+ if (!config.state.metaPresent && !config.state.envPresent)
4189
4781
  throw new Error(`Instance '${name}' not found at ${instanceDir}`);
4190
- const env = await readEnvFile(envFile);
4782
+ const env = config.state.envPresent && config.state.envReadable
4783
+ ? await readEnvFile(envFile)
4784
+ : {};
4191
4785
  const runtime = await readInstanceRuntimeSummary(root, name);
4192
4786
  console.log(bold(`Instance: ${name}`));
4193
4787
  console.log(` dir : ${instanceDir}`);
4194
4788
  console.log(` env : ${envFile}`);
4789
+ console.log(` config: ${describeInstanceConfig(config)}`);
4195
4790
  console.log(` port: ${env.IRANTI_PORT ?? '3001'}`);
4196
4791
  console.log(` db : ${env.DATABASE_URL ?? '(missing)'}`);
4197
4792
  console.log(` esc : ${env.IRANTI_ESCALATION_DIR ?? '(missing)'}`);
@@ -4208,11 +4803,7 @@ async function runInstanceCommand(args) {
4208
4803
  }
4209
4804
  const scope = normalizeScope(getFlag(args, 'scope'));
4210
4805
  const root = resolveInstallRoot(args, scope);
4211
- const { instanceDir, envFile, runtimeFile } = instancePaths(root, name);
4212
- if (!fs_1.default.existsSync(envFile)) {
4213
- throw cliError('IRANTI_INSTANCE_NOT_FOUND', `Instance '${name}' not found. Create it first.`, [`Run \`iranti setup\` or \`iranti instance create ${name}\` first.`], { instance: name, envFile });
4214
- }
4215
- const env = await readEnvFile(envFile);
4806
+ const { instanceDir, envFile, runtimeFile, env } = await loadInstanceEnv(root, name);
4216
4807
  const runtime = await readInstanceRuntimeSummary(root, name);
4217
4808
  if (runtime.running) {
4218
4809
  throw cliError('IRANTI_INSTANCE_ALREADY_RUNNING', `Instance '${name}' is already running on pid ${runtime.state?.pid ?? '(unknown)'}.`, [`Run \`iranti instance restart ${name}\` to restart the live process, or stop the existing process first.`], { instance: name, pid: runtime.state?.pid ?? null, runtimeFile });
@@ -4226,6 +4817,13 @@ async function runInstanceCommand(args) {
4226
4817
  if (!process.env.DATABASE_URL || process.env.DATABASE_URL.includes('yourpassword')) {
4227
4818
  throw cliError('IRANTI_INSTANCE_DATABASE_PLACEHOLDER', `Instance '${name}' has placeholder DATABASE_URL. Edit ${envFile} first.`, ['Run `iranti configure instance <name> --interactive` or rerun `iranti setup`.'], { instance: name, envFile });
4228
4819
  }
4820
+ const port = Number.parseInt(env.IRANTI_PORT ?? '3001', 10);
4821
+ if (!Number.isFinite(port) || port <= 0) {
4822
+ throw cliError('IRANTI_INSTANCE_PORT_INVALID', `Instance '${name}' has invalid IRANTI_PORT in ${envFile}.`, ['Run `iranti configure instance <name> --port <n>` to repair it.'], { instance: name, envFile, port: env.IRANTI_PORT ?? null });
4823
+ }
4824
+ if (!(await isPortUsable(port, '0.0.0.0', listPublishedDockerHostPorts()))) {
4825
+ throw cliError('IRANTI_INSTANCE_PORT_IN_USE', `Cannot start instance '${name}' because port ${port} is already in use.`, ['Run `iranti configure instance <name> --port <n>` or free the port before retrying.'], { instance: name, envFile, port });
4826
+ }
4229
4827
  await startInstanceRuntime(name, instanceDir, envFile, runtimeFile);
4230
4828
  }
4231
4829
  async function restartInstanceCommand(args) {
@@ -4293,7 +4891,7 @@ async function configureInstanceCommand(args) {
4293
4891
  }
4294
4892
  const scope = normalizeScope(getFlag(args, 'scope'));
4295
4893
  const root = resolveInstallRoot(args, scope);
4296
- const { envFile, env } = await loadInstanceEnv(root, name);
4894
+ const { instanceDir, envFile, env, config } = await loadInstanceEnv(root, name, { allowRepair: true });
4297
4895
  const updates = {};
4298
4896
  let portRaw = getFlag(args, 'port');
4299
4897
  let dbUrl = getFlag(args, 'db-url');
@@ -4319,6 +4917,7 @@ async function configureInstanceCommand(args) {
4319
4917
  const port = Number.parseInt(portRaw, 10);
4320
4918
  if (!Number.isFinite(port) || port <= 0)
4321
4919
  throw new Error(`Invalid --port '${portRaw}'.`);
4920
+ await assertPortAssignable(root, port, name);
4322
4921
  updates.IRANTI_PORT = String(port);
4323
4922
  }
4324
4923
  if (dbUrl)
@@ -4345,7 +4944,26 @@ async function configureInstanceCommand(args) {
4345
4944
  if (Object.keys(updates).length === 0) {
4346
4945
  throw new Error('No changes provided. Use flags like --provider, --provider-key, --api-key, --db-url, or --port.');
4347
4946
  }
4947
+ const nextEnv = { ...env };
4948
+ for (const [key, value] of Object.entries(updates)) {
4949
+ if (value === undefined) {
4950
+ delete nextEnv[key];
4951
+ }
4952
+ else {
4953
+ nextEnv[key] = value;
4954
+ }
4955
+ }
4956
+ const nextPortRaw = nextEnv.IRANTI_PORT?.trim();
4957
+ const nextPort = Number.parseInt(nextPortRaw ?? '', 10);
4958
+ if (!Number.isFinite(nextPort) || nextPort <= 0) {
4959
+ throw cliError('IRANTI_INSTANCE_PORT_REQUIRED', `Instance '${name}' still needs a valid IRANTI_PORT before it can be considered repaired.`, ['Pass `--port <n>` or rerun `iranti configure instance <name> --interactive`.'], { instance: name, envFile, port: nextPortRaw ?? null, config: config.classification });
4960
+ }
4961
+ if (!nextEnv.DATABASE_URL?.trim()) {
4962
+ throw cliError('IRANTI_INSTANCE_DATABASE_REQUIRED', `Instance '${name}' still needs DATABASE_URL before it can be considered repaired.`, ['Pass `--db-url <postgresql://...>` or rerun `iranti configure instance <name> --interactive`.'], { instance: name, envFile, config: config.classification });
4963
+ }
4964
+ await ensureDir(instanceDir);
4348
4965
  await upsertEnvFile(envFile, updates);
4966
+ await syncInstanceMeta(root, name, nextPort);
4349
4967
  const json = hasFlag(args, 'json');
4350
4968
  const result = {
4351
4969
  instance: name,
@@ -4638,6 +5256,113 @@ async function attendCommand(args) {
4638
5256
  console.log('');
4639
5257
  console.log(`${infoLabel()} This is a manual Attendant inspection tool. Claude Code should still use hooks + MCP in normal operation.`);
4640
5258
  }
5259
+ async function handoffCommand(args) {
5260
+ const json = hasFlag(args, 'json');
5261
+ const target = await resolveAttendantCliTarget(args);
5262
+ const taskEntity = resolveTaskEntity(args);
5263
+ const projectEntity = getFlag(args, 'project-entity')?.trim();
5264
+ if (projectEntity && !projectEntity.includes('/')) {
5265
+ throw new Error('project-entity must use entityType/entityId format.');
5266
+ }
5267
+ const nextStep = getFlag(args, 'next-step')?.trim();
5268
+ if (!nextStep) {
5269
+ throw new Error('Missing --next-step. A standardized handoff must record the receiver action.');
5270
+ }
5271
+ const status = getFlag(args, 'status')?.trim() || 'ready_for_handoff';
5272
+ const owner = getFlag(args, 'owner')?.trim();
5273
+ const blockers = parseDelimitedList(getFlag(args, 'blockers'));
5274
+ const artifacts = parseDelimitedList(getFlag(args, 'artifacts'));
5275
+ const notes = getFlag(args, 'notes')?.trim();
5276
+ const source = getFlag(args, 'source')?.trim() || 'CLIHandoff';
5277
+ const confidence = parsePositiveInteger(getFlag(args, 'confidence'), 'confidence') ?? 95;
5278
+ if (confidence > 100) {
5279
+ throw new Error('confidence must be <= 100.');
5280
+ }
5281
+ const writes = [];
5282
+ writes.push({
5283
+ entity: taskEntity,
5284
+ key: 'status',
5285
+ value: { state: status },
5286
+ summary: buildHandoffSummary('status', { state: status }),
5287
+ });
5288
+ writes.push({
5289
+ entity: taskEntity,
5290
+ key: 'next_step',
5291
+ value: { instruction: nextStep },
5292
+ summary: buildHandoffSummary('next_step', { instruction: nextStep }),
5293
+ });
5294
+ if (owner) {
5295
+ writes.push({
5296
+ entity: taskEntity,
5297
+ key: 'current_owner',
5298
+ value: { agentId: owner },
5299
+ summary: buildHandoffSummary('current_owner', { agentId: owner }),
5300
+ });
5301
+ }
5302
+ if (blockers.length > 0) {
5303
+ writes.push({
5304
+ entity: taskEntity,
5305
+ key: 'blockers',
5306
+ value: { items: blockers },
5307
+ summary: buildHandoffSummary('blockers', { items: blockers }),
5308
+ });
5309
+ }
5310
+ if (artifacts.length > 0) {
5311
+ writes.push({
5312
+ entity: taskEntity,
5313
+ key: 'artifacts',
5314
+ value: { files: artifacts },
5315
+ summary: buildHandoffSummary('artifacts', { files: artifacts }),
5316
+ });
5317
+ }
5318
+ if (notes) {
5319
+ writes.push({
5320
+ entity: taskEntity,
5321
+ key: 'notes',
5322
+ value: { text: notes },
5323
+ summary: buildHandoffSummary('notes', { text: notes }),
5324
+ });
5325
+ }
5326
+ if (projectEntity) {
5327
+ writes.push({
5328
+ entity: projectEntity,
5329
+ key: 'active_handoff_task',
5330
+ value: {
5331
+ taskEntity,
5332
+ owner: owner ?? null,
5333
+ status,
5334
+ updatedBy: target.agentId,
5335
+ },
5336
+ summary: buildHandoffSummary('active_handoff_task', { taskEntity }),
5337
+ });
5338
+ }
5339
+ for (const write of writes) {
5340
+ await target.iranti.write({
5341
+ entity: write.entity,
5342
+ key: write.key,
5343
+ value: write.value,
5344
+ summary: write.summary,
5345
+ confidence,
5346
+ source,
5347
+ agent: target.agentId,
5348
+ });
5349
+ }
5350
+ if (json) {
5351
+ console.log(JSON.stringify({
5352
+ agent: target.agentId,
5353
+ envSource: target.envSource,
5354
+ envFile: target.envFile,
5355
+ source,
5356
+ confidence,
5357
+ writes,
5358
+ }, null, 2));
5359
+ process.exit(0);
5360
+ }
5361
+ printHandoffResult(target, taskEntity, writes);
5362
+ console.log('');
5363
+ console.log(`${infoLabel()} Handoffs are shared-memory facts. Pair this with checkpoint() if the sender also needs agent-local recovery.`);
5364
+ process.exit(0);
5365
+ }
4641
5366
  function printClaudeSetupHelp() {
4642
5367
  console.log([
4643
5368
  'Scaffold Claude Code MCP and hook files for the current project.',
@@ -4930,12 +5655,14 @@ function printHelp() {
4930
5655
  ['iranti remove api-key [provider] [--instance <name>] [--project <path>] [--json]', 'Remove a stored provider key.'],
4931
5656
  ]);
4932
5657
  printRows('Diagnostics And Operator Tools', [
5658
+ ['iranti version', 'Print the installed CLI version and exit.'],
4933
5659
  ['iranti doctor [--instance <name>] [--scope user|system] [--env <file>] [--json] [--debug]', 'Run environment and runtime diagnostics.'],
4934
5660
  ['iranti status [--scope user|system] [--json]', 'Show runtime roots, bindings, and known instances.'],
4935
5661
  ['iranti upgrade [--check] [--dry-run] [--yes] [--all] [--target auto|npm-global|npm-repo|python[,python]] [--json]', 'Check or run CLI/runtime/package upgrades.'],
4936
5662
  ['iranti uninstall [--dry-run] [--yes] [--all] [--keep-data] [--keep-project-bindings] [--scan-root <dir[,dir2]>] [--json]', 'Remove Iranti packages and, with --all, runtime data and project integrations.'],
4937
5663
  ['iranti handshake [--instance <name> | --project-env <file>] [--agent <id>] [--task <text>] [--recent <msg1||msg2>] [--recent-file <path>] [--json]', 'Manually inspect Attendant handshake output.'],
4938
5664
  ['iranti attend [message] [--instance <name> | --project-env <file>] [--agent <id>] [--context <text> | --context-file <path>] [--entity-hint <entity>] [--force] [--max-facts <n>] [--json]', 'Manually inspect turn-level memory injection decisions.'],
5665
+ ['iranti handoff task/<task_id> [--instance <name> | --project-env <file>] [--agent <id>] --next-step <text> [--status <state>] [--owner <agent-id>] [--blockers <a||b>] [--artifacts <path1||path2>] [--project-entity <entity>] [--notes <text>] [--source <label>] [--confidence <n>] [--json]', 'Write a standardized shared-memory handoff for Claude/Codex collaboration.'],
4939
5666
  ['iranti chat [--agent <agent-id>] [--provider <provider>] [--model <model>]', 'Open the local interactive chat shell.'],
4940
5667
  ['iranti resolve [--dir <escalation-dir>]', 'Walk through pending escalation files.'],
4941
5668
  ]);
@@ -5021,6 +5748,10 @@ async function main() {
5021
5748
  subcommand: args.subcommand,
5022
5749
  cwd: process.cwd(),
5023
5750
  });
5751
+ if (args.command === '--version' || args.command === 'version' || hasFlag(args, 'version')) {
5752
+ console.log(getPackageVersion());
5753
+ return;
5754
+ }
5024
5755
  if (!args.command || args.command === 'help' || args.command === '--help') {
5025
5756
  printHelp();
5026
5757
  return;
@@ -5162,6 +5893,10 @@ async function main() {
5162
5893
  await attendCommand(args);
5163
5894
  return;
5164
5895
  }
5896
+ if (args.command === 'handoff') {
5897
+ await handoffCommand(args);
5898
+ return;
5899
+ }
5165
5900
  if (args.command === 'chat') {
5166
5901
  await chatCommand(args);
5167
5902
  return;