@turri-dev/cli 0.1.7 → 0.1.8-next.91b957c

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 (96) hide show
  1. package/README.md +6 -8
  2. package/dist/btw.d.ts +2 -0
  3. package/dist/btw.js +2 -2
  4. package/dist/cli.js +6 -2
  5. package/dist/config-edit.d.ts +1 -1
  6. package/dist/config-edit.js +4 -0
  7. package/dist/config.d.ts +27 -0
  8. package/dist/config.js +28 -1
  9. package/dist/extension.d.ts +11 -0
  10. package/dist/extension.js +118 -14
  11. package/dist/genome.js +2 -0
  12. package/dist/host.d.ts +3 -1
  13. package/dist/host.js +4 -1
  14. package/dist/identity.d.ts +6 -1
  15. package/dist/identity.js +33 -7
  16. package/dist/init-config.d.ts +3 -0
  17. package/dist/init-config.js +15 -0
  18. package/dist/ledger-cli.d.ts +1 -0
  19. package/dist/ledger-cli.js +50 -0
  20. package/dist/ledger-tools.d.ts +10 -0
  21. package/dist/ledger-tools.js +64 -0
  22. package/dist/loop-cli.js +4 -0
  23. package/dist/loop-tools.js +3 -1
  24. package/dist/pi.d.ts +2 -0
  25. package/dist/pi.js +24 -4
  26. package/dist/regenerate/activity.d.ts +9 -0
  27. package/dist/regenerate/activity.js +11 -0
  28. package/dist/regenerate/agent-env.d.ts +7 -0
  29. package/dist/regenerate/agent-env.js +126 -5
  30. package/dist/regenerate/agent-shell.js +5 -0
  31. package/dist/regenerate/cases.js +3 -0
  32. package/dist/regenerate/chunk-plan.d.ts +5 -6
  33. package/dist/regenerate/chunk-plan.js +43 -10
  34. package/dist/regenerate/conformance-agents.js +2 -2
  35. package/dist/regenerate/conformance.js +6 -3
  36. package/dist/regenerate/debug-log.d.ts +3 -1
  37. package/dist/regenerate/debug-log.js +10 -2
  38. package/dist/regenerate/diagnose.js +12 -2
  39. package/dist/regenerate/environment-error.d.ts +6 -0
  40. package/dist/regenerate/environment-error.js +19 -1
  41. package/dist/regenerate/job-log.d.ts +3 -1
  42. package/dist/regenerate/job-log.js +8 -5
  43. package/dist/regenerate/jobs-tree.d.ts +7 -3
  44. package/dist/regenerate/jobs-tree.js +21 -6
  45. package/dist/regenerate/jobs.d.ts +18 -0
  46. package/dist/regenerate/jobs.js +62 -5
  47. package/dist/regenerate/ledger-view.d.ts +92 -0
  48. package/dist/regenerate/ledger-view.js +345 -0
  49. package/dist/regenerate/ledger.d.ts +275 -0
  50. package/dist/regenerate/ledger.js +349 -0
  51. package/dist/regenerate/lineage.d.ts +9 -1
  52. package/dist/regenerate/lineage.js +64 -12
  53. package/dist/regenerate/loop.d.ts +6 -0
  54. package/dist/regenerate/loop.js +36 -6
  55. package/dist/regenerate/pulse-files.d.ts +14 -0
  56. package/dist/regenerate/pulse-files.js +63 -0
  57. package/dist/regenerate/records.d.ts +5 -3
  58. package/dist/regenerate/records.js +6 -4
  59. package/dist/regenerate/regenerate.js +7 -5
  60. package/dist/regenerate/regression.js +4 -2
  61. package/dist/regenerate/services.js +3 -0
  62. package/dist/regenerate/side-panel.d.ts +2 -0
  63. package/dist/regenerate/side-panel.js +4 -3
  64. package/dist/regenerate/spec-map.js +8 -5
  65. package/dist/regenerate/test-scripts.js +18 -5
  66. package/dist/regenerate/testing.js +3 -0
  67. package/dist/regenerate/unit-review.js +3 -0
  68. package/dist/run-report.d.ts +1 -1
  69. package/dist/run-report.js +15 -5
  70. package/dist/self-invocation.d.ts +1 -1
  71. package/dist/self-invocation.js +3 -2
  72. package/dist/skill-progress.d.ts +1 -1
  73. package/dist/skill.d.ts +5 -0
  74. package/dist/skill.js +1 -0
  75. package/dist/skills.d.ts +14 -0
  76. package/dist/skills.js +19 -1
  77. package/dist/surface.d.ts +5 -0
  78. package/dist/surface.js +18 -0
  79. package/docs/authoring-plain.md +2 -2
  80. package/docs/config.md +24 -3
  81. package/docs/development.md +3 -1
  82. package/docs/headless.md +9 -4
  83. package/docs/hosting.md +46 -0
  84. package/docs/installation.md +1 -0
  85. package/docs/regenerating.md +11 -11
  86. package/docs/sandbox.md +5 -5
  87. package/docs/skills-and-gates.md +3 -1
  88. package/docs/using.md +39 -8
  89. package/genome/rules/func-specs.md +1 -0
  90. package/genome/rules/turri-regeneration.md +3 -0
  91. package/genome/skills/add-functional-spec/SKILL.md +12 -3
  92. package/genome/skills/add-functional-specs/SKILL.md +12 -2
  93. package/genome/skills/brood/SKILL.md +2 -0
  94. package/genome/skills/bud/SKILL.md +2 -0
  95. package/genome/skills/spawn/SKILL.md +47 -35
  96. package/package.json +1 -1
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { reportActivity } from "./activity.js";
5
+ import { historyError } from "./environment-error.js";
6
+ import { firstLine, record } from "./ledger.js";
5
7
  import { projectRootOf } from "./paths.js";
