@turri-dev/cli 0.2.0 → 0.2.1-next.47b578c

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 (80) hide show
  1. package/README.md +3 -2
  2. package/dist/chats.d.ts +31 -4
  3. package/dist/chats.js +146 -52
  4. package/dist/cli.js +4 -2
  5. package/dist/config.d.ts +4 -1
  6. package/dist/config.js +39 -4
  7. package/dist/environment.d.ts +0 -2
  8. package/dist/environment.js +0 -6
  9. package/dist/extension.d.ts +4 -3
  10. package/dist/extension.js +110 -23
  11. package/dist/host-session.d.ts +14 -0
  12. package/dist/host-session.js +2 -0
  13. package/dist/host.d.ts +6 -1
  14. package/dist/host.js +5 -1
  15. package/dist/init-config.d.ts +1 -1
  16. package/dist/init-config.js +33 -3
  17. package/dist/installed-providers.d.ts +40 -0
  18. package/dist/installed-providers.js +98 -0
  19. package/dist/model-check.d.ts +4 -2
  20. package/dist/model-check.js +33 -17
  21. package/dist/notice.d.ts +30 -6
  22. package/dist/pi.js +8 -24
  23. package/dist/plain/files.d.ts +16 -0
  24. package/dist/plain/files.js +68 -5
  25. package/dist/plain/graph.js +2 -2
  26. package/dist/plain/module-concepts.js +3 -2
  27. package/dist/plain/parser.d.ts +8 -3
  28. package/dist/plain/parser.js +47 -6
  29. package/dist/plain/stdlib.js +3 -3
  30. package/dist/regenerate/base-folder.d.ts +2 -0
  31. package/dist/regenerate/base-folder.js +24 -6
  32. package/dist/regenerate/command-check.d.ts +12 -1
  33. package/dist/regenerate/command-check.js +74 -0
  34. package/dist/regenerate/confine.d.ts +5 -0
  35. package/dist/regenerate/confine.js +9 -9
  36. package/dist/regenerate/ledger.d.ts +7 -0
  37. package/dist/regenerate/lineage.js +43 -2
  38. package/dist/regenerate/loop.d.ts +8 -0
  39. package/dist/regenerate/loop.js +11 -4
  40. package/dist/regenerate/os-sandbox.d.ts +2 -0
  41. package/dist/regenerate/os-sandbox.js +55 -20
  42. package/dist/regenerate/release.d.ts +2 -0
  43. package/dist/regenerate/requires.d.ts +12 -0
  44. package/dist/regenerate/requires.js +70 -1
  45. package/dist/regenerate/spec-steps.js +18 -1
  46. package/dist/regenerate/standing.d.ts +28 -0
  47. package/dist/regenerate/standing.js +30 -0
  48. package/dist/regenerate/testing.d.ts +2 -0
  49. package/dist/regenerate/testing.js +14 -0
  50. package/dist/release-agent.d.ts +17 -0
  51. package/dist/release-agent.js +19 -7
  52. package/dist/release-tools.d.ts +6 -0
  53. package/dist/release-tools.js +24 -5
  54. package/dist/sentinel-chat.d.ts +1 -1
  55. package/dist/sentinel-chat.js +21 -8
  56. package/dist/sentinel-check.d.ts +36 -0
  57. package/dist/sentinel-check.js +78 -0
  58. package/dist/sentinel.d.ts +40 -9
  59. package/dist/sentinel.js +240 -37
  60. package/dist/setup.d.ts +24 -3
  61. package/dist/setup.js +117 -31
  62. package/dist/spawn-async.d.ts +18 -0
  63. package/dist/spawn-async.js +35 -0
  64. package/dist/stack.d.ts +14 -0
  65. package/dist/stack.js +44 -2
  66. package/dist/turri-versions.d.ts +71 -0
  67. package/dist/turri-versions.js +275 -0
  68. package/docs/README.md +2 -0
  69. package/docs/claude-subscription.md +131 -0
  70. package/docs/config.md +5 -0
  71. package/docs/deploy.md +2 -2
  72. package/docs/hosting.md +42 -4
  73. package/docs/installation.md +39 -2
  74. package/docs/parser.md +1 -1
  75. package/docs/regenerating.md +1 -1
  76. package/docs/sandbox.md +2 -1
  77. package/docs/sentinel.md +11 -3
  78. package/docs/using.md +4 -2
  79. package/genome/rules/turri-regeneration.md +1 -1
  80. package/package.json +1 -1
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  ```bash
21
21
  npm install -g @turri-dev/cli # puts `turri` on your PATH
22
+ turri --version # the version you installed? if not, see docs/installation.md#an-older-turri-runs
22
23
  turri doctor # checks the machine
23
24
  ```
