staysfixed 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/init.js CHANGED
@@ -78,6 +78,14 @@ import { PRODUCT_KINDS, detectProject } from './detect.js';
78
78
  * one need said twice — doctor asks "what is missing on this
79
79
  * machine", this file asks "what is missing from these
80
80
  * settings", and on a server with no snapshot both answer.
81
+ * @property {boolean} [stopgap] A need that exists only so a surface can never read as ready
82
+ * because the machine check came back with nothing to say. The
83
+ * machine check has actually looked at this machine, so when it
84
+ * does answer on the same topic its answer is better and this
85
+ * one steps aside. It is here for the case where it answers
86
+ * nothing at all, which is exactly when it goes quiet: doctor
87
+ * returns no Windows needs when there is no Windows host, which
88
+ * is the very situation somebody has to be told about.
81
89
  */
82
90
 
83
91
  /**
@@ -267,7 +275,7 @@ const SURFACE_FOR_PRODUCT = /** @type {Record<string, string>} */ ({
267
275
  * @param {Capabilities|null} machine
268
276
  * @returns {Readiness[]}
269
277
  */
270
- function readinessFor(project, machine) {
278
+ export function readinessFor(project, machine) {
271
279
  /** @type {Readiness[]} */
272
280
  const out = [];
273
281
 
@@ -296,8 +304,18 @@ function readinessFor(project, machine) {
296
304
  }
297
305
 
298
306
  const mine = productNeeds(product, project);
307
+ // A stopgap is deliberately kept out of what the machine check is told is already
308
+ // covered, so the machine check still gets to answer on that topic. If it does, its
309
+ // answer wins — it has looked at this actual machine and can name the host it found —
310
+ // and the stopgap drops out. If it says nothing, the stopgap is what stops the product
311
+ // being called ready over a surface that cannot run at all.
312
+ const fromMachine = machineNeeds(surface, product, mine.filter((need) => !need.stopgap));
313
+ const answered = new Set(fromMachine.map((need) => need.topic).filter(Boolean));
299
314
  /** @type {Need[]} */
300
- const needs = [...mine, ...machineNeeds(surface, product, mine)];
315
+ const needs = [
316
+ ...mine.filter((need) => !(need.stopgap && need.topic && answered.has(need.topic))),
317
+ ...fromMachine,
318
+ ];
301
319
 
302
320
  if (needs.length === 0) {
303
321
  out.push({
@@ -311,15 +329,25 @@ function readinessFor(project, machine) {
311
329
  continue;
312
330
  }
313
331
 
314
- const everythingIsACommand = needs.every((need) => need.who === 'the agent');
332
+ // A need nobody can clear is not a job, and telling somebody they have work to do when
333
+ // they have none is how a set-up list gets a line that never comes off it. It still keeps
334
+ // the product out of "covered in full", which is the entire reason it exists.
335
+ const actionable = needs.filter((need) => need.who !== 'nobody');
336
+ const permanent = needs.filter((need) => need.who === 'nobody');
337
+ const everythingIsACommand = actionable.length > 0 && actionable.every((need) => need.who === 'the agent');
338
+ const alsoPermanent = permanent.length > 0 && actionable.length > 0
339
+ ? ` One other thing about it can never be checked here, and it is listed below.`
340
+ : '';
315
341
  out.push({
316
342
  product: product.name,
317
343
  kind: product.kind,
318
344
  surface: product.surface,
319
345
  state: everythingIsACommand ? 'the agent can fix this' : 'only a person can do this',
320
- summary: everythingIsACommand
321
- ? `${sentenceCase(product.name)} needs ${needs.length === 1 ? 'one thing' : `${needs.length} things`} setting up, and all of it can be done without asking anybody.`
322
- : `${sentenceCase(product.name)} needs ${needs.filter((n) => n.who === 'a person').length === 1 ? 'one thing' : 'a few things'} only you can supply.`,
346
+ summary: actionable.length === 0
347
+ ? `${sentenceCase(product.name)} is checked as far as this tool can reach it, and one thing about it can never be checked here: ${permanent[0].what}.`
348
+ : everythingIsACommand
349
+ ? `${sentenceCase(product.name)} needs ${actionable.length === 1 ? 'one thing' : `${actionable.length} things`} setting up, and all of it can be done without asking anybody.${alsoPermanent}`
350
+ : `${sentenceCase(product.name)} needs ${actionable.filter((n) => n.who === 'a person').length === 1 ? 'one thing' : 'a few things'} only you can supply.${alsoPermanent}`,
323
351
  needs,
324
352
  });
325
353
  }
@@ -372,6 +400,26 @@ function productNeeds(product, project) {
372
400
  const needs = [];
373
401
  const suggest = product.suggest ?? {};
374
402
 
403
+ // A product this tool can boot and can run but cannot READ. Nothing anybody does clears
404
+ // this one, and that is exactly why it is here: without it a Go server that gets booted
405
+ // and answered was reported as covered in full, and "in full" was a lie about the half
406
+ // nobody was looking at. A permanent hole said out loud is worth more than a clean result
407
+ // that means less than it says.
408
+ if (product.sourceBlind) {
409
+ const { language, reads } = product.sourceBlind;
410
+ needs.push({
411
+ what: reads ? `the rest of the ${language} source, which nothing here reads` : `the ${language} source, which nothing here reads`,
412
+ why: reads
413
+ ? `The source channel reads ${reads} out of this ${language} project. Everything else it holds — what it exports, what it reads out of the environment — is not read at all, so a change to any of that is invisible to this tool.`
414
+ : `Nothing here reads ${language} source. ${product.kind === 'server' ? 'The server is booted and watched, but its addresses were never read, so a route that quietly disappears is invisible.' : 'The command is run and every word it prints is compared, but a change inside it that never reaches the output is invisible.'}`,
415
+ unlocks: 'nothing anybody can do today — it is written down so a clean run here is never mistaken for a full one',
416
+ fix: `Nobody can fix this. It is a limit of this tool, not of your project. What IS checked here is real: ${product.kind === 'server' ? 'the server is booted on a spare port and asked for every address that could be read' : 'the command is run and everything it prints, returns and touches is compared'}.`,
417
+ who: 'nobody',
418
+ product: product.name,
419
+ topic: 'source',
420
+ });
421
+ }
422
+
375
423
  if (product.kind === 'electron') {
376
424
  if (!product.built.found) {
377
425
  needs.push({
@@ -429,6 +477,83 @@ function productNeeds(product, project) {
429
477
  });
430
478
  }
431
479
 
480
+ // An Android app with no package built. Exactly the same hole the iPhone one above was
481
+ // written to close, and it was still open here: a bare Gradle project was told "the Android
482
+ // app can be checked here now" and "nothing is being left out", and then the very next
483
+ // command said there was nothing to walk in this project at all. Setup promising more than
484
+ // the run can deliver is the one failure this tool cannot afford, because it is the failure
485
+ // that makes a clean result mean nothing.
486
+ if (product.kind === 'android' && !product.built.found) {
487
+ const build = typeof suggest.buildWith === 'string' ? String(suggest.buildWith) : null;
488
+ needs.push({
489
+ what: 'the app built as a package',
490
+ why: 'An Android app is checked by installing a built package on an emulator. There is no built package here yet, and a repository usually does not commit one.',
491
+ unlocks: 'opening the app on an emulator and reading what the screen says every control is and does',
492
+ fix: build
493
+ ? `cd ${product.where} && ${build} (then set android.apk in the settings to the .apk it wrote)`
494
+ : 'Build the app the way this project normally does, then set android.apk in the settings to the .apk it wrote.',
495
+ who: build ? 'the agent' : 'a person',
496
+ product: product.name,
497
+ topic: 'app',
498
+ });
499
+ }
500
+
501
+ // A native desktop app, which needs two things this machine may not have, and which had no
502
+ // branch here at all. Its adapter refuses to run without a machine of the right operating
503
+ // system AND a built program — and doctor returns NO Windows needs at all when there is no
504
+ // Windows host, which is precisely the case somebody has to be told about. Empty needs plus
505
+ // an empty machine list read as "ready", so a Tauri app on a machine with no Windows box
506
+ // was reported as covered in full, and then the run said there was nowhere to open it.
507
+ //
508
+ // Both are stopgaps: on a machine where doctor DID find a host it says so by name, which is
509
+ // more useful than anything that can be written here, and these step aside for it.
510
+ if (product.kind === 'desktopNative') {
511
+ needs.push({
512
+ what: 'a machine running the operating system this app is built for',
513
+ why: 'A native window can only be read from the operating system it runs on, so this needs a machine running that one. Any ssh host that gets you a shell counts, including a shell on a Windows box, and nothing has to be installed on it.',
514
+ unlocks: 'opening the window and reading what it says every control is and does',
515
+ fix: 'Put {"host": "your-windows-box"} under "windows" in the settings.',
516
+ who: 'a person',
517
+ product: product.name,
518
+ topic: 'host',
519
+ stopgap: true,
520
+ });
521
+ if (!product.built.found) {
522
+ needs.push({
523
+ what: 'the built program',
524
+ why: 'A native app is checked by opening the built program, not the source, and there is no built program here yet.',
525
+ unlocks: 'opening the real window instead of reading about it',
526
+ fix: `Build it the way this project normally does${project.scripts.build ? ` — \`${project.scripts.build}\`` : ''}, then put {"exe": "path/to/YourApp.exe"} under "windows" in the settings — or {"remoteExe": "C:\\\\path\\\\to\\\\YourApp.exe"} if the build already lives on that machine, which is much faster.`,
527
+ who: project.scripts.build ? 'the agent' : 'a person',
528
+ product: product.name,
529
+ topic: 'app',
530
+ stopgap: true,
531
+ });
532
+ }
533
+ }
534
+
535
+ // A command-line program or a library with nothing named to run or import. The process
536
+ // adapter refuses outright in that state, and nothing here said so: doctor's answer for
537
+ // this surface is hardcoded to an empty list, so an empty needs list met an empty machine
538
+ // list and the product read as ready over an adapter that would not start.
539
+ const somethingToRun = (Array.isArray(suggest.commands) ? suggest.commands.length : 0)
540
+ + (Array.isArray(suggest.imports) ? suggest.imports.length : 0);
541
+ if ((product.kind === 'cli' || product.kind === 'library') && somethingToRun === 0) {
542
+ needs.push({
543
+ what: product.kind === 'library' ? 'something to import and compare' : 'a list of commands worth running',
544
+ why: 'Nothing was worked out here that this could actually run or import, and a run with nothing to do proves nothing about anything.',
545
+ unlocks: product.kind === 'library'
546
+ ? 'comparing what it exports and what those exports do'
547
+ : 'every word of what a command prints, what it exits with and every file it touches',
548
+ fix: product.kind === 'library'
549
+ ? 'Put {"imports": [{"name": "the package entry", "module": "./src/index.js"}]} under "process" in the settings.'
550
+ : 'Put {"commands": [{"name": "help", "run": "your-command --help"}]} under "process" in the settings. Only ever --help: a command in a manifest could deploy or publish, and running one because it was there would be this tool causing the damage it exists to catch.',
551
+ who: 'a person',
552
+ product: product.name,
553
+ topic: 'commands',
554
+ });
555
+ }
556
+
432
557
  // A command-line program that has to be built before it can be run. This is what a product
433
558
  // nothing in package.json names looks like on a fresh clone: the source is there, the
434
559
  // program is real, and the file that would be run does not exist yet.
@@ -651,7 +776,16 @@ function topicOf(text) {
651
776
  const words = text.toLowerCase();
652
777
  if (/snapshot|restore|database|the same data|data folder/.test(words)) return 'data';
653
778
  if (/starts it|command that starts|start\b/.test(words)) return 'start';
654
- if (/built app|app\.binary|electron\.binary/.test(words)) return 'app';
779
+ // "a built app", "a built iPhone app", "a built package" and "the app built as a package"
780
+ // are one missing thing, not four. The pattern used to be the two literal words "built app"
781
+ // and so it matched none of the ones with the platform's name in the middle — which is how
782
+ // an iPhone app with nothing built ended up asked for twice on one screen, once with a
783
+ // command to run and once with a paragraph saying the tool would never run it. Two lines
784
+ // about one missing file, apparently disagreeing, in front of somebody who does not write
785
+ // code.
786
+ if (/built (?:[a-z]+ )?(?:app|bundle|package|program)|built as a (?:package|bundle)|app\.binary|electron\.binary|android\.apk|\bapk\b|windows\.exe|remoteexe/.test(words)) return 'app';
787
+ if (/machine with a windows desktop|windows machine|ssh host|"host":/.test(words)) return 'host';
788
+ if (/commands worth running|to import and compare|process\.commands/.test(words)) return 'commands';
655
789
  if (/device id|identity|identityenv/.test(words)) return 'identity';
656
790
  if (/sample|real value/.test(words)) return 'samples';
657
791
  if (/browser|playwright|chromium/.test(words)) return 'browser';
@@ -690,7 +824,10 @@ function sortNeeds(readiness, project, machine) {
690
824
  what: 'one build on record as working',
691
825
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
692
826
  unlocks: 'every check from then on',
693
- fix: 'staysfixed check --paired (or ship once with `staysfixed ship` at the end of your release)',
827
+ // `check --paired` was named here first, and it does not do this: run it twice on a
828
+ // fresh project and both runs answer "there is no build on record as working". Only
829
+ // shipping cuts a reference, on purpose — no agent may bless its own work.
830
+ fix: 'staysfixed ship (only shipping records what "working" means — run it once at the end of your release)',
694
831
  who: 'the agent',
695
832
  });
696
833
  }
@@ -1014,7 +1151,10 @@ export function configText(project) {
1014
1151
  w(' // "working" means, so the name is how two of them are told apart.');
1015
1152
  if (project.products.length > 1) {
1016
1153
  w(' // This repository makes more than one thing. A check covers whichever of them the');
1017
- w(' // settings below describe; run the others with `staysfixed check --product <name>`.');
1154
+ // `--product` is not an option on `check` and never has been. Naming it here sent people
1155
+ // to a flag the CLI rejects, in the settings file the tool itself wrote for them.
1156
+ w(' // settings below describe. To check one of the others, run `staysfixed check` from');
1157
+ w(' // inside that package, or point at its settings with `--config <file>`.');
1018
1158
  }
1019
1159
  w(` product: ${JSON.stringify(project.name)},`);
1020
1160
  w('');
@@ -1208,7 +1348,12 @@ export function configText(project) {
1208
1348
  } else if (project.pages.length > 0) {
1209
1349
  w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
1210
1350
  w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
1211
- w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ fill: '#email', with: 'a@b.c' }, { click: 'Sign in' }] }],`);
1351
+ // `fill:` and `with:` are not words this tool knows the verb is `type:` and the value
1352
+ // is `text:`. An unknown key used to be skipped in silence, so this example, handed to
1353
+ // every stranger with a sign-in, filled nothing, clicked Sign in on an empty form, and
1354
+ // then photographed the login page for every screen behind the wall while reporting a
1355
+ // clean run. The example that teaches the vocabulary has to be IN the vocabulary.
1356
+ w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ type: '#email', text: 'a@b.c' }, { type: '#password', text: 'secret' }, { click: 'Sign in' }] }],`);
1212
1357
  } else {
1213
1358
  w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
1214
1359
  }
@@ -1421,15 +1566,29 @@ function whatItCovers(readiness) {
1421
1566
  const covered = readiness.filter((r) => r.state === 'ready').map((r) => r.product);
1422
1567
  const partly = readiness.filter((r) => r.state === 'the agent can fix this' || r.state === 'only a person can do this').map((r) => r.product);
1423
1568
  const notCovered = readiness.filter((r) => r.state === 'not possible here').map((r) => r.product);
1569
+ // A product whose only outstanding thing is a permanent limit is still not covered in
1570
+ // full — but "not covered YET" would promise somebody a job that will never exist, so it
1571
+ // gets the sentence further down instead of this one.
1572
+ const waiting = readiness
1573
+ .filter((r) => partly.includes(r.product) && r.needs.some((n) => n.who !== 'nobody'))
1574
+ .map((r) => r.product);
1424
1575
 
1425
1576
  /** @type {string[]} */
1426
1577
  const parts = [];
1427
1578
  if (covered.length > 0) parts.push(`Right now a check here covers ${plainList(covered)} in full.`);
1428
1579
  else parts.push('Right now a check here covers nothing in full.');
1429
- if (partly.length > 0) parts.push(`${plainList(partly, true)} ${partly.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1580
+ if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1430
1581
  if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
1431
1582
  if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) parts.push('Nothing is being left out.');
1432
1583
 
1584
+ // The hole that never closes, named with the language that causes it. "Not covered yet"
1585
+ // reads as a job somebody will get to; this one is nobody's job and saying so is the
1586
+ // difference between an honest limit and a promise that is never kept.
1587
+ const blind = readiness.filter((r) => r.needs.some((n) => n.who === 'nobody' && n.topic === 'source'));
1588
+ if (blind.length > 0) {
1589
+ parts.push(`${plainList(blind.map((r) => r.product), true)} ${blind.length === 1 ? 'is' : 'are'} written in a language this tool cannot read the way it reads JavaScript, so what is watched is what ${blind.length === 1 ? 'it does' : 'they do'} when run rather than what the code says — the list below names the language. No amount of setting up changes that.`);
1590
+ }
1591
+
1433
1592
  return { covered, partly, notCovered, short: parts.join(' ') };
1434
1593
  }
1435
1594
 
@@ -1452,9 +1611,14 @@ function nextCommands(readiness, project) {
1452
1611
  if (reachable.length > 0) {
1453
1612
  next.push({
1454
1613
  command: 'staysfixed check --paired',
1614
+ // It does NOT record what "working" means, and saying so here sent everybody round a
1615
+ // loop: run it, be told there is no build on record, run it again, be told the same
1616
+ // thing. Only `ship` cuts a reference — that is the rule the whole product rests on,
1617
+ // because an agent that can bless its own work is not a safety net. So this says what
1618
+ // the run actually does, and `ship` below says what only it can do.
1455
1619
  what: reachable.some((r) => r.state === 'ready')
1456
- ? 'The first real run. It records what working looks like, so later runs have something to compare against.'
1457
- : 'The first real run. Nothing here is fully set up yet, so it records what it can reach and says plainly what it left out — which is more useful than waiting.',
1620
+ ? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means only shipping does that.'
1621
+ : 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
1458
1622
  });
1459
1623
  }
1460
1624
  if (project.tests.files > 0) {
@@ -1469,58 +1633,6 @@ function nextCommands(readiness, project) {
1469
1633
  // Words
1470
1634
  // ---------------------------------------------------------------------------
1471
1635
 
1472
- /**
1473
- * The plan, said out loud, short.
1474
- *
1475
- * Short is a requirement rather than a preference. Everything here also exists as data in the
1476
- * plan object, and an agent reads that; a person reads this, and a person reading twenty lines
1477
- * reads none of them.
1478
- *
1479
- * @param {InitPlan} plan
1480
- * @returns {string[]}
1481
- */
1482
- export function describePlan(plan) {
1483
- /** @type {string[]} */
1484
- const lines = [];
1485
- lines.push(plan.project.summary);
1486
- lines.push('');
1487
- lines.push(plan.covers.short);
1488
-
1489
- if (plan.needs.person.length > 0) {
1490
- lines.push('');
1491
- lines.push(plan.needs.person.length === 1 ? 'One thing needs you:' : `${plan.needs.person.length} things need you:`);
1492
- for (const need of plan.needs.person) {
1493
- lines.push(` ${need.what} — ${need.why} It unlocks ${need.unlocks}.`);
1494
- lines.push(` ${need.fix}`);
1495
- }
1496
- }
1497
- for (const need of plan.needs.impossible) {
1498
- lines.push('');
1499
- lines.push(`Not possible here: ${need.what}. ${need.fix}`);
1500
- }
1501
- // Code nothing could account for is the quietest way a run over-claims, so it is said in
1502
- // the summary a person reads rather than left in the data an agent might not open.
1503
- if (plan.project.unsure.length > 0) {
1504
- lines.push('');
1505
- lines.push('Worth knowing:');
1506
- for (const doubt of plan.project.unsure) lines.push(` ${doubt}`);
1507
- }
1508
- return lines;
1509
- }
1510
-
1511
- /**
1512
- * @param {InitResult} result
1513
- * @returns {string[]}
1514
- */
1515
- export function describeResult(result) {
1516
- const lines = describePlan(result.plan);
1517
- lines.push('');
1518
- if (result.written.length > 0) lines.push(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}.`);
1519
- if (result.kept.length > 0) lines.push(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}.`);
1520
- for (const problem of result.problems) lines.push(problem);
1521
- return lines;
1522
- }
1523
-
1524
1636
  // ---------------------------------------------------------------------------
1525
1637
  // Small helpers
1526
1638
  // ---------------------------------------------------------------------------
@@ -1665,7 +1777,12 @@ export async function run(ctx) {
1665
1777
  continue;
1666
1778
  }
1667
1779
  for (const need of item.needs) {
1668
- say(paint.grey(` ${mark.info} ${need.what} — ${need.who === 'the agent' ? 'the tool can do this itself: ' : 'somebody has to: '}${need.fix}`));
1780
+ // A need nobody can clear must not read like a job. "Somebody has to" in front of a
1781
+ // permanent limit sends a person looking for the thing they are supposed to do.
1782
+ const label = need.who === 'the agent' ? 'the tool can do this itself: '
1783
+ : need.who === 'nobody' ? 'nobody can do anything about this: '
1784
+ : 'somebody has to: ';
1785
+ say(paint.grey(` ${mark.info} ${need.what} — ${label}${need.fix}`));
1669
1786
  }
1670
1787
  }
1671
1788
 
package/src/v2/intent.js CHANGED
@@ -44,6 +44,7 @@ import { promisify } from 'node:util';
44
44
  import { safeName } from '../core/paths.js';
45
45
  import { StaysFixedError } from '../core/errors.js';
46
46
  import { referencePointer } from './store.js';
47
+ import { NOT_THE_TOOLS_OWN_FOLDER } from './rank.js';
47
48
 
48
49
  const run = promisify(execFile);
49
50
 
@@ -215,17 +216,6 @@ export async function readIntents(store, product) {
215
216
  return raw.filter((i) => i && typeof i === 'object' && typeof i.id === 'string' && typeof i.summary === 'string');
216
217
  }
217
218
 
218
- /**
219
- * Forget a product's intents. Housekeeping, and the way a test starts clean.
220
- *
221
- * @param {Store} store
222
- * @param {string} product
223
- * @returns {Promise<void>}
224
- */
225
- export async function forgetIntents(store, product) {
226
- await fsp.rm(intentsFile(store, product), { force: true });
227
- }
228
-
229
219
  // ---------------------------------------------------------------------------
230
220
  // Does this finding fall inside what was declared?
231
221
  // ---------------------------------------------------------------------------
@@ -397,11 +387,17 @@ export async function fingerprintTree(root) {
397
387
  }
398
388
 
399
389
  const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], root);
400
- const status = (await git(['status', '--porcelain'], root)) ?? '';
390
+ // Both questions are asked with this tool's own folder left out, the same way ranking asks
391
+ // them. Without that, sealing an intent moves the tree it has just fingerprinted — the seal
392
+ // writes into .staysfixed, git reports the new file, and the next comparison says the code
393
+ // changed when nobody touched a line of it. In a project that has run `init` the folder is
394
+ // in .gitignore and this makes no difference; in one that has not, it is the difference
395
+ // between "you edited this after you sealed" and the truth.
396
+ const status = (await git(['status', '--porcelain', '--', NOT_THE_TOOLS_OWN_FOLDER], root)) ?? '';
401
397
  const changedFiles = filesFromStatus(status);
402
398
  // The diff itself is hashed rather than kept. Two moments only ever need to be compared, and
403
399
  // keeping the text would put a copy of the working tree in a file people commit by accident.
404
- const diff = (await git(['diff', 'HEAD'], root)) ?? '';
400
+ const diff = (await git(['diff', 'HEAD', '--', NOT_THE_TOOLS_OWN_FOLDER], root)) ?? '';
405
401
  const digest = shortDigest([head, status, diff]);
406
402
 
407
403
  return {
@@ -523,16 +519,6 @@ async function git(args, cwd) {
523
519
  // The bookkeeping this lane shares
524
520
  // ---------------------------------------------------------------------------
525
521
 
526
- /**
527
- * Where intents and waivers live: beside the engine's observations, not inside them.
528
- *
529
- * @param {Store} store
530
- * @returns {string}
531
- */
532
- export function stateDir(store) {
533
- return store.dir;
534
- }
535
-
536
522
  /**
537
523
  * @param {Store} store
538
524
  * @param {string} product