6
8
  /**
7
9
  * Version history for each module's generated code and tests.
@@ -15,7 +17,8 @@ import { projectRootOf } from "./paths.js";
15
17
  * A commit is made after every step that changes the module - a render, a regeneration, a
16
18
  * conformance run, a regression repair, a spec change, a start from the module below - with
17
19
  * a message saying what it was. History is a record of the work, never a gate on it: a git
18
- * failure is swallowed, not raised.
20
+ * failure is swallowed, not raised - except landing work (`landWork`), whose commit the next
21
+ * start would otherwise undo.
19
22
  */
20
23
  export const HISTORY_DIR = ".plaingit";
21
24
  const AUTHOR = ["-c", "user.name=turri", "-c", "user.email=turri@localhost", "-c", "commit.gpgsign=false"];
@@ -39,7 +42,10 @@ const EXCLUDED = [
39
42
  ".DS_Store",
40
43
  ];
41
44
  function git(moduleDir, args, input) {
42
- return spawnSync("git", [...AUTHOR, "--git-dir", join(moduleDir, HISTORY_DIR), "--work-tree", moduleDir, ...args], {
45
+ const gitDir = join(moduleDir, HISTORY_DIR);
46
+ // The history is turri's, not the person's: hooks their git config names - gitleaks, husky - would refuse some of its
47
+ // commits, and the next start would put back what landed. Its own hooks folder holds only git's samples.
48
+ return spawnSync("git", [...AUTHOR, "-c", `core.hooksPath=${join(gitDir, "hooks")}`, "--git-dir", gitDir, "--work-tree", moduleDir, ...args], {
43
49
  cwd: moduleDir,
44
50
  encoding: "utf8",
45
51
  input,
@@ -79,21 +85,65 @@ function ensureHistory(moduleDir) {
79
85
  */
80
86
  export function recordHistory(dir, subject, body) {
81
87
  try {
82
- const moduleDir = moduleDirOf(dir);
83
- if (!existsSync(moduleDir) || !ensureHistory(moduleDir))
84
- return undefined;
85
- git(moduleDir, ["add", "-A"]);
86
- if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
87
- return undefined;
88
- const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
89
- if (git(moduleDir, ["commit", "-q", "-F", "-"], message).status !== 0)
90
- return undefined;
91
- return head(dir);
88
+ return recorded(dir, subject, commitAll(dir, subject, body), false).commit;
92
89
  }
93
90
  catch {
94
91
  return undefined;
95
92
  }
96
93
  }
94
+ /**
95
+ * Commit work that landed - the code or tests a pulse passed, with the record of what they are built from. Unlike any
96
+ * other step's, this commit is not only a record: the next unit of work puts the module back to its last commit, and
97
+ * would undo what landed with it. So a commit that cannot be made is raised, as the environment's, with what git said -
98
+ * the work left in place, uncommitted. Nothing changed, nothing is committed. Returns the commit, if any.
99
+ */
100
+ export function landWork(dir, subject, body) {
101
+ const moduleDir = moduleDirOf(dir);
102
+ let made;
103
+ try {
104
+ made = commitAll(dir, subject, body);
105
+ }
106
+ catch (error) {
107
+ made = { failed: error instanceof Error ? error.message : String(error) };
108
+ }
109
+ recorded(dir, subject, made, true);
110
+ if (made.failed !== undefined)
111
+ throw historyError(moduleDir, made.failed);
112
+ return made.commit;
113
+ }
114
+ /** A commit made or failed, in the ledger - nothing for a step that changed nothing. */
115
+ function recorded(dir, subject, made, landing) {
116
+ const place = placeOf(moduleDirOf(dir));
117
+ if (made.failed !== undefined)
118
+ record({ kind: "history.failed", subject: firstLine(subject, 200), said: firstLine(made.failed, 400), landing }, place);
119
+ else if (made.commit !== undefined)
120
+ record({ kind: "history.committed", subject: firstLine(subject, 200), commit: made.commit }, place);
121
+ return made;
122
+ }
123
+ /** Where a module's history is, for the ledger: its project and its name. */
124
+ function placeOf(moduleDir) {
125
+ return { project: projectRootOf(moduleDir), module: basename(moduleDir) };
126
+ }
127
+ /** Commit everything the step changed in the module `dir` belongs to: the commit, nothing when nothing changed, or why git failed. */
128
+ function commitAll(dir, subject, body) {
129
+ const moduleDir = moduleDirOf(dir);
130
+ if (!existsSync(moduleDir))
131
+ return {};
132
+ if (!ensureHistory(moduleDir))
133
+ return { failed: `git could not make the module's history in ${join(moduleDir, HISTORY_DIR)}` };
134
+ const failed = (result) => (result.error?.message ?? `${result.stderr}${result.stdout}`.trim()) || `git exited ${result.status}`;
135
+ const added = git(moduleDir, ["add", "-A"]);
136
+ if (added.status !== 0)
137
+ return { failed: failed(added) };
138
+ if (git(moduleDir, ["diff", "--cached", "--quiet"]).status === 0)
139
+ return {};
140
+ const message = body ? `${subject}\n\n${body.trim()}\n` : subject;
141
+ const committed = git(moduleDir, ["commit", "-q", "-F", "-"], message);
142
+ if (committed.status !== 0)
143
+ return { failed: failed(committed) };
144
+ const commit = head(dir);
145
+ return commit !== undefined ? { commit } : {};
146
+ }
97
147
  /**
98
148
  * The notes the harness writes once a pulse has ended, committed - or the next pulse, putting the module back to its last
99
149
  * commit, would take them away. Into the pulse's own commit when it landed one - `landedSince` (ms), when it started - so
@@ -156,6 +206,7 @@ export function resetToLastCommit(dir) {
156
206
  return [];
157
207
  git(moduleDir, ["reset", "-q", "--hard", "HEAD"]);
158
208
  git(moduleDir, ["clean", "-q", "-fd"]);
209
+ record({ kind: "history.reset", undone: changed.length, paths: changed.slice(0, 20) }, placeOf(moduleDir));
159
210
  return changed;
160
211
  }
161
212
  catch {
@@ -235,6 +286,7 @@ export function abandonWork(start, moduleName, why) {
235
286
  git(moduleDir, ["reset", "-q", "--hard", commit]);
236
287
  git(moduleDir, ["clean", "-q", "-fd"]);
237
288
  cleanTestsBuild(moduleDir);
289
+ record({ kind: "history.rolledBack", why: firstLine(why, 300) }, placeOf(moduleDir));
238
290
  reportActivity(`${moduleName}: ${why} - ${basename(moduleDir)} is back where the work started`);
239
291
  }
240
292
  catch {
@@ -385,6 +385,12 @@ export declare function isUpToDate(status: ModuleStatus): boolean;
385
385
  export declare function describeModules(statuses: ModuleStatus[], options?: {
386
386
  for?: "chat" | "command line";
387
387
  }): string;
388
+ /**
389
+ * A module's first pulse when it builds one functional spec - the project set up and the smallest thing that runs: it lands on its
390
+ * unit tests alone, with no review. A reviewer, and an arbiter after it, on a scaffold is time spent before anything can
391
+ * be tried.
392
+ */
393
+ export declare function skipsReview(cases: JobCases): boolean;
388
394
  /** How a code pulse went, for its line in the notes: the rounds it took, its test runs, what the debugger found. */
389
395
  export declare function codeStory(result: {
390
396
  attempts?: number;
@@ -5,14 +5,15 @@ import { STDLIB_ROOT } from "../plain/stdlib.js";
5
5
  import { ACCEPTANCE_TESTS, FUNCTIONAL_REQUIREMENTS, REQUIRED_FUNCTIONAL_SPECS, TEST_REQUIREMENTS } from "../plain/spec.js";
6
6
  import { reportActivity, reportStage } from "./activity.js";
7
7
  import { llmChunkAgents, MAX_LINES_PER_JOB, MAX_SPECS_PER_JOB, planChunks, stepsOf } from "./chunk-plan.js";
8
- import { commitNotes, recordHistory, resetToLastCommit } from "./lineage.js";
8
+ import { commitNotes, landWork, recordHistory, resetToLastCommit } from "./lineage.js";
9
+ import { firstLine, record } from "./ledger.js";
9
10
  import { NOTES_FILE, recordPulse } from "./notes.js";
10
11
  import { createJobRegistry, formatDuration } from "./jobs.js";
11
12
  import { moduleLanes } from "./lanes.js";
12
- import { adoptDigests, chainTree, codeTree, conformanceTarget, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
13
+ import { adoptDigests, chainTree, codeTree, conformanceTarget, marksRegenerateAgain, REGENERATE_AGAIN, inheritedRecord, notBuilt, readRecord, RECORD_FILE, withResourceDigests, writeCodeRecord, writeTestsRecord, } from "./records.js";
13
14
  import { describeSpecDiffConformance, renderSpecDiffConformance } from "./conform.js";
14
15
  import { conformanceDirFor } from "./conformance.js";
15
- import { modulesRoot } from "./paths.js";
16
+ import { modulesRoot, projectRootOf } from "./paths.js";
16
17
  import { budgetFor, choosingWhen, configForSpec, conformanceSwitchedOn, stackForModule, stackPinned, withStack } from "../config.js";
17
18
  import { describeSpecChange } from "./prompt.js";
18
19
  import { resolveTemplateDirs } from "./paths.js";
@@ -153,7 +154,8 @@ export function functionalityCompletion(plainFile) {
153
154
  codebase.forEach((entry, index) => {
154
155
  const count = counts[index];
155
156
  const completed = entry.frids.filter((frid) => !touched.has(`${entry.moduleName}:${frid}`));
156
- const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && item.markdown.trim() === specText(entry.moduleName, item.frid));
157
+ // Tests marked to regenerate again were written against code since replaced: not conformance tested until rewritten.
158
+ const tested = (readRecord(conformanceDirFor(entry.moduleName, modulesRoot(projectDirOf(entry.plainFile))))?.[FUNCTIONAL_REQUIREMENTS] ?? []).filter((item) => item.frid !== undefined && !marksRegenerateAgain(item) && item.markdown.trim() === specText(entry.moduleName, item.frid));
157
159
  count.completed = completed.length;
158
160
  count.tested = completed.filter((frid) => tested.some((item) => item.frid === frid));
159
161
  count.conformanceTested = count.tested.length;
@@ -190,6 +192,11 @@ export function createWorkLoop(options) {
190
192
  // One tick at a time: each reads the records and the spec, and decides.
191
193
  let ticking = Promise.resolve();
192
194
  const tipEntry = (state) => tipOf(state.chain);
195
+ /** Where what the loop decides for a module is recorded: its tip's project, the module and the pulse it is about. */
196
+ const loopPlace = (state, job) => {
197
+ const tip = tipEntry(state);
198
+ return { project: projectRootOf(tip.buildDir), module: tip.moduleName, job };
199
+ };
193
200
  /** `work` with the codebase's stack chosen - the one it was asked for with - when it has one pinned. */
194
201
  const inStack = (state, work) => (state.stack !== undefined ? withStack(state.stack.name, work) : work());
195
202
  const keyOf = (spec) => serializeTree(codeTree(spec.tree));
@@ -521,7 +528,7 @@ export function createWorkLoop(options) {
521
528
  run: async (onDelta) => {
522
529
  // The guardrail: a conformance job tests only code that is built - the code record, read as the job starts, has every functional spec it tests as it tests it.
523
530
  if (conformance) {
524
- const testing = [...planned.cases.add, ...planned.cases.patch].map((touched) => touched.frid);
531
+ const testing = [...planned.cases.add, ...planned.cases.patch, ...planned.cases.again].map((touched) => touched.frid);
525
532
  const missing = notBuilt(readRecord(tip.buildDir), planned.step.tree, testing);
526
533
  if (missing.length > 0) {
527
534
  const record = relative(options.cwd, join(tip.buildDir, RECORD_FILE));
@@ -680,6 +687,7 @@ export function createWorkLoop(options) {
680
687
  /* a spec that does not parse halts it all the same */
681
688
  }
682
689
  state.halted = { why: `${why} - nothing more is built on it until its conformance tests pass`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
690
+ record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "conformance" }, loopPlace(state, running.jobId));
683
691
  }
684
692
  }
685
693
  else if (running !== undefined && !landed) {
@@ -699,6 +707,7 @@ export function createWorkLoop(options) {
699
707
  }
700
708
  const why = settled?.status === "stopped" ? `pulse ${running.jobId} was stopped` : `pulse ${running.jobId} ("${running.planned.title}") did not land`;
701
709
  state.halted = { why: `${why} - it runs again once the spec changes, or the module is asked for again`, specKey: key, pulse: pulseOf(running), ...(settled?.status === "stopped" ? {} : causeOf(settled)) };
710
+ record({ kind: "loop.halted", why: firstLine(state.halted.why, 300), track: "code" }, loopPlace(state, running.jobId));
702
711
  }
703
712
  }
704
713
  else {
@@ -865,6 +874,7 @@ export function createWorkLoop(options) {
865
874
  else {
866
875
  state.running.stopping = true;
867
876
  state.why = `pulse ${state.running.jobId} was stopped: ${why}, which it was regenerating from`;
877
+ record({ kind: "loop.stopped", why: firstLine(why, 300) }, loopPlace(state, state.running.jobId));
868
878
  reportActivity(` ${tip.moduleName}: ${state.why}`);
869
879
  options.registry.stop(state.running.jobId, `Stopped by turri, not by you: ${why}, which it was regenerating from. What it had done is rolled back, and the spec as it is now is regenerated instead. To have a running pulse finish first and the change regenerated on top of it, set allow-to-stop-jobs: false in config.turri.yaml.`);
870
880
  return;
@@ -1109,6 +1119,17 @@ export function createWorkLoop(options) {
1109
1119
  found.add(frid);
1110
1120
  const marked = items.map((item, index) => (frids.includes(named(item, index)) ? { ...item, [REGENERATE_AGAIN]: true } : item));
1111
1121
  writeCodeRecord(tip.buildDir, { ...record, [section]: marked });
1122
+ // Its conformance tests were written against the code being replaced: marked with it, they are written again -
1123
+ // checked against the new code, and rewritten where they relied on what the old code chose - once it lands.
1124
+ if (section === FUNCTIONAL_REQUIREMENTS) {
1125
+ const testsDir = conformanceDirFor(tip.moduleName, modulesRoot(projectDirOf(tip.plainFile)));
1126
+ const tests = readRecord(testsDir);
1127
+ const tested = tests?.[FUNCTIONAL_REQUIREMENTS] ?? [];
1128
+ if (tested.some((item) => item.frid !== undefined && taken.includes(item.frid))) {
1129
+ const again = tested.map((item) => (item.frid !== undefined && taken.includes(item.frid) ? { ...item, [REGENERATE_AGAIN]: true } : item));
1130
+ writeTestsRecord(testsDir, { ...tests, [FUNCTIONAL_REQUIREMENTS]: again });
1131
+ }
1132
+ }
1112
1133
  recordHistory(tip.buildDir, `${module} functional spec${taken.length === 1 ? "" : "s"} ${taken.join(", ")} to regenerate again`);
1113
1134
  }
1114
1135
  const missing = frids.filter((frid) => !found.has(frid));
@@ -1596,10 +1617,18 @@ const defaultConformanceRunner = async (job, onDelta) => {
1596
1617
  });
1597
1618
  if (result.passed) {
1598
1619
  writeTestsRecord(result.conformanceDir, job.planned.step.tree);
1599
- recordHistory(job.buildDir, `conformance tests: ${job.planned.title}`);
1620
+ landWork(job.buildDir, `conformance tests: ${job.planned.title}`);
1600
1621
  }
1601
1622
  return { landed: result.passed, report: describeSpecDiffConformance(result), ...(result.specGaps !== undefined ? { specGaps: result.specGaps } : {}) };
1602
1623
  };
1624
+ /**
1625
+ * A module's first pulse when it builds one functional spec - the project set up and the smallest thing that runs: it lands on its
1626
+ * unit tests alone, with no review. A reviewer, and an arbiter after it, on a scaffold is time spent before anything can
1627
+ * be tried.
1628
+ */
1629
+ export function skipsReview(cases) {
1630
+ return cases.fresh && cases.add.length <= 1 && cases.patch.length === 0 && cases.again.length === 0 && cases.delete.length === 0;
1631
+ }
1603
1632
  /** A job as a spec diff render, landed on its unit tests: conformance is a track of its own. */
1604
1633
  const defaultRunner = async (job, onDelta) => {
1605
1634
  const result = await renderSpecDiff({
@@ -1609,6 +1638,7 @@ const defaultRunner = async (job, onDelta) => {
1609
1638
  applied: "already applied",
1610
1639
  spec: job.spec,
1611
1640
  cases: job.planned.cases,
1641
+ ...(skipsReview(job.planned.cases) ? { unitReview: false } : {}),
1612
1642
  ...(job.model !== undefined ? { model: job.model } : {}),
1613
1643
  ...(job.steering !== undefined ? { steering: job.steering } : {}),
1614
1644
  onDelta,
@@ -0,0 +1,14 @@
1
+ /** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
2
+ export declare function pulseWrote(file: string, job?: string | undefined): void;
3
+ /**
4
+ * A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
5
+ * temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
6
+ * pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
7
+ * everything was kept.
8
+ */
9
+ export declare function pulseEnded(options: {
10
+ job: string;
11
+ project: string;
12
+ module?: string;
13
+ lanes: string[];
14
+ }): boolean;
@@ -0,0 +1,63 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { debugMode } from "../config.js";
4
+ import { currentJob } from "./activity.js";
5
+ import { modulesScratchDir } from "./paths.js";
6
+ /**
7
+ * What a pulse writes only for a person debugging turri, and when it goes.
8
+ *
9
+ * A pulse writes its own log, every one of its agents' whole logs, the testing scripts' and services' full outputs and
10
+ * its agents' temporary files. Nothing of turri reads them once the pulse is over: while it runs, its agents read their
11
+ * outputs (`read_output`, `service_logs`) and the diagnosis of a failure reads the logs - so they are written as before,
12
+ * and removed as it ends. In debug mode (`debug: true`, TURRI_DEBUG, `--debug`) they are kept, as they always were.
13
+ *
14
+ * So are the dependencies installed in the modules below a pulse's own in its chain: the chain is built in its tip, and
15
+ * what was installed in the codebases it left behind is only installed again - from the package cache - if one of them
16
+ * is ever built on again. The testing scripts install what they run with themselves.
17
+ */
18
+ /** The files each pulse wrote for a person to read, by its id. */
19
+ const written = new Map();
20
+ /** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
21
+ export function pulseWrote(file, job = currentJob()?.id) {
22
+ if (job === undefined)
23
+ return;
24
+ const files = written.get(job) ?? new Set();
25
+ files.add(file);
26
+ written.set(job, files);
27
+ }
28
+ /** Where a codebase's dependencies are installed: what a module its chain has left behind keeps no longer. */
29
+ const INSTALLED = ["node_modules", ".venv", "venv"];
30
+ /**
31
+ * A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
32
+ * temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
33
+ * pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
34
+ * everything was kept.
35
+ */
36
+ export function pulseEnded(options) {
37
+ const files = written.get(options.job) ?? new Set();
38
+ written.delete(options.job);
39
+ if (debugMode(options.project))
40
+ return true;
41
+ const gone = (path) => {
42
+ try {
43
+ rmSync(path, { recursive: true, force: true });
44
+ }
45
+ catch {
46
+ /* what cannot be removed stays: never a reason for the pulse to fail */
47
+ }
48
+ };
49
+ for (const file of files)
50
+ gone(file);
51
+ for (const lane of options.lanes) {
52
+ // A codebase of the chain: its folder holds `code` - a lane may also be a spec file, or a build folder.
53
+ if (!existsSync(join(lane, "code")))
54
+ continue;
55
+ const name = basename(lane);
56
+ if (name === options.module)
57
+ gone(join(modulesScratchDir(lane), "tmp", name));
58
+ else
59
+ for (const installed of INSTALLED)
60
+ gone(join(lane, "code", installed));
61
+ }
62
+ return false;
63
+ }
@@ -63,9 +63,11 @@ export type Chosen = (frid: string) => boolean;
63
63
  * definitions and implementation reqs, with the test reqs as the spec has them now.
64
64
  *
65
65
  * Conformance follows the code, never goes ahead of it: a functional spec whose code is not caught up
66
- * with the spec - its text in the code record is not the spec's, or the code record does not have it at
67
- * all, as while it is regenerated again - stays as the tests record has it, until the code lands. Its tests are
68
- * taken out only once the spec and the code have both dropped it.
66
+ * with the spec - its text in the code record is not the spec's, the code record does not have it at
67
+ * all, or has it marked to regenerate again - stays as the tests record has it, until the code lands. Its tests are
68
+ * taken out only once the spec and the code have both dropped it. Tests marked to regenerate again with their code
69
+ * (`regenerateAgain`) differ from the target once the code lands: written against the code it replaced, they are
70
+ * conformance work again, though the spec's text never changed.
69
71
  */
70
72
  export declare function conformanceTarget(code: MarshalledPlainSource | null, tests: MarshalledPlainSource | null, spec: MarshalledPlainSource, chosen?: Chosen): MarshalledPlainSource;
71
73
  /**
@@ -155,9 +155,11 @@ export function inheritedRecord(source, sourceModule) {
155
155
  * definitions and implementation reqs, with the test reqs as the spec has them now.
156
156
  *
157
157
  * Conformance follows the code, never goes ahead of it: a functional spec whose code is not caught up
158
- * with the spec - its text in the code record is not the spec's, or the code record does not have it at
159
- * all, as while it is regenerated again - stays as the tests record has it, until the code lands. Its tests are
160
- * taken out only once the spec and the code have both dropped it.
158
+ * with the spec - its text in the code record is not the spec's, the code record does not have it at
159
+ * all, or has it marked to regenerate again - stays as the tests record has it, until the code lands. Its tests are
160
+ * taken out only once the spec and the code have both dropped it. Tests marked to regenerate again with their code
161
+ * (`regenerateAgain`) differ from the target once the code lands: written against the code it replaced, they are
162
+ * conformance work again, though the spec's text never changed.
161
163
  */
162
164
  export function conformanceTarget(code, tests, spec, chosen = () => true) {
163
165
  const built = itemsOf(code, FUNCTIONAL_REQUIREMENTS);
@@ -168,7 +170,7 @@ export function conformanceTarget(code, tests, spec, chosen = () => true) {
168
170
  for (let index = 0; index < Math.max(built.length, now.length); index += 1) {
169
171
  const frid = String(index + 1);
170
172
  const current = now[index];
171
- if (current !== undefined && built[index]?.markdown.trim() === current.markdown.trim()) {
173
+ if (current !== undefined && built[index]?.markdown.trim() === current.markdown.trim() && !marksRegenerateAgain(built[index])) {
172
174
  if (chosen(frid))
173
175
  specs.push({ ...current, frid });
174
176
  continue;
@@ -11,7 +11,8 @@ import { debugFailure, diagnosisText } from "./debugger.js";
11
11
  import { codebaseMap, diffFiles, digestFiles, wroteNothing } from "./codebase.js";
12
12
  import { confinedTools } from "./confine.js";
13
13
  import { buildSpecDiffPrompt, describeSpecChange, reviewedSpec, sectionModules } from "./prompt.js";
14
- import { abandonWork, recordHistory, startWork } from "./lineage.js";
14
+ import { abandonWork, landWork, startWork } from "./lineage.js";
15
+ import { healing, record, signatureHash } from "./ledger.js";
15
16
  import { createWorkLedger, SUBMIT_WORK_NOTE } from "./submit-work.js";
16
17
  import { CODER_COMPACTION } from "./compaction.js";
17
18
  import { gapMark, gapsSince, NOTES_TURN, notesForSession, passedOutcome, recordNote, withNotes } from "./notes.js";
@@ -193,7 +194,7 @@ export async function renderSpecDiff(options) {
193
194
  if (builtBefore !== null && sameSpec(builtBefore, tree) && !dryRun) {
194
195
  stage("the specs were only restructured - nothing to render; recording the spec as it is now laid out");
195
196
  writeCodeRecord(tip.buildDir, tree);
196
- recordHistory(tip.buildDir, `spec restructured: ${summarizeDiff(options.diff)}`, options.diff);
197
+ landWork(tip.buildDir, `spec restructured: ${summarizeDiff(options.diff)}`, options.diff);
197
198
  return {
198
199
  moduleName,
199
200
  buildDir: tip.buildDir,
@@ -440,7 +441,7 @@ export async function renderSpecDiff(options) {
440
441
  : timedOut && seen < STUCK_ROUNDS
441
442
  ? `the unit testing script did not finish in ${Math.round(verdict.run.timeoutMs / 1000)}s - a debugger works out whether something hangs`
442
443
  : `the same failure ${seen} times - a debugger looks at it with fresh eyes`);
443
- const found = await debugFailure({
444
+ const found = await healing("debugger", () => debugFailure({
444
445
  buildDir,
445
446
  failure: verdict.run.output,
446
447
  times,
@@ -450,7 +451,7 @@ export async function renderSpecDiff(options) {
450
451
  label: `${moduleName} spec change`,
451
452
  ...(debuggerModel !== undefined ? { model: debuggerModel } : {}),
452
453
  onDelta: options.onDelta,
453
- }).catch(() => undefined);
454
+ }), (finding) => finding?.rootCause);
454
455
  // Nothing hangs: the suite needs more time than a test run may take, and no agent can give it that.
455
456
  if (found?.tooSlow === true) {
456
457
  stage(`the unit testing script needs more than the ${Math.round(verdict.run.timeoutMs / 1000)}s a test run may take - stopping`);
@@ -477,6 +478,7 @@ export async function renderSpecDiff(options) {
477
478
  ].join("\n");
478
479
  }
479
480
  }
481
+ record({ kind: "gate.round", gate: "unit tests", round: followUp + 1, of: MAX_FOLLOW_UPS, ...(key !== undefined ? { signature: signatureHash(key) } : {}) });
480
482
  stage(`${verdict.staticOnly ? "static checks" : "unit tests"} not passing - fixing (round ${followUp + 1} of ${MAX_FOLLOW_UPS})`);
481
483
  response = await ask(executor, prompt, options.onDelta);
482
484
  continue;
@@ -554,7 +556,7 @@ export async function renderSpecDiff(options) {
554
556
  stage("recording what the code is built from, and committing it");
555
557
  // Steered, it is built from the spec as it was steered to - the record the loop moved with it.
556
558
  writeCodeRecord(buildDir, steering?.target ?? tree);
557
- recordHistory(buildDir, `spec diff: ${summarizeDiff(options.diff)}`, options.diff);
559
+ landWork(buildDir, `spec diff: ${summarizeDiff(options.diff)}`, options.diff);
558
560
  };
559
561
  if (!unitPassed) {
560
562
  stage(unitReviewStopped ? "the unit tests pass but still leave the change untested - undoing the change" : "unit tests NOT passing - undoing the change");
@@ -6,7 +6,8 @@ import { reportActivity, reportStage, unitOf } from "./activity.js";
6
6
  import { confinedTools } from "./confine.js";
7
7
  import { projectRootOf } from "./paths.js";
8
8
  import { buildRegressionFixPrompt, sectionModules } from "./prompt.js";
9
- import { abandonWork, markWork, recordHistory } from "./lineage.js";
9
+ import { abandonWork, landWork, markWork } from "./lineage.js";
10
+ import { record } from "./ledger.js";
10
11
  import { missingToolchainError } from "./environment-error.js";
11
12
  import { workspaceBlock } from "./workspace.js";
12
13
  import { CODER_COMPACTION } from "./compaction.js";
@@ -154,13 +155,14 @@ export async function ensureRegressionPasses(input) {
154
155
  if (passing) {
155
156
  reportStage(`${unitOf(input.moduleName)}: the whole conformance suite passes, and the unit tests with it${repairs.length > 0 ? ` after ${repairs.length} repair${repairs.length === 1 ? "" : "s"}` : ""}`);
156
157
  if (repairs.length > 0) {
157
- recordHistory(input.buildDir, `regression ${input.moduleName} (pass after ${repairs.length} repair(s))`);
158
+ landWork(input.buildDir, `regression ${input.moduleName} (pass after ${repairs.length} repair(s))`);
158
159
  }
159
160
  return { passed: true, runs, repairs, command };
160
161
  }
161
162
  if (round === maxRounds)
162
163
  break;
163
164
  input.onEvent?.({ type: "repair", frids: input.frids });
165
+ record({ kind: "gate.round", gate: "regression", round: round + 1, of: maxRounds });
164
166
  reportStage(`${unitOf(input.moduleName)}: an earlier functionality broke - repairing (round ${round + 1} of ${maxRounds})`);
165
167
  repairs.push(await repairRegression({
166
168
  testsDirs: dirs,
@@ -8,6 +8,7 @@ import { agentShell } from "./agent-shell.js";
8
8
  import { maskSecrets } from "./masking.js";
9
9
  import { modulesScratchDir } from "./paths.js";
10
10
  import { isRunning, startInBackground, stopGroup } from "./shell.js";
11
+ import { pulseWrote } from "./pulse-files.js";
11
12
  /**
12
13
  * What an agent starts and keeps running while it works - a server, a worker - to try the software as someone would,
13
14
  * from outside: with curl, or what the stack's own client is. Whatever the stack: the agent gives the command that
@@ -83,6 +84,8 @@ export function createServiceTools(options) {
83
84
  return say(`Not started: ${checked.reason}`);
84
85
  mkdirSync(logsDir, { recursive: true });
85
86
  const log = join(logsDir, `${name.replace(/[^\w.-]+/g, "_")}-${randomUUID().slice(0, 8)}.log`);
87
+ // Read through service_logs while the pulse runs, and by no one after it (pulse-files.ts).
88
+ pulseWrote(log);
86
89
  const limit = Math.min(Math.max(seconds ?? DEFAULT_READY_SECONDS, 1), MAX_READY_SECONDS);
87
90
  // Started and waited on under the audit: what it writes while it comes up, outside its role, is put back.
88
91
  const { value: outcome, note } = await fenced.audited(async () => {
@@ -17,6 +17,8 @@ import type { TranscriptStyle } from "./transcript-view.ts";
17
17
  export interface SidePanelOptions {
18
18
  /** Every job, oldest first. */
19
19
  jobs: () => Job[];
20
+ /** Earlier sessions' pulses, from the ledger: listed under this session's, in a section of their own. */
21
+ earlier?: () => Job[];
20
22
  /** The regeneration's pages. Not given: the pulse list alone. */
21
23
  map?: () => MarkedPage[];
22
24
  /** Rows the panel may use. */
@@ -1,5 +1,5 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, treeKey } from "./jobs-tree.js";
2
+ import { createJobsTreeState, jobsTreeLines, jobsTreeRows, keepCursorInView, scrollTree, sectionOf, treeKey } from "./jobs-tree.js";
3
3
  import { columnHeadings, createSpecMapState, DISCUSS_KEY, legendLines, minimap, shownPage, specMapHeader, specMapLines, switchTab, tabBar } from "./spec-map-view.js";
4
4
  export function createSidePanel(options) {
5
5
  const style = options.style;
@@ -71,7 +71,8 @@ export function createSidePanel(options) {
71
71
  const bodyHeight = Math.max(3, rows - top.length - 2);
72
72
  lastBodyHeight = bodyHeight;
73
73
  // The tree scrolls to keep the cursor in sight, and only that far.
74
- const tree_ = jobsTreeLines(options.jobs(), tree, width, style);
74
+ // This session's pulses, and under them the earlier sessions' the ledger has.
75
+ const tree_ = jobsTreeLines([...options.jobs(), ...(options.earlier?.() ?? [])], tree, width, style);
75
76
  drawnTree = { rows: tree_.rows, rowStarts: tree_.rowStarts, lineCount: tree_.lines.length };
76
77
  keepCursorInView(tree, tree_.rowStarts, tree_.lines.length, bodyHeight);
77
78
  const visible = tree_.lines.slice(tree.top, tree.top + bodyHeight);
@@ -132,7 +133,7 @@ export function createSidePanel(options) {
132
133
  const job = jobs.find((entry) => entry.id === id);
133
134
  if (job === undefined)
134
135
  return;
135
- const section = job.status === "running" ? "running" : job.status === "queued" ? "queued" : "finished";
136
+ const section = sectionOf(job);
136
137
  tree.open[section] = true;
137
138
  const at = jobsTreeRows(jobs, tree).findIndex((entry) => entry.kind === "job" && entry.job.id === id);
138
139
  if (at !== -1)
@@ -8,7 +8,7 @@ import { conformanceDirFor } from "./conformance.js";
8
8
  import { chainSpec } from "./loop.js";
9
9
  import { modulesRoot, projectDirOf } from "./paths.js";
10
10
  import { HISTORY_DIR } from "./lineage.js";
11
- import { codeTree, readRecord, RECORD_FILE } from "./records.js";
11
+ import { codeTree, marksRegenerateAgain, readRecord, RECORD_FILE } from "./records.js";
12
12
  import { specFilesOf } from "./regenerate.js";
13
13
  import { builtTips, resolveRequiresChain } from "./requires.js";
14
14
  import { changesBetween, layoutOf, serializeTree } from "./spec-steps.js";
@@ -115,17 +115,20 @@ export function pageItems(input) {
115
115
  }
116
116
  }
117
117
  // Conformance tests: of the functional specs, those the tests record has as they read now, as they read before, or taken out.
118
- const tested = new Set(itemsOf(input.tested ?? null, FUNCTIONAL_REQUIREMENTS).map((item) => item.markdown.trim()));
118
+ // Tests marked to regenerate again were written against code since replaced: written for an earlier build, not now.
119
+ const testedItems = itemsOf(input.tested ?? null, FUNCTIONAL_REQUIREMENTS);
120
+ const tested = new Set(testedItems.filter((item) => !marksRegenerateAgain(item)).map((item) => item.markdown.trim()));
121
+ const replaced = new Set(testedItems.filter((item) => marksRegenerateAgain(item)).map((item) => item.markdown.trim()));
119
122
  for (const item of items) {
120
- if (item.section !== FUNCTIONAL_REQUIREMENTS || tested.size === 0)
123
+ if (item.section !== FUNCTIONAL_REQUIREMENTS || testedItems.length === 0)
121
124
  continue;
122
125
  if (item.state === "removed") {
123
- if (tested.has(item.text.trim()))
126
+ if (tested.has(item.text.trim()) || replaced.has(item.text.trim()))
124
127
  item.tests = "removed";
125
128
  }
126
129
  else if (tested.has(item.text.trim()))
127
130
  item.tests = "now";
128
- else if (item.was !== undefined && tested.has(item.was.trim()))
131
+ else if (replaced.has(item.text.trim()) || (item.was !== undefined && (tested.has(item.was.trim()) || replaced.has(item.was.trim()))))
129
132
  item.tests = "older";
130
133
  }
131
134
  return keyed(items);
@@ -4,6 +4,7 @@ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync
4
4
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
5
5
  import { CONFIG_FILE, CONFIG_KEYS, projectConfigFile, readProjectConfig, stackConfigFile, stackConfigFileIn, stackOf, stacksOf } from "../config.js";
6
6
  import { reportActivity, reportLogNote, reportNote } from "./activity.js";
7
+ import { record, signatureHash } from "./ledger.js";
7
8
  import { ensureInModulesScratch, modulesScratchDir } from "./paths.js";
8
9
  import { defineTool } from "@earendil-works/pi-coding-agent";
9
10
  import { Type } from "typebox";
@@ -361,7 +362,7 @@ async function runScript(cwd, script, args, logDir, options = {}) {
361
362
  const noted = withScriptLineNote(text, script);
362
363
  // Its failures, counts and end are what the agent is shown; the whole is kept, a read_output away.
363
364
  const log = keepTestOutput(logDir, basename(script), noted);
364
- return {
365
+ const run = {
365
366
  command: shown(script),
366
367
  exitCode: timedOut === true ? 124 : exitCode,
367
368
  output: shownTestOutput(noted, log),
@@ -370,6 +371,18 @@ async function runScript(cwd, script, args, logDir, options = {}) {
370
371
  elapsedMs: Date.now() - started,
371
372
  ...(log !== undefined ? { log } : {}),
372
373
  };
374
+ record({
375
+ kind: "script.run",
376
+ script: basename(script),
377
+ exit: run.exitCode,
378
+ ms: run.elapsedMs,
379
+ timedOut: run.timedOut === true,
380
+ inPlace: options.named === undefined,
381
+ // On code changed for the run - can_it_fail's bug: failing is what it is for.
382
+ ...(options.mutated === true ? { mutated: true } : {}),
383
+ ...(run.exitCode !== 0 ? { signature: signatureHash(failureSignature(run)) } : {}),
384
+ });
385
+ return run;
373
386
  }
374
387
  /** What bash says when a line of the script itself cannot run: a command or file that is not there, or not runnable. */
375
388
  const OWN_LINE_FAILURE = /: (No such file or directory|command not found|Permission denied)$/;
@@ -406,10 +419,10 @@ function quote(path) {
406
419
  return `'${path.replace(/'/g, "'\\''")}'`;
407
420
  }
408
421
  /** A testing script run on a fresh copy of the code: the project's script copied fresh into the staging folder, beside it. */
409
- async function runOnCopy(stagingRoot, script, args, logDir, limitMs) {
422
+ async function runOnCopy(stagingRoot, script, args, logDir, limitMs, mutated) {
410
423
  const copy = join(stagingRoot, basename(script));
411
424
  writeFileSync(copy, readFileSync(script));
412
- return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}) });
425
+ return runScript(stagingRoot, script, args, logDir, { named: copy, ...(limitMs !== undefined ? { limitMs } : {}), ...(mutated === true ? { mutated } : {}) });
413
426
  }
414
427
  /**
415
428
  * The harness's run of the unit testing script on the code as it is now - and run_unit_tests'.
@@ -475,7 +488,7 @@ limitMs) {
475
488
  return run;
476
489
  };
477
490
  if (project.inPlace) {
478
- const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, limitMs !== undefined ? { limitMs } : {});
491
+ const inPlace = () => runScript(project.root, script, [resolve(buildDir)], buildDir, { ...(limitMs !== undefined ? { limitMs } : {}), ...(change !== undefined ? { mutated: true } : {}) });
479
492
  const started = Date.now();
480
493
  if (change === undefined)
481
494
  return passed(await inPlace(), started);
@@ -490,7 +503,7 @@ limitMs) {
490
503
  if (change !== undefined)
491
504
  writeFileSync(join(staging.build, change.file), change.text);
492
505
  const started = Date.now();
493
- return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs), started);
506
+ return passed(await runOnCopy(staging.root, script, [staging.build], buildDir, limitMs, change !== undefined), started);
494
507
  }
495
508
  finally {
496
509
  rmSync(staging.root, { recursive: true, force: true });
@@ -9,6 +9,7 @@ import { modulesScratchDir } from "./paths.js";
9
9
  import { RECORD_FILE } from "./records.js";
10
10
  import { missingToolchain, runUnitScript, scriptFailure } from "./test-scripts.js";
11
11
  import { COMMAND_TIMEOUT_MS } from "./shell.js";
12
+ import { pulseWrote } from "./pulse-files.js";
12
13
  /** Up to this much of a run's output is shown: all of it when it fits, otherwise its end. */
13
14
  const MAX_OUTPUT_CHARS = 60_000;
14
15
  /** A test run has the same time limit as any command: see COMMAND_TIMEOUT_MS. */
@@ -92,6 +93,8 @@ export function keepOutput(dir, label, output) {
92
93
  const name = label.replace(/[^\w.-]+/g, "_").slice(0, 60);
93
94
  const file = join(logs, `${stamp}-${name}.log`);
94
95
  writeFileSync(file, typeof output === "string" ? maskSecrets(output) : maskSecrets(output.toString("utf8")));
96
+ // Read through read_output while the pulse runs, and by no one after it (pulse-files.ts).
97
+ pulseWrote(file);
95
98
  const all = readdirSync(logs).sort();
96
99
  for (const old of all.slice(0, Math.max(0, all.length - MAX_LOGS)))
97
100
  rmSync(join(logs, old), { force: true });