24
25
 
@@ -30,7 +31,7 @@ Then start it in your project, signed in to your model providers:
30
31
 
31
32
  ```bash
32
33
  cd ~/my-project
33
- turri init-config # asks which vendor you use: Anthropic, OpenAI, Google, Amazon Bedrock, or other
34
+ turri init-config # asks which vendor you use: Anthropic, OpenAI, Google, Amazon Bedrock, a Claude subscription, or other
34
35
  export ANTHROPIC_API_KEY=... # the key it names - or skip it, and type /login in turri once it has started
35
36
  turri
36
37
  ```
@@ -42,7 +43,7 @@ harness-model: anthropic/claude-opus-5-5:medium # you talk to it
42
43
  coding-model: anthropic/claude-haiku-5-5:low # it writes the code
43
44
  ```
44
45
 
45
- To skip the question, name the vendor - `anthropic`, `openai`, `google`, `amazon-bedrock` (with `--maker` and `--region`) or `other`:
46
+ To skip the question, name the vendor - `anthropic`, `openai`, `google`, `amazon-bedrock` (with `--maker` and `--region`), `claude-code` - your Claude Pro or Max subscription, through Claude Code ([how](docs/claude-subscription.md)) - or `other`:
46
47
 
47
48
  ```bash
48
49
  turri init-config --vendor anthropic
package/dist/chats.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { AskUserIO } from "./ask.ts";
7
7
  import type { Job, JobRegistry } from "./regenerate/jobs.ts";
8
8
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
9
9
  import type { AdvisorVerdict } from "./regenerate/advisor-runs.ts";
10
- import { type ChatRole, type Conclusion, type Notice, type NoticeAnswer, type NoticeInput } from "./notice.ts";
10
+ import { type ChatRole, type Conclusion, type Notice, type NoticeAnswer, type NoticeInput, type SentinelCheck } from "./notice.ts";
11
11
  /**
12
12
  * The chats of a turri session.
13
13
  *
@@ -73,6 +73,13 @@ export interface TurriHost {
73
73
  onConcluded?(conclusion: Conclusion & {
74
74
  chat: string;
75
75
  }): void;
76
+ /**
77
+ * The host shows the person each pulse that lands and each of the sentinel's conclusions itself - its own
78
+ * notifications, from onJobChange and onConcluded. With the sentinel on, the chat the person is in is then told of both
79
+ * without a turn of its own: its agent knows what landed and what the sentinel found when the person asks, and does
80
+ * not say it again in the conversation. Unset, it says both, as in the terminal.
81
+ */
82
+ pulseEndsShown?: boolean;
76
83
  /** What the sentinel puts to the person - and, `kind: "sentinel-start"`, whether to start it: the host draws it in the chat it names, and answers it with `answer`. */
77
84
  onNotice?(notice: Notice): void;
78
85
  /** A notice is over - answered, dismissed, or superseded by a newer one about its module: the host takes its card down. */
@@ -103,6 +110,14 @@ export interface ChatInstance {
103
110
  asked?: string;
104
111
  focused?: string;
105
112
  }): Promise<void>;
113
+ /**
114
+ * The sentinel's: functional specs a host asked it to check - the module released first, unless what runs is its last
115
+ * commit, then its agent told, in a conversation of its own for the check. Why not, when the release failed: then its
116
+ * agent is told nothing.
117
+ */
118
+ briefCheck(check: SentinelCheck, context: {
119
+ focused?: string;
120
+ }): Promise<string | undefined>;
106
121
  /** A notice put in the chat: drawn, and - in the terminal - put to the person once the chat is idle. */
107
122
  notice(notice: Notice): void;
108
123
  /** A notice is over: its dialog closed, if it is open. */
