dsh-fast-mode 1.5.18 → 1.5.19

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.
package/src/auto.js CHANGED
@@ -36,6 +36,7 @@
36
36
  */
37
37
 
38
38
  import { statSync } from 'node:fs'
39
+ import { isAbsolute, resolve as resolvePath } from 'node:path'
39
40
  /** The gear id the adaptive scheduler runs under. */
40
41
  export const AUTO = 'auto'
41
42
 
@@ -131,7 +132,7 @@ export function noteEntry(state, agent, width) {
131
132
  *
132
133
  * @param {ReturnType<typeof createAutoState>} state
133
134
  * @param {string} agent
134
- * @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, outputBudgetAfterSteps?: number, openBatch?: boolean, shapeGate?: boolean, scheduler?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean, disciplineAfterSteps?: number, disciplineRepeatAtSteps?: number, disciplineEarlyCalls?: number}} policy
135
+ * @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, outputBudgetAfterSteps?: number, openBatch?: boolean, shapeGate?: boolean, scheduler?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean, disciplineAfterSteps?: number, disciplineRepeatAtSteps?: number, disciplineEarlyCalls?: number, rereadHint?: boolean}} policy
135
136
  */
136
137
  export function notePolicy(state, agent, policy) {
137
138
  const entry = entryOf(state, agent)
@@ -146,6 +147,10 @@ export function notePolicy(state, agent, policy) {
146
147
  // The reminder above is once per turn; a long turn drifts back to prose after
147
148
  // it, so an explicit step can repeat the same ask exactly once more.
148
149
  entry.disciplineRepeatAtSteps = stepCount(policy?.disciplineRepeatAtSteps, 0, 0)
150
+ // One extra sentence, not one extra message: the compression reminder already
151
+ // owns a step where the turn has proven itself long, and that is the only
152
+ // place a re-read rule can be said for free.
153
+ entry.rereadHint = policy?.rereadHint === true
149
154
  // 0 keeps the early reminder unconditional, which is the 1.5.12 behaviour and
150
155
  // the arm that measured slower on short turns.
151
156
  entry.disciplineEarlyCalls = stepCount(policy?.disciplineEarlyCalls, 0, 0)
@@ -223,15 +228,21 @@ function entryOf(state, agent) {
223
228
  /** @type {Map<string, {epoch: number, digest: string}>} */
224
229
  checks: new Map(),
225
230
  /**
226
- * Stat fingerprints of files this session wrote, captured when the write was
227
- * observed and refreshed whenever a check passes. A stored pass is only
228
- * offered for reuse while these still match on disk, which is the one piece
229
- * of evidence the epoch counter cannot see: a write from outside the tool
230
- * call stream.
231
+ * Stat fingerprints of the files this session wrote, keyed by absolute path
232
+ * and captured when the write was observed. They are only read to snapshot
233
+ * the artifact versions a passing check covered; they are never rebaselined
234
+ * by a later check, because that would let one pass make a different pass
235
+ * look current.
231
236
  *
232
237
  * @type {Map<string, {mtimeMs: number, size: number}>}
233
238
  */
234
239
  writes: new Map(),
240
+ /**
241
+ * True once a write happened whose target could not be fingerprinted. The
242
+ * artifact set is then unknown, and no stored pass can be defended by a stat
243
+ * re-read, so reuse is not offered for the rest of the session.
244
+ */
245
+ writesUncertain: false,
235
246
  /** Signature of a check that just repeated with a still-valid stored pass. */
236
247
  reuseHint: '',
237
248
  /** Epoch in which the reuse reminder last fired, so it fires once per epoch. */
@@ -249,8 +260,15 @@ function entryOf(state, agent) {
249
260
  disciplineSent: false,
250
261
  /** Step for the optional repeat of that reminder; 0 = off. */
251
262
  disciplineRepeatAtSteps: AUTO_LIMITS.disciplineRepeatAtSteps,
263
+ /** Append the "do not re-read what this turn wrote" sentence to the reminder. */
264
+ rereadHint: false,
252
265
  /** True once the repeat has been sent. */
253
266
  disciplineRepeatSent: false,
267
+ /**
268
+ * The step the real-time reminder was locked to for this turn, decided once
269
+ * from the first step's call density. 0 means "not decided yet".
270
+ */
271
+ disciplineAtLocked: 0,
254
272
  /** Hard stop: the step count that ends the turn with "report now". */
255
273
  turnStepBudget: AUTO_LIMITS.turnStepBudget,
256
274
  /** Soft stop: one "no more scope" reminder at this step, 0 = off. */
@@ -304,6 +322,7 @@ export function noteTurn(state, agent) {
304
322
  entry.stepProgress = false
305
323
  entry.disciplineSent = false
306
324
  entry.disciplineRepeatSent = false
325
+ entry.disciplineAtLocked = 0
307
326
  entry.finishLineSent = false
308
327
  entry.outBudgetSent = false
309
328
  entry.openingSent = false
@@ -386,14 +405,22 @@ export function noteStep(state, agent) {
386
405
  *
387
406
  * @param {ReturnType<typeof createAutoState>} state
388
407
  * @param {string} agent
389
- * @param {{name?: string, argsKey?: string, isError?: boolean, chars?: number, digest?: string, durationMs?: number}} observation
408
+ * @param {{name?: string, argsKey?: string, isError?: boolean, chars?: number, digest?: string,
409
+ * durationMs?: number, paths?: string[], command?: string, cwd?: string}} observation
410
+ * `argsKey` is dedupe identity only (sorted, truncated); `paths`, `command` and
411
+ * `cwd` are the untruncated execution facts the verification evidence needs.
390
412
  */
391
413
  export function noteTool(state, agent, observation) {
392
414
  const entry = entryOf(state, agent)
393
415
  const obs = observation && typeof observation === 'object' ? observation : {}
394
416
  const name = typeof obs.name === 'string' && obs.name.length > 0 ? obs.name : 'unknown'
395
417
  const argsKey = typeof obs.argsKey === 'string' ? obs.argsKey : ''
396
- const signature = `${name}\u0000${argsKey}`
418
+ // Dedupe identity: bounded and cheap. Execution semantics come from the
419
+ // untruncated command/paths the observation carries, never from this key.
420
+ const command = typeof obs.command === 'string' ? obs.command : ''
421
+ const signature = SHELL_CALLS.has(name) && command.length > 0
422
+ ? `${name}\u0000${command}`
423
+ : `${name}\u0000${argsKey}`
397
424
  const chars = Number.isFinite(obs.chars) ? Number(obs.chars) : -1
398
425
  const digest = typeof obs.digest === 'string' && obs.digest.length > 0 ? obs.digest : `chars:${chars}`
399
426
  const isError = obs.isError === true
@@ -417,7 +444,7 @@ export function noteTool(state, agent, observation) {
417
444
  // command often names a build tool (`npm test`, `make test`): bumping the
418
445
  // epoch on the check itself is what used to make the one command this
419
446
  // reminder targets unable to produce it.
420
- const shellKind = SHELL_CALLS.has(name) ? classifyShell(argsKey) : undefined
447
+ const shellKind = SHELL_CALLS.has(name) ? classifyShell(command.length > 0 ? command : argsKey) : undefined
421
448
  if (MUTATING_CALLS.has(name) || shellKind === 'unknown') {
422
449
  entry.epoch += 1
423
450
  entry.checks.clear()
@@ -425,7 +452,7 @@ export function noteTool(state, agent, observation) {
425
452
  entry.writesSinceCheck += 1
426
453
  entry.batchVerifySent = false
427
454
  }
428
- if (MUTATING_CALLS.has(name)) recordWritePaths(entry, argsKey)
455
+ if (MUTATING_CALLS.has(name)) recordWritePaths(entry, obs)
429
456
 
430
457
  // A whole-file argument is the compressible half of output: the file lands on
431
458
  // disk either way, so the only thing a hunk form changes is how many tokens
@@ -453,7 +480,7 @@ export function noteTool(state, agent, observation) {
453
480
  else if (previous.digest !== digest) progress = true
454
481
  entry.seen.set(signature, { digest, chars, errored: false })
455
482
  entry.errors = 0
456
- noteCheck(entry, argsKey, signature, digest, shellKind)
483
+ noteCheck(entry, signature, digest, shellKind)
457
484
  // Only a verification run is evidence that the current files were checked;
458
485
  // a read-only shell command says nothing about the edits still unverified.
459
486
  if (shellKind === 'verify') {
@@ -498,6 +525,42 @@ const WRITE_WINDOW = 64
498
525
  const MUTATING_CALLS = Object.freeze(new Set(['write', 'write_file', 'create_file', 'edit', 'multi_edit', 'apply_patch', 'patch']))
499
526
  const SHELL_CALLS = Object.freeze(new Set(['bash', 'shell', 'terminal', 'run_code', 'exec']))
500
527
 
528
+ /**
529
+ * What a tool call actually names: the files a mutation targets, the command text
530
+ * a shell call runs. Read from the **untruncated** arguments the hook receives.
531
+ *
532
+ * This exists because the dedupe key cannot be reused as execution semantics: it
533
+ * is sorted by field name and cut at 400 characters, so a whole-file write whose
534
+ * `content` sorts before `path` loses the path entirely — the tracking table then
535
+ * stays empty while the write did happen. Callers pass the result into
536
+ * `noteTool` so the reuse evidence covers the real artifact.
537
+ *
538
+ * @param {unknown} args
539
+ * @returns {{paths: string[], command: string}}
540
+ */
541
+ export function toolTargets(args) {
542
+ const paths = []
543
+ const commands = []
544
+ if (typeof args === 'string') {
545
+ commands.push(args)
546
+ } else if (args && typeof args === 'object') {
547
+ for (const field of PATH_FIELDS) {
548
+ const value = args[field]
549
+ if (typeof value === 'string') paths.push(value)
550
+ else if (Array.isArray(value)) paths.push(...value.filter(part => typeof part === 'string'))
551
+ }
552
+ for (const field of COMMAND_FIELDS) {
553
+ const value = args[field]
554
+ if (typeof value === 'string') commands.push(value)
555
+ else if (Array.isArray(value)) commands.push(...value.filter(part => typeof part === 'string'))
556
+ }
557
+ }
558
+ return {
559
+ paths: paths.filter(path => path.length > 0 && path.length <= 4096),
560
+ command: commands.join('\n').slice(0, 4000),
561
+ }
562
+ }
563
+
501
564
  /** @param {string} key @returns {unknown} */
502
565
  function parseArgs(key) {
503
566
  try {
@@ -599,11 +662,28 @@ function classifyShell(argsKey) {
599
662
  * @param {string} argsKey
600
663
  * @returns {string[]}
601
664
  */
602
- function writePathsOf(argsKey) {
603
- const key = typeof argsKey === 'string' ? argsKey : ''
604
- if (key.length === 0) return []
665
+ function writePathsOf(obs) {
666
+ const direct = obs && Array.isArray(obs.paths) ? obs.paths : []
667
+ const named = direct.filter(path => typeof path === 'string' && path.length > 0 && path.length <= 4096)
668
+ if (named.length > 0) return named
669
+ return pathsFromKey(typeof obs?.argsKey === 'string' ? obs.argsKey : '')
670
+ }
671
+
672
+ /**
673
+ * Fallback target extraction from the bounded dedupe key. Only for hosts that do
674
+ * not pass the real arguments: the key is cut at 400 characters and sorted by
675
+ * field name, so a large whole-file write can lose its path. Treat a key-derived
676
+ * target as a hint, never as proof that the rest of the call touched nothing —
677
+ * the caller marks such a write uncertain when a target cannot be fingerprinted.
678
+ *
679
+ * @param {string} key
680
+ * @returns {string[]}
681
+ */
682
+ function pathsFromKey(key) {
683
+ const argsKey = typeof key === 'string' ? key : ''
684
+ if (argsKey.length === 0) return []
605
685
  const paths = []
606
- const parsed = parseArgs(key)
686
+ const parsed = parseArgs(argsKey)
607
687
  if (typeof parsed === 'string') paths.push(parsed)
608
688
  else if (parsed && typeof parsed === 'object') {
609
689
  for (const field of PATH_FIELDS) {
@@ -613,17 +693,34 @@ function writePathsOf(argsKey) {
613
693
  }
614
694
  } else {
615
695
  const field = /"(?:path|file_path|filePath|target|file|filename)"\s*:\s*("(?:[^"\\]|\\.)*")/g
616
- let match = field.exec(key)
696
+ let match = field.exec(argsKey)
617
697
  while (match) {
618
698
  const value = parseArgs(match[1])
619
699
  if (typeof value === 'string') paths.push(value)
620
- match = field.exec(key)
700
+ match = field.exec(argsKey)
621
701
  }
622
- if (paths.length === 0 && !key.startsWith('{') && !key.startsWith('[')) paths.push(key)
702
+ if (paths.length === 0 && !argsKey.startsWith('{') && !argsKey.startsWith('[')) paths.push(argsKey)
623
703
  }
624
704
  return paths.filter(path => path.length > 0 && path.length <= 4096)
625
705
  }
626
706
 
707
+ /**
708
+ * Resolve a tool-named path the way the tool itself would. A relative path needs
709
+ * the session's project directory: the host process runs somewhere else, so
710
+ * `statSync('./src/a.js')` against the wrong base silently tracked the wrong
711
+ * file (or nothing at all).
712
+ *
713
+ * @param {string} cwd
714
+ * @param {string} path
715
+ * @returns {string|undefined}
716
+ */
717
+ function absolutePath(cwd, path) {
718
+ if (typeof path !== 'string' || path.length === 0) return undefined
719
+ if (isAbsolute(path)) return path
720
+ if (typeof cwd !== 'string' || cwd.length === 0) return undefined
721
+ return resolvePath(cwd, path)
722
+ }
723
+
627
724
  /**
628
725
  * A stat fingerprint, or `undefined` when the path is not visible from this
629
726
  * process. Only visible targets are tracked; the epoch remains the evidence for
@@ -645,7 +742,7 @@ function fingerprintOf(path) {
645
742
  /** @param {Map<string, {mtimeMs: number, size: number}>} writes @param {string} path */
646
743
  function trackPath(writes, path) {
647
744
  const print = fingerprintOf(path)
648
- if (!print) return
745
+ if (!print) return false
649
746
  writes.delete(path)
650
747
  writes.set(path, print)
651
748
  while (writes.size > WRITE_WINDOW) {
@@ -653,34 +750,59 @@ function trackPath(writes, path) {
653
750
  if (oldest === undefined) break
654
751
  writes.delete(oldest)
655
752
  }
753
+ return true
656
754
  }
657
755
 
658
- /** @param {object} entry @param {string} argsKey */
659
- function recordWritePaths(entry, argsKey) {
660
- for (const path of writePathsOf(argsKey)) trackPath(entry.writes, path)
756
+ /**
757
+ * Fingerprint the files a mutation named, resolved against the session directory.
758
+ *
759
+ * @param {object} entry
760
+ * @param {{paths?: string[], cwd?: string, argsKey?: string}} obs
761
+ */
762
+ function recordWritePaths(entry, obs) {
763
+ const cwd = obs && typeof obs.cwd === 'string' ? obs.cwd : ''
764
+ const paths = writePathsOf(obs)
765
+ let tracked = 0
766
+ for (const path of paths) {
767
+ const absolute = absolutePath(cwd, path)
768
+ if (absolute === undefined) continue
769
+ if (trackPath(entry.writes, absolute)) tracked += 1
770
+ }
771
+ // A mutation whose target could not be fingerprinted leaves the artifact set
772
+ // unknown. No stored pass can then be defended by a stat re-read, so the
773
+ // session stops offering reuse instead of guessing.
774
+ if (tracked === 0) entry.writesUncertain = true
661
775
  }
662
776
 
663
777
  /**
664
- * Re-baseline the tracked files at the moment a check passes, so the finger-
665
- * prints describe the artifact that pass proved rather than the write that
666
- * preceded it.
778
+ * Snapshot the versions of every tracked artifact at this instant. This is the
779
+ * evidence a passing check covers: the files on disk when the pass ran, not the
780
+ * state at the last write (which the pass may have been re-run against) and not a
781
+ * session-wide table a different check could refresh.
667
782
  *
668
783
  * @param {object} entry
784
+ * @returns {Map<string, {mtimeMs: number, size: number}>}
669
785
  */
670
- function refreshWritePaths(entry) {
671
- for (const path of [...entry.writes.keys()]) trackPath(entry.writes, path)
786
+ function evidenceSnapshot(entry) {
787
+ const evidence = new Map()
788
+ for (const path of entry.writes.keys()) {
789
+ const print = fingerprintOf(path)
790
+ if (print) evidence.set(path, print)
791
+ }
792
+ return evidence
672
793
  }
673
794
 
674
795
  /**
675
- * True when a file this session wrote no longer matches the fingerprint of the
676
- * pass being offered for reuse. A stat that fails counts as changed: a pass is
677
- * never offered on evidence that could not be re-read.
796
+ * True when a file covered by the stored pass no longer matches the fingerprint
797
+ * captured with that pass. A stat that fails counts as changed: a pass is never
798
+ * offered on evidence that could not be re-read.
678
799
  *
679
- * @param {Map<string, {mtimeMs: number, size: number}>} writes
800
+ * @param {Map<string, {mtimeMs: number, size: number}>} evidence
680
801
  * @returns {boolean}
681
802
  */
682
- function artifactsChanged(writes) {
683
- for (const [path, print] of writes) {
803
+ function evidenceStale(evidence) {
804
+ if (!(evidence instanceof Map)) return true
805
+ for (const [path, print] of evidence) {
684
806
  const now = fingerprintOf(path)
685
807
  if (!now || now.mtimeMs !== print.mtimeMs || now.size !== print.size) return true
686
808
  }
@@ -699,28 +821,35 @@ function withdrawCheck(entry, signature) {
699
821
  }
700
822
 
701
823
  /**
702
- * Store a passing verification as a reusable result, and raise the hint when it
703
- * is a verbatim repeat of one that is still valid for the current epoch.
824
+ * Store a passing verification as a reusable result, together with the artifact
825
+ * versions **that pass actually covered**, and raise the hint when the same check
826
+ * repeats while that evidence still holds.
827
+ *
828
+ * The evidence is per check, never refreshed by a different check: a later pass
829
+ * of command B used to re-baseline the session-wide fingerprints, which made an
830
+ * older pass of command A look current although A had run against older files.
831
+ * Each stored pass now snapshots `entry.writes` at the moment it is seen, and is
832
+ * only offered while those exact versions are still on disk and no write or
833
+ * unrecognised command has bumped the epoch since.
704
834
  *
705
- * Only a recognised verification run is stored. A repeated read or grep is not
706
- * a check: offering to "reuse" its output would be a claim about an artifact
707
- * that nothing verified.
835
+ * Only a recognised verification run is stored. A repeated read or grep is not a
836
+ * check: offering to "reuse" its output would be a claim about an artifact that
837
+ * nothing verified.
708
838
  *
709
839
  * @param {object} entry
710
- * @param {string} argsKey
711
840
  * @param {string} signature
712
841
  * @param {string} digest
713
842
  * @param {'readonly'|'verify'|'unknown'|undefined} shellKind
714
843
  */
715
- function noteCheck(entry, argsKey, signature, digest, shellKind) {
716
- if (shellKind !== 'verify' || argsKey.length === 0) return
717
- refreshWritePaths(entry)
844
+ function noteCheck(entry, signature, digest, shellKind) {
845
+ if (shellKind !== 'verify' || signature.length === 0) return
718
846
  const stored = entry.checks.get(signature)
719
- if (stored && stored.epoch === entry.epoch && stored.digest === digest && entry.reuseHint === '') {
847
+ if (stored && stored.epoch === entry.epoch && stored.digest === digest
848
+ && entry.reuseHint === '' && !entry.writesUncertain && !evidenceStale(stored.evidence)) {
720
849
  entry.reuseHint = signature
721
850
  return
722
851
  }
723
- entry.checks.set(signature, { epoch: entry.epoch, digest })
852
+ entry.checks.set(signature, { epoch: entry.epoch, digest, evidence: evidenceSnapshot(entry) })
724
853
  if (entry.checks.size > 64) {
725
854
  const oldest = entry.checks.keys().next().value
726
855
  if (oldest !== undefined) entry.checks.delete(oldest)
@@ -918,11 +1047,19 @@ function directiveFor(entry, stats) {
918
1047
  // step's own calls have not happened yet, so counting them would let a step pay
919
1048
  // for its own admission.
920
1049
  const callsBefore = entry.turnCalls - entry.stepCalls
921
- const disciplineAt = entry.disciplineEarlyCalls > 0
922
- && earlyAt < AUTO_LIMITS.disciplineAfterSteps
923
- && callsBefore < entry.disciplineEarlyCalls
924
- ? AUTO_LIMITS.disciplineAfterSteps
925
- : earlyAt
1050
+ // The bet is decided once, from the first step's density, and then frozen for
1051
+ // the turn. Re-reading the cumulative count at every step made the reminder
1052
+ // drift: a two-call first step qualified for the early slot at step 2, then the
1053
+ // same turn looked dense at step 3 and pushed the reminder to step 4 instead.
1054
+ if (entry.disciplineEarlyCalls > 0
1055
+ && earlyAt < AUTO_LIMITS.disciplineAfterSteps
1056
+ && entry.disciplineAtLocked === 0
1057
+ && stats.turnSteps >= 2) {
1058
+ entry.disciplineAtLocked = callsBefore < entry.disciplineEarlyCalls
1059
+ ? AUTO_LIMITS.disciplineAfterSteps
1060
+ : earlyAt
1061
+ }
1062
+ const disciplineAt = entry.disciplineAtLocked > 0 ? entry.disciplineAtLocked : earlyAt
926
1063
  if (entry.lateDiscipline && !entry.disciplineSent && stats.turnSteps >= disciplineAt) {
927
1064
  // Late engagement: the prompt carried no prose rules, so the only cost of a
928
1065
  // short task was its own work. By this step the task is demonstrably long,
@@ -935,7 +1072,11 @@ function directiveFor(entry, stats) {
935
1072
  text: 'Auto schedule: this turn is long enough that prose is now the cost. '
936
1073
  + 'For the remaining steps write only what the next action needs: the command, the file, the '
937
1074
  + 'conclusion. No restating file contents, no narrating what you are about to do, no summary of '
938
- + 'what a tool returned. Keep the answer complete; cut the commentary.',
1075
+ + 'what a tool returned. Keep the answer complete; cut the commentary.'
1076
+ + (entry.rereadHint
1077
+ ? ' For a file this turn already wrote, work from the last edit and the check output; do not read '
1078
+ + 'the whole file again just to confirm.'
1079
+ : ''),
939
1080
  summary: 'auto: compress output for the rest of the turn',
940
1081
  }
941
1082
  }
@@ -954,7 +1095,7 @@ function directiveFor(entry, stats) {
954
1095
  }
955
1096
  }
956
1097
  const outBudget = entry.outBudget
957
- const budgetArmed = entry.steps >= (entry.outBudgetAfterSteps || 0)
1098
+ const budgetArmed = stats.turnSteps >= (entry.outBudgetAfterSteps || 0)
958
1099
  if (outBudget > 0 && budgetArmed && !entry.outBudgetSent && stats.turnOutTokens >= outBudget) {
959
1100
  // Evidence that the turn is expensive, not that it is long: the counter is
960
1101
  // the provider's own usage. A three-step turn that wrote 30 000 tokens is a
@@ -1010,14 +1151,16 @@ function directiveFor(entry, stats) {
1010
1151
  // already passed and neither a write nor an unrecognised shell command ran
1011
1152
  // since. Both of those bump the epoch, so reaching this branch already means
1012
1153
  // the tool call stream saw nothing that could have changed the result. The
1013
- // files it wrote are re-read here because that stream cannot see a change
1014
- // that arrived some other way; a stale fingerprint withdraws the pass.
1015
- if (artifactsChanged(entry.writes)) {
1016
- entry.epoch += 1
1017
- entry.checks.clear()
1154
+ // artifacts that particular pass covered are re-read here because the stream
1155
+ // cannot see a change that arrived some other way; a pass whose own evidence
1156
+ // no longer matches is withdrawn, and so is every pass once a write left the
1157
+ // artifact set unknown.
1158
+ const stored = entry.checks.get(entry.reuseHint)
1159
+ const backedByEvidence = stored !== undefined && stored.epoch === entry.epoch
1160
+ && !entry.writesUncertain && !evidenceStale(stored.evidence)
1161
+ if (!backedByEvidence) {
1162
+ entry.checks.delete(entry.reuseHint)
1018
1163
  entry.reuseHint = ''
1019
- entry.writesSinceCheck += 1
1020
- entry.batchVerifySent = false
1021
1164
  } else {
1022
1165
  entry.reuseHint = ''
1023
1166
  entry.reuseSentEpoch = entry.epoch
package/src/config.js CHANGED
@@ -10,12 +10,26 @@
10
10
  * why the on/off switch and the gear selector exist there without a line of
11
11
  * client code.
12
12
  *
13
- * The four form fields (`enabled`, `gear`, `parallel`, `sessions`) are `.volatile()`: that
14
- * is what makes them appear in the generated form *and* what lets an edit reach
15
- * a running fiber instead of forcing a remount. A non-volatile declaration is
16
- * invisible in the form — `describe()` drops it and `update()` refuses the path
17
- * with "is not volatile". The measurement paths below are deliberately *not*
18
- * volatile: they are read once at load, like `debugFile`.
13
+ * The seven form fields (`enabled`, `gear`, `parallel`, `sessions`,
14
+ * `outputDiscipline`, `stepBudget`, `workspaceMap`) are `.volatile()`: that is
15
+ * what makes them appear in the generated form *and* what lets an edit reach a
16
+ * running fiber instead of forcing a remount. A non-volatile declaration is
17
+ * invisible in the form — `describe()` projects through `volatileForm()`, which
18
+ * keeps only volatile nodes, and `update()` refuses the path with "is not
19
+ * volatile".
20
+ *
21
+ * The panel's three rows are volatile for exactly that reason: the client half
22
+ * has one channel to read state from, `describe()`, so a non-volatile field is a
23
+ * row it can write into `sessions` but never paint — it would keep showing the
24
+ * built-in default (`late` / 0 / off) while a hand-written `cordis.patch.yml`
25
+ * said something else. Reading the effective config from the plugin half instead
26
+ * was rejected: it needs a new remote method and a new config field to carry
27
+ * values `describe()` already projects, and the client still could not read them.
28
+ *
29
+ * Volatile does not mean "not saved": the host strips volatile keys from the raw
30
+ * layer and merges the projected form back on top, so an explicit value in the
31
+ * patch layer survives a write. It only means an edit needs no remount. The knobs
32
+ * below stay non-volatile: they are read once at load, like `debugFile`.
19
33
  *
20
34
  * Only the fields that belong on a settings page are declared. Every other
21
35
  * knob (`metrics`, `metricsFile`, `benchmarkFile`, `debug`, `debugFile`,
@@ -80,6 +94,7 @@ export const Config = z.object({
80
94
  outputDiscipline: z
81
95
  .union([z.boolean(), z.const('late')])
82
96
  .default('late')
97
+ .volatile()
83
98
  .description(
84
99
  '输出纪律 / Output discipline(面板「少写过程」,也是没有自己条目的会话的默认值): 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
85
100
  ),
@@ -97,6 +112,7 @@ export const Config = z.object({
97
112
  .min(0)
98
113
  .max(80)
99
114
  .default(0)
115
+ .volatile()
100
116
  .description(
101
117
  '单轮步数预算 / Steps per turn(面板「最多几步」,0 显示为「不限」): 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
102
118
  ),
@@ -181,9 +197,17 @@ export const Config = z.object({
181
197
  '压缩指令的重复步 / Compression repeat step: `outputDiscipline: late` 的提醒每回合只发一次,第 4 步之后的长回合(10 步以上)输出会重新变长。给一个正数就按那一步再提醒一次「仍然只写下一步要用的」,仍然每回合最多一次。0 = 不发(默认,行为不变)。',
182
198
  ),
183
199
 
200
+ compactRereadHint: z
201
+ .boolean()
202
+ .default(false)
203
+ .description(
204
+ '别重读刚写过的文件 / No re-read after edit: `outputDiscipline: late` 的第 4 步提醒里追加一句「本回合已经写过的文件,按最后一次编辑和检查输出来判断,不要为了确认整份重读」。依据是迁移类任务的长尾:一个回合 12–20 次读文件调用里有一部分是刚写完又整份读回来,省下的是工具参数与回显的输出 token。只在压缩提醒里多一句话,不新增消息、不改调度;默认关闭以保持 1.5.17 行为。',
205
+ ),
206
+
184
207
  workspaceMap: z
185
208
  .boolean()
186
209
  .default(false)
210
+ .volatile()
187
211
  .description(
188
212
  '工作区清单 / Workspace inventory(面板「先列文件清单」): 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
189
213
  ),
package/src/effort.js CHANGED
@@ -266,6 +266,8 @@ export function resolveConfig(raw, overrides) {
266
266
  disciplineRepeatAtSteps: gear.directive
267
267
  ? Math.max(0, Math.floor(number(config.disciplineRepeatAtSteps, 0)))
268
268
  : 0,
269
+ /** Extra sentence in the compression reminder: do not re-read a file this turn wrote. */
270
+ rereadHint: gear.directive && config.compactRereadHint === true,
269
271
  /** One "batch the opening step" reminder per turn, for short tasks. */
270
272
  openBatch: gear.directive && config.openBatch === true,
271
273
  /** One "send hunks, not whole files" reminder after a large whole-file write. */