@@ -219,8 +234,20 @@ export interface TurriCore {
219
234
  sentinelOn(): boolean;
220
235
  /** A notice put to the person, in the chat they look at - the sentinel's, or turri's own. Undefined: no chat to put it in. */
221
236
  notice(input: NoticeInput): Notice | undefined;
222
- /** The sentinel done with a pulse: told to the chat the person looks at. Undefined: no chat to tell. */
237
+ /** The sentinel done with a pulse or a check: told to the chat the person looks at. Undefined: no chat to tell. */
223
238
  concluded(conclusion: Conclusion): string | undefined;
239
+ /**
240
+ * A host's check for the sentinel: refused with the sentinel off; else `prepare` finds its module and functional specs
241
+ * good (sentinel-check.ts), and it is queued with the pulses - under `sentinel: ask`, held while the person is asked
242
+ * whether to start it.
243
+ */
244
+ check(prepare: () => SentinelCheck | {
245
+ refused: string;
246
+ }): {
247
+ check: string;
248
+ } | {
249
+ refused: string;
250
+ };
224
251
  /** The test advisor's verdict, for the chat the person looks at - held until there is one. Undefined: no chat to hand it to yet. */
225
252
  advised(verdict: AdvisorVerdict): string | undefined;
226
253
  /** The notices put to the person and not yet answered. */
@@ -252,8 +279,8 @@ export interface CoreOptions {
252
279
  dialogs?: (chat: string) => AskUserIO | undefined;
253
280
  /** Whether the project wants a sentinel - read as each pulse finishes: on, off, or ask the person first. Unset: none. */
254
281
  sentinel?: () => SentinelSetting;
255
- /** Under `sentinel: ask`: what the person is asked before the sentinel first opens, about the pulse that would open it. */
256
- sentinelStart?: (job: Job) => NoticeInput;
282
+ /** Under `sentinel: ask`: what the person is asked before the sentinel first opens, about the pulse - or the check - that would open it. */
283
+ sentinelStart?: (about: Job | SentinelCheck) => NoticeInput;
257
284
  /** The person chose for the project, asked whether to start the sentinel: `sentinel:` written. */
258
285
  setSentinel?: (on: boolean) => void;
259
286
  /** The person ignored a notice for good: kept in the sentinel's guidance, for every conversation after. */
package/dist/chats.js CHANGED
@@ -91,11 +91,13 @@ export function createChats(options) {
91
91
  let notices = [];
92
92
  /** What the sentinel put to the person, and turri itself, this session: open until answered or superseded. */
93
93
  const open = [];
94
- /** The pulse the sentinel's conversation is on, from its brief until its agent is done: a newer one of the same module replaces a check of one that went well. */
94
+ /** The work the sentinel's conversation is on, from its brief until its agent is done: a newer pulse of the same module replaces a check of one that went well. */
95
95
  let checking;
96
- /** Pulses that finished while the sentinel was at work: each checked in turn, in a conversation of its own. */
96
+ /** Pulses that finished, and checks asked for, while the sentinel was at work: each taken in turn, in a conversation of its own. */
97
97
  const waiting = [];
98
- /** What the sentinel is given is given one pulse at a time: its conversations made, and its pulses put to them, in order. */
98
+ /** The checks hosts asked for this session, by id: what a notice or a conclusion about one is about. */
99
+ const checks = new Map();
100
+ /** What the sentinel is given is given one at a time: its conversations made, and its pulses and checks put to them, in order. */
99
101
  let sentinelWork = Promise.resolve();
100
102
  /**
101
103
  * Under `sentinel: ask`, whether the person said to start the sentinel this session: asked - the notice `asking` open,
@@ -188,7 +190,7 @@ export function createChats(options) {
188
190
  // Done with its pulse - or with what the person said to it: the next pulse waiting, in a conversation of its own.
189
191
  if (chat.role === SENTINEL && event.willRetry !== true) {
190
192
  checking = undefined;
191
- toSentinel(() => nextPulse(chat));
193
+ toSentinel(() => nextWork(chat));
192
194
  }
193
195
  }
194
196
  else if (event.type === "session_info_changed") {
@@ -277,7 +279,10 @@ export function createChats(options) {
277
279
  // Closed, the sentinel is let go with what it had to check: the next pulse that finishes opens it again.
278
280
  if (chat.role === SENTINEL) {
279
281
  checking = undefined;
280
- waiting.length = 0;
282
+ // A check a host asked for ends all the same: it is told it was not done.
283
+ for (const work of waiting.splice(0))
284
+ if ("check" in work)
285
+ unchecked(work.check, "the sentinel was closed before it got to it");
281
286
  }
282
287
  // The question whether to start the sentinel was in it: put again in the chat the person looks at now.
283
288
  if (asking?.chat === id)
@@ -384,7 +389,7 @@ export function createChats(options) {
384
389
  settleStart(setting() === true ? "given" : "declined", "superseded");
385
390
  if (core.sentinelOn()) {
386
391
  if (consent === "asking" || (setting() === "ask" && consent === "unasked" && sentinelChat() === undefined && askToStart(job))) {
387
- held.push(job);
392
+ held.push({ pulse: job });
388
393
  changed();
389
394
  return;
390
395
  }
@@ -393,6 +398,30 @@ export function createChats(options) {
393
398
  }
394
399
  toAsker(job);
395
400
  },
401
+ check(prepare) {
402
+ if (driver === undefined)
403
+ return { refused: "There is nowhere to open the sentinel in this session." };
404
+ // The question was settled in config.turri.yaml while it was open: what it held goes as the file now says.
405
+ if (consent === "asking" && setting() !== "ask")
406
+ settleStart(setting() === true ? "given" : "declined", "superseded");
407
+ const now = setting();
408
+ if (now === false)
409
+ return { refused: "The sentinel is off for this project (sentinel: false): nothing checks the functional specs. Turn it on with sentinel: true in config.turri.yaml." };
410
+ if (!wantsSentinel())
411
+ return { refused: "The sentinel was not started this session - the person said not now when asked: nothing checks the functional specs until turri starts again, or sentinel: true is set." };
412
+ const check = prepare();
413
+ if ("refused" in check)
414
+ return check;
415
+ checks.set(check.id, check);
416
+ record({ kind: "sentinel.asked", check: check.id, module: check.module, funcIds: check.funcIds }, { project: cwd });
417
+ if (consent === "asking" || (now === "ask" && consent === "unasked" && sentinelChat() === undefined && askToStart(check))) {
418
+ held.push({ check });
419
+ changed();
420
+ return { check: check.id };
421
+ }
422
+ sentinelChecks(check);
423
+ return { check: check.id };
424
+ },
396
425
  jobsChanged() {
397
426
  for (const chat of chats) {
398
427
  try {
@@ -404,18 +433,22 @@ export function createChats(options) {
404
433
  }
405
434
  },
406
435
  notice(input) {
436
+ // A check's notice is about its module.
437
+ const about = input.check !== undefined ? checks.get(input.check) : undefined;
438
+ if (about !== undefined && input.module === undefined)
439
+ input = { ...input, module: about.module };
407
440
  const asked = chats.find((chat) => chat.id === (input.job !== undefined ? jobs.get(input.job)?.chat : undefined));
408
441
  const target = personFacing(asked);
409
442
  if (target === undefined)
410
443
  return undefined;
411
- // One notice open a module: a newer one makes the older one's call to action stale.
412
- if (input.module !== undefined) {
444
+ // One notice open a module: a newer one makes the older one's call to action stale. A note of a fix asks nothing.
445
+ if (input.module !== undefined && input.kind !== "fixed") {
413
446
  for (const older of open.filter((notice) => notice.state === "open" && notice.module === input.module))
414
447
  over(older, "superseded");
415
448
  }
416
449
  // Only the sentinel's own may be ignored: never the question whether to start it.
417
450
  const { ignorable, ...rest } = input;
418
- const notice = newNotice(ignorable === true && input.kind !== "sentinel-start" ? input : rest, target.id);
451
+ const notice = newNotice(ignorable === true && input.kind === undefined ? input : rest, target.id);
419
452
  open.push(notice);
420
453
  if (target.id !== shownId)
421
454
  target.unread = true;
@@ -437,16 +470,17 @@ export function createChats(options) {
437
470
  },
438
471
  notices: () => open.filter((notice) => notice.state === "open"),
439
472
  concluded(conclusion) {
440
- const job = jobs.get(conclusion.job);
473
+ const job = conclusion.job !== undefined ? jobs.get(conclusion.job) : undefined;
474
+ const check = conclusion.check !== undefined ? checks.get(conclusion.check) : undefined;
441
475
  const target = personFacing(chats.find((chat) => chat.id === job?.chat));
442
476
  if (target === undefined)
443
477
  return undefined;
444
- const module = conclusion.module ?? job?.module;
478
+ const module = conclusion.module ?? job?.module ?? check?.module;
445
479
  const pulse = conclusion.pulse ?? job?.pulse;
446
480
  const told = { ...conclusion, ...(module !== undefined ? { module } : {}), ...(pulse !== undefined ? { pulse } : {}) };
447
481
  if (target.id !== shownId)
448
482
  target.unread = true;
449
- inChat(target.scope, () => record({ kind: "sentinel.concluded", verdict: told.verdict, ...(module !== undefined ? { module } : {}) }, { project: cwd, job: told.job }));
483
+ inChat(target.scope, () => record({ kind: "sentinel.concluded", verdict: told.verdict, ...(module !== undefined ? { module } : {}), ...(told.check !== undefined ? { check: told.check } : {}) }, { project: cwd, ...(told.job !== undefined ? { job: told.job } : {}) }));
450
484
  try {
451
485
  target.instance?.concluded(told);
452
486
  }
@@ -577,10 +611,10 @@ export function createChats(options) {
577
611
  * Under `sentinel: ask`, the first pulse that would open the sentinel this session: the person asked whether to start it,
578
612
  * in the chat they look at. False when there is nothing to ask with, or no chat to ask in: the sentinel opens as asked.
579
613
  */
580
- function askToStart(job) {
614
+ function askToStart(about) {
581
615
  let input;
582
616
  try {
583
- input = options.sentinelStart?.(job);
617
+ input = options.sentinelStart?.(about);
584
618
  }
585
619
  catch {
586
620
  input = undefined;
@@ -599,7 +633,8 @@ export function createChats(options) {
599
633
  over(question, "superseded");
600
634
  asking = undefined;
601
635
  consent = "unasked";
602
- if (!askToStart(held[0]))
636
+ const first = held[0];
637
+ if (!askToStart("pulse" in first ? first.pulse : first.check))
603
638
  settleStart("declined", "superseded");
604
639
  }
605
640
  /** The person answered whether to start the sentinel: written to the project, when they chose for it, and what was held let go. */
@@ -629,11 +664,17 @@ export function createChats(options) {
629
664
  asking = undefined;
630
665
  if (question !== undefined && question.state === "open")
631
666
  over(question, state);
632
- for (const job of held.splice(0)) {
633
- if (core.sentinelOn())
634
- sentinelFinished(job);
667
+ for (const work of held.splice(0)) {
668
+ if ("check" in work) {
669
+ if (core.sentinelOn())
670
+ sentinelChecks(work.check);
671
+ else
672
+ unchecked(work.check, "the person did not start the sentinel");
673
+ }
674
+ else if (core.sentinelOn())
675
+ sentinelFinished(work.pulse);
635
676
  else
636
- toAsker(job);
677
+ toAsker(work.pulse);
637
678
  }
638
679
  changed();
639
680
  }
@@ -658,24 +699,41 @@ export function createChats(options) {
658
699
  }
659
700
  changed();
660
701
  toSentinel(async () => {
661
- let sentinel = sentinelChat();
662
- if (sentinel === undefined) {
663
- try {
664
- await core.open({ hidden: true, role: SENTINEL });
665
- sentinel = sentinelChat();
666
- }
667
- catch (error) {
668
- // No room for it, or nowhere to open it: the pulse is told as it is without one.
669
- core.notify(`The sentinel could not be opened: ${error instanceof Error ? error.message : String(error)}`, "warning");
670
- (asked ?? where)?.instance?.announce(job);
671
- return;
672
- }
673
- }
674
- if (sentinel?.instance === undefined)
675
- return;
676
- await takePulse(sentinel, job);
702
+ // No room for it, or nowhere to open it: the pulse is told as it is without one.
703
+ const sentinel = await openSentinel(() => (asked ?? where)?.instance?.announce(job));
704
+ if (sentinel !== undefined)
705
+ await takeWork(sentinel, { pulse: job });
677
706
  });
678
707
  }
708
+ /** A check a host asked for, for the sentinel - opened in the background when it is not open - in its turn. */
709
+ function sentinelChecks(check) {
710
+ toSentinel(async () => {
711
+ const sentinel = await openSentinel((why) => unchecked(check, `the sentinel could not be opened: ${why}`));
712
+ if (sentinel !== undefined)
713
+ await takeWork(sentinel, { check });
714
+ });
715
+ }
716
+ /** The sentinel's chat, opened hidden when it is not open - none, and `failed` told why, when it cannot be. */
717
+ async function openSentinel(failed) {
718
+ let sentinel = sentinelChat();
719
+ if (sentinel === undefined) {
720
+ try {
721
+ await core.open({ hidden: true, role: SENTINEL });
722
+ sentinel = sentinelChat();
723
+ }
724
+ catch (error) {
725
+ const why = error instanceof Error ? error.message : String(error);
726
+ core.notify(`The sentinel could not be opened: ${why}`, "warning");
727
+ failed(why);
728
+ return undefined;
729
+ }
730
+ }
731
+ return sentinel?.instance !== undefined ? sentinel : undefined;
732
+ }
733
+ /** A check that will not be done, told as a conclusion of turri's own - the host waits for one. */
734
+ function unchecked(check, why) {
735
+ core.concluded({ check: check.id, module: check.module, verdict: "unchecked", summary: `Not checked: ${why}.` });
736
+ }
679
737
  /** The sentinel's work, after what it was given before. */
680
738
  function toSentinel(work) {
681
739
  sentinelWork = sentinelWork.then(work).catch((error) => {
@@ -685,36 +743,72 @@ export function createChats(options) {
685
743
  /** Whether the sentinel is at work: on a pulse - its turn starts as it is told of one - or on what the person said to it. */
686
744
  const sentinelBusy = (sentinel) => sentinel.session?.isStreaming === true;
687
745
  /**
688
- * A finished pulse, for the sentinel: checked now when it is free. Busy, a newer pulse of a module replaces a check of
689
- * the module's last pulse that went well - and a pulse of it that went well and waits - and one that went wrong is
690
- * looked into to its end; the rest wait their turn.
746
+ * A finished pulse or a host's check, for the sentinel: taken now when it is free. Busy, a newer pulse of a module
747
+ * replaces a check of the module's last pulse that went well - and a pulse of it that went well and waits - and one that
748
+ * went wrong is looked into to its end; the rest, checks among them, wait their turn.
691
749
  */
692
- function takePulse(sentinel, job) {
750
+ function takeWork(sentinel, work) {
693
751
  if (!sentinelBusy(sentinel))
694
- return check(sentinel, job);
695
- const sameModule = (other) => job.module !== undefined && other.module === job.module;
696
- if (checking !== undefined && !checking.wrong && sameModule(checking.job))
697
- return check(sentinel, job, checking.job);
698
- const older = waiting.findIndex((other) => sameModule(other) && !wentWrong(other));
752
+ return begin(sentinel, work);
753
+ if (!("pulse" in work)) {
754
+ waiting.push(work);
755
+ return;
756
+ }
757
+ const job = work.pulse;
758
+ const sameModule = (other) => job.module !== undefined && "pulse" in other && other.pulse.module === job.module;
759
+ if (checking !== undefined && !checking.wrong && sameModule(checking.work) && "pulse" in checking.work)
760
+ return check(sentinel, job, checking.work.pulse);
761
+ const older = waiting.findIndex((other) => sameModule(other) && "pulse" in other && !wentWrong(other.pulse));
699
762
  if (older !== -1)
700
- waiting.splice(older, 1, job);
763
+ waiting.splice(older, 1, work);
701
764
  else
702
- waiting.push(job);
765
+ waiting.push(work);
703
766
  }
704
- /** The sentinel free again: the next pulse that waits, checked. */
705
- function nextPulse(sentinel) {
767
+ /** The sentinel free again: the next pulse or check that waits, taken. */
768
+ function nextWork(sentinel) {
706
769
  if (!chats.includes(sentinel) || sentinelBusy(sentinel))
707
770
  return;
708
- const job = waiting.shift();
709
- if (job !== undefined)
710
- return check(sentinel, job);
771
+ const work = waiting.shift();
772
+ if (work !== undefined)
773
+ return begin(sentinel, work);
774
+ }
775
+ function begin(sentinel, work) {
776
+ return "pulse" in work ? check(sentinel, work.pulse) : checkSpecs(sentinel, work.check);
777
+ }
778
+ /**
779
+ * The sentinel on a host's check, from scratch, as on a pulse: a conversation of its own, the module released first
780
+ * unless what runs is its last commit. A release that fails ends the check - a conclusion of its own - and the next
781
+ * waiting is taken.
782
+ */
783
+ async function checkSpecs(sentinel, check) {
784
+ checking = { work: { check }, wrong: false };
785
+ inChat(sentinel.scope, () => record({ kind: "sentinel.checking", module: check.module, check: check.id }, { project: cwd }));
786
+ const session = sentinel.session;
787
+ if (session !== undefined && (session.messages.length > 0 || session.isStreaming))
788
+ await freshConversation(sentinel);
789
+ if (sentinel.id !== shownId)
790
+ sentinel.unread = true;
791
+ const where = personFacing();
792
+ let why;
793
+ try {
794
+ why = await sentinel.instance.briefCheck(check, where !== undefined ? { focused: titleFor(where) } : {});
795
+ }
796
+ catch (error) {
797
+ why = error instanceof Error ? error.message : String(error);
798
+ }
799
+ changed();
800
+ if (why === undefined)
801
+ return;
802
+ checking = undefined;
803
+ core.concluded({ check: check.id, module: check.module, verdict: "wrong", summary: `Not tested: ${check.module} could not be released - ${why}` });
804
+ await nextWork(sentinel);
711
805
  }
712
806
  /**
713
807
  * The sentinel on a pulse, from scratch: a conversation of its own - its prompt, the project's guidance and this
714
808
  * pulse's brief, nothing of the pulses before it - in place of the one it had, stopped when it was on `replacing`.
715
809
  */
716
810
  async function check(sentinel, job, replacing) {
717
- checking = { job, wrong: wentWrong(job) };
811
+ checking = { work: { pulse: job }, wrong: wentWrong(job) };
718
812
  const module = job.module;
719
813
  inChat(sentinel.scope, () => record({ kind: "sentinel.checking", ...(module !== undefined ? { module } : {}), ...(replacing !== undefined ? { replaced: replacing.id } : {}) }, { project: cwd, job: job.id }));
720
814
  const session = sentinel.session;
package/dist/cli.js CHANGED
@@ -142,9 +142,11 @@ if (command === "-h" || command === "--help") {
142
142
  console.log(USAGE);
143
143
  process.exit(0);
144
144
  }
145
- // turri's version, not pi's alone: pi's --version would answer for it.
145
+ // turri's version, not pi's alone: pi's --version would answer for it - and where this turri is, the newest on npm,
146
+ // every turri on the PATH, and what to do when the one that runs is not the newest (turri-versions.ts).
146
147
  if (command === "-v" || command === "--version") {
147
- console.log(versionLine());
148
+ const { describeVersion } = await import("./turri-versions.js");
149
+ console.log(await describeVersion(versionLine()));
148
150
  process.exit(0);
149
151
  }
150
152
  // The AWS region the project's Bedrock models need, set before any model is called - here, and in the pi the chat starts,
package/dist/config.d.ts CHANGED
@@ -379,6 +379,9 @@ export declare const DEFAULT_TEST_DIR = "tests";
379
379
  export declare function testDirOf(plainFile: string): string;
380
380
  /** The config a spec is built with: its project's, with its module's stack over it (`stackForModule`). */
381
381
  export declare function configForSpec(plainFile: string): ProjectConfig;
382
- /** One config file, read, or nothing of it when it is not there or does not parse. */
382
+ /**
383
+ * One config file, read, or nothing of it when it is not there or does not parse. Parsed again only once the file has
384
+ * changed - its size or its modification time - and each caller given its own copy.
385
+ */
383
386
  export declare function readConfigFile(path: string): ProjectConfig;
384
387
  export {};
package/dist/config.js CHANGED
@@ -81,14 +81,27 @@ export function stackConfigFile(stack) {
81
81
  function legacyStackConfigFile(stack) {
82
82
  return `config.${stack}.yaml`;
83
83
  }
84
+ /**
85
+ * The names in a folder - read again only once the folder itself has changed: a name added, removed or renamed in it
86
+ * moves its modification time. Asked many times a look, for every module of every chain.
87
+ */
84
88
  function namesIn(root) {
85
89
  try {
86
- return readdirSync(root);
90
+ const at = statSync(root).mtimeMs;
91
+ const kept = keptNames.get(root);
92
+ if (kept !== undefined && kept.at === at)
93
+ return [...kept.names];
94
+ const names = readdirSync(root);
95
+ keptNames.set(root, { at, names });
96
+ if (keptNames.size > 64)
97
+ keptNames.delete(keptNames.keys().next().value);
98
+ return [...names];
87
99
  }
88
100
  catch {
89
101
  return [];
90
102
  }
91
103
  }
104
+ const keptNames = new Map();
92
105
  /** The stacks in the old naming, `config.<stack>.yaml`. */
93
106
  function legacyStacks(root) {
94
107
  return namesIn(root)
@@ -530,12 +543,34 @@ export function configForSpec(plainFile) {
530
543
  const root = projectDirOf(resolve(plainFile));
531
544
  return readProjectConfig(root, stackForModule(plainFile));
532
545
  }
533
- /** One config file, read, or nothing of it when it is not there or does not parse. */
546
+ /**
547
+ * One config file, read, or nothing of it when it is not there or does not parse. Parsed again only once the file has
548
+ * changed - its size or its modification time - and each caller given its own copy.
549
+ */
534
550
  export function readConfigFile(path) {
535
- let parsed;
551
+ let stamp;
536
552
  try {
537
- if (!statSync(path).isFile())
553
+ const stat = statSync(path);
554
+ if (!stat.isFile())
538
555
  return {};
556
+ stamp = `${stat.size}:${stat.mtimeMs}:${stat.ino}`;
557
+ }
558
+ catch {
559
+ return {};
560
+ }
561
+ const kept = keptConfigs.get(path);
562
+ if (kept !== undefined && kept.stamp === stamp)
563
+ return structuredClone(kept.config);
564
+ const config = readConfigFileNow(path);
565
+ keptConfigs.set(path, { stamp, config });
566
+ if (keptConfigs.size > 64)
567
+ keptConfigs.delete(keptConfigs.keys().next().value);
568
+ return structuredClone(config);
569
+ }
570
+ const keptConfigs = new Map();
571
+ function readConfigFileNow(path) {
572
+ let parsed;
573
+ try {
539
574
  parsed = loadYaml(readFileSync(path, "utf8"));
540
575
  }
541
576
  catch {
@@ -22,8 +22,6 @@ export declare function themeFiles(): string[];
22
22
  * about a duplicate we created ourselves.
23
23
  */
24
24
  export declare function themesInstalledGlobally(): boolean;
25
- /** Where `turri` on the PATH points, if it is on the PATH at all. */
26
- export declare function turriOnPath(): string | undefined;
27
25
  /** The version of the pi turri runs on, as Node finds it - undefined when it is not installed. */
28
26
  export declare function piVersion(): string | undefined;
29
27
  /**
@@ -62,12 +62,6 @@ export function themesInstalledGlobally() {
62
62
  const names = themeFiles();
63
63
  return names.length > 0 && names.every((name) => existsSync(join(AGENT_DIR, "themes", name)));
64
64
  }
65
- /** Where `turri` on the PATH points, if it is on the PATH at all. */
66
- export function turriOnPath() {
67
- const which = spawnSync(process.platform === "win32" ? "where" : "which", ["turri"], { encoding: "utf8" });
68
- const found = which.status === 0 ? which.stdout.trim().split("\n")[0] : undefined;
69
- return found === "" ? undefined : found;
70
- }
71
65
  /** The version of the pi turri runs on, as Node finds it - undefined when it is not installed. */
72
66
  export function piVersion() {
73
67
  try {
@@ -107,13 +107,14 @@ export declare function parallelJobs(cwd: string): number;
107
107
  export declare function machineBlock(cwd: string, modules?: ReturnType<typeof stackOnMachine>): string;
108
108
  /**
109
109
  * What the project's modules need of this machine, kept between turns - working it out parses every spec, seconds on a
110
- * project of long requires chains, and a message is never kept waiting for it. Worked out once the chat is up, and again
111
- * when a turn has settled and a spec changed since: a turn is told what was known last, at most a turn behind.
110
+ * project of long requires chains. Worked out when the first turn asks - not as the session starts: a host's thread
111
+ * would be held up opening a chat no one may write in - and again, once known, when a turn has settled and a spec
112
+ * changed since: a turn is told what was known last, at most a turn behind.
112
113
  */
113
114
  export declare function machineNeeds(cwd: string, work?: (cwd: string) => ReturnType<typeof stackOnMachine>): {
114
115
  /** What is known - worked out now only when nothing is yet. */
115
116
  now(): ReturnType<typeof stackOnMachine>;
116
- /** Worked out again, if a spec changed - once the chat has drawn what it is drawing. */
117
+ /** Worked out again, if it is known and a spec changed - once the chat has drawn what it is drawing. */
117
118
  refreshSoon(): void;
118
119
  };
119
120
  /**