@dzhechkov/harness-core 0.8.46 → 0.8.47

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 (143) hide show
  1. package/.dz-manifest.json +175 -163
  2. package/README.md +84 -2
  3. package/dist/agentdb-index.d.ts.map +1 -1
  4. package/dist/agentdb-index.js +32 -2
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/agentdb-snapshot.d.ts +14 -1
  7. package/dist/agentdb-snapshot.d.ts.map +1 -1
  8. package/dist/agentdb-snapshot.js +100 -4
  9. package/dist/agentdb-snapshot.js.map +1 -1
  10. package/dist/apply-leg.d.ts +9 -1
  11. package/dist/apply-leg.d.ts.map +1 -1
  12. package/dist/apply-leg.js +26 -4
  13. package/dist/apply-leg.js.map +1 -1
  14. package/dist/architecture.d.ts +0 -5
  15. package/dist/architecture.d.ts.map +1 -1
  16. package/dist/architecture.js +25 -2
  17. package/dist/architecture.js.map +1 -1
  18. package/dist/codex-rollouts.d.ts +35 -7
  19. package/dist/codex-rollouts.d.ts.map +1 -1
  20. package/dist/codex-rollouts.js +201 -113
  21. package/dist/codex-rollouts.js.map +1 -1
  22. package/dist/cost-ledger.d.ts +42 -3
  23. package/dist/cost-ledger.d.ts.map +1 -1
  24. package/dist/cost-ledger.js +478 -15
  25. package/dist/cost-ledger.js.map +1 -1
  26. package/dist/feature-adr-routing.d.ts +16 -0
  27. package/dist/feature-adr-routing.d.ts.map +1 -1
  28. package/dist/feature-adr-routing.js +21 -0
  29. package/dist/feature-adr-routing.js.map +1 -1
  30. package/dist/guard.d.ts +7 -0
  31. package/dist/guard.d.ts.map +1 -1
  32. package/dist/guard.js +48 -0
  33. package/dist/guard.js.map +1 -1
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +10 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/loop-plan.d.ts +4 -0
  39. package/dist/loop-plan.d.ts.map +1 -1
  40. package/dist/loop-plan.js +4 -0
  41. package/dist/loop-plan.js.map +1 -1
  42. package/dist/mutation-gate.d.ts +36 -1
  43. package/dist/mutation-gate.d.ts.map +1 -1
  44. package/dist/mutation-gate.js +109 -19
  45. package/dist/mutation-gate.js.map +1 -1
  46. package/dist/npm-homepage.d.ts +64 -0
  47. package/dist/npm-homepage.d.ts.map +1 -0
  48. package/dist/npm-homepage.js +109 -0
  49. package/dist/npm-homepage.js.map +1 -0
  50. package/dist/operations.d.ts +6 -0
  51. package/dist/operations.d.ts.map +1 -1
  52. package/dist/operations.js +150 -24
  53. package/dist/operations.js.map +1 -1
  54. package/dist/pack-inventory.d.ts.map +1 -1
  55. package/dist/pack-inventory.js +6 -1
  56. package/dist/pack-inventory.js.map +1 -1
  57. package/dist/parity.d.ts.map +1 -1
  58. package/dist/parity.js +4 -1
  59. package/dist/parity.js.map +1 -1
  60. package/dist/publish-sibling-drift.d.ts +43 -1
  61. package/dist/publish-sibling-drift.d.ts.map +1 -1
  62. package/dist/publish-sibling-drift.js +269 -13
  63. package/dist/publish-sibling-drift.js.map +1 -1
  64. package/dist/publish.d.ts +5 -1
  65. package/dist/publish.d.ts.map +1 -1
  66. package/dist/publish.js +18 -1
  67. package/dist/publish.js.map +1 -1
  68. package/dist/qe-bridge.d.ts +63 -1
  69. package/dist/qe-bridge.d.ts.map +1 -1
  70. package/dist/qe-bridge.js +82 -0
  71. package/dist/qe-bridge.js.map +1 -1
  72. package/dist/release-package-audit.d.ts +86 -0
  73. package/dist/release-package-audit.d.ts.map +1 -0
  74. package/dist/release-package-audit.js +272 -0
  75. package/dist/release-package-audit.js.map +1 -0
  76. package/dist/release.d.ts +8 -1
  77. package/dist/release.d.ts.map +1 -1
  78. package/dist/release.js +58 -32
  79. package/dist/release.js.map +1 -1
  80. package/dist/round.d.ts +10 -1
  81. package/dist/round.d.ts.map +1 -1
  82. package/dist/round.js +5 -1
  83. package/dist/round.js.map +1 -1
  84. package/dist/run-records.d.ts +11 -0
  85. package/dist/run-records.d.ts.map +1 -1
  86. package/dist/run-records.js +124 -20
  87. package/dist/run-records.js.map +1 -1
  88. package/dist/stage-usage.d.ts +148 -0
  89. package/dist/stage-usage.d.ts.map +1 -0
  90. package/dist/stage-usage.js +261 -0
  91. package/dist/stage-usage.js.map +1 -0
  92. package/dist/statusline.d.ts +23 -0
  93. package/dist/statusline.d.ts.map +1 -1
  94. package/dist/statusline.js +167 -1
  95. package/dist/statusline.js.map +1 -1
  96. package/dist/workflow-run-dispatch.d.ts +21 -19
  97. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  98. package/dist/workflow-run-dispatch.js +206 -95
  99. package/dist/workflow-run-dispatch.js.map +1 -1
  100. package/dist/workflow-run.d.ts +12 -3
  101. package/dist/workflow-run.d.ts.map +1 -1
  102. package/dist/workflow-run.js +29 -2
  103. package/dist/workflow-run.js.map +1 -1
  104. package/package.json +3 -3
  105. package/sbom.json +264 -234
  106. package/src/agentdb-index.ts +31 -2
  107. package/src/agentdb-snapshot.ts +105 -4
  108. package/src/apply-leg.ts +26 -4
  109. package/src/architecture.ts +15 -2
  110. package/src/codex-rollouts.ts +155 -147
  111. package/src/cost-ledger.ts +308 -19
  112. package/src/feature-adr-routing.ts +22 -0
  113. package/src/guard.ts +52 -0
  114. package/src/index.ts +15 -3
  115. package/src/loop-plan.ts +8 -0
  116. package/src/mutation-gate.ts +153 -19
  117. package/src/npm-homepage.ts +142 -0
  118. package/src/operations.ts +177 -22
  119. package/src/pack-inventory.ts +6 -1
  120. package/src/parity.ts +4 -1
  121. package/src/publish-sibling-drift.ts +214 -11
  122. package/src/publish.ts +20 -1
  123. package/src/qe-bridge.ts +86 -1
  124. package/src/release-package-audit.ts +264 -0
  125. package/src/release.ts +55 -22
  126. package/src/round.ts +8 -0
  127. package/src/run-records.ts +101 -22
  128. package/src/stage-usage.ts +191 -0
  129. package/src/statusline.ts +149 -1
  130. package/src/workflow-run-dispatch.ts +148 -89
  131. package/src/workflow-run.ts +40 -4
  132. package/dist/ledger-cost-fill.d.ts +0 -58
  133. package/dist/ledger-cost-fill.d.ts.map +0 -1
  134. package/dist/ledger-cost-fill.js +0 -78
  135. package/dist/ledger-cost-fill.js.map +0 -1
  136. package/dist/retro.d.ts +0 -131
  137. package/dist/retro.d.ts.map +0 -1
  138. package/dist/retro.js +0 -207
  139. package/dist/retro.js.map +0 -1
  140. package/dist/sbom.d.ts +0 -42
  141. package/dist/sbom.d.ts.map +0 -1
  142. package/dist/sbom.js +0 -120
  143. package/dist/sbom.js.map +0 -1
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export { REPOSITORY_ORIGIN } from './repository-origin.js';
19
19
  export { noSearchableTermsReason } from '@dzhechkov/memory';
20
20
 
21
21
  export * from './skills.js';
22
+ export { buildStageUsageReport } from './stage-usage.js';
23
+ export { deriveStageUsageReport, renderStageUsageReport } from './cost-ledger.js';
22
24
  export * from './apply.js';
23
25
  export {
24
26
  buildPublicSnapshot,
@@ -175,6 +177,8 @@ export {
175
177
  parseBridgeOutput,
176
178
  buildBridgeFailureRecord,
177
179
  renderBridgeReport,
180
+ resolveBridgeRoundIdentity,
181
+ selectQeBridgeRoundSignoff,
178
182
  } from './qe-bridge.js';
179
183
  export type { BridgeFamily, BridgeFailureReason, BridgeFinding, BridgeSignoff, BridgeParse, BridgeParseOk, BridgeParseFail, BridgeChannels, BridgeAudit, ClaudeResultExtraction, NamedExtract, BridgePromptInput } from './qe-bridge.js';
180
184
  export type { PatternRecord, SessionRecord, LearningConfig, MemoryLearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, RecallPatternsOptions, RecordLessonFormsResult, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult, ReinforcementState, ReinforcePatternResult, StoreStats, LessonDeltaReport, LessonDeltaRow, QuarantineState, PromoteResult, QuarantineExpiryCandidate } from './patterns.js';
@@ -325,8 +329,8 @@ export type {
325
329
  StoreGuardPrunePlan,
326
330
  StoreGuardPruneDeps,
327
331
  } from './store-guard-prune.js';
328
- export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath, writeFeatureAdrStateDetailed, renderFeatureAdrPhaseLine } from './statusline.js';
329
- export type { StatuslineData, StatuslineStoreHealth, FeatureAdrState, WriteFeatureAdrStateInput, WriteFeatureAdrStateResult } from './statusline.js';
332
+ export { statuslineData, readFeatureAdrObservation, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath, writeFeatureAdrStateDetailed, renderFeatureAdrPhaseLine } from './statusline.js';
333
+ export type { StatuslineData, StatuslineStoreHealth, FeatureAdrObservation, FeatureAdrSelector, FeatureAdrState, WriteFeatureAdrStateInput, WriteFeatureAdrStateResult } from './statusline.js';
330
334
  export {
331
335
  ETA_MAX_STAGE_MS,
332
336
  estimateEta,
@@ -782,7 +786,7 @@ export type {
782
786
  } from './event-chain.js';
783
787
  export { packArtifact, readWorkspaceVersions, UnknownWorkspaceSpecError } from './pack-artifact.js';
784
788
  export type { PackArtifactResult, ExecLike } from './pack-artifact.js';
785
- export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
789
+ export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, validatePublishFilters, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
786
790
  export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
787
791
  export { RELEASE_LINE_RE, parseReleaseLine, findReleaseLine, rewriteReleaseLine, shortPackageName } from './release-line.js';
788
792
  export type { ReleaseLineToken, ReleaseLineParse, ReleaseLineMatch } from './release-line.js';
@@ -958,6 +962,8 @@ export {
958
962
  PLAN_GATE_SCRIPT,
959
963
  planCompletenessGateCmd,
960
964
  parsePlanGateVerdict,
965
+ // landed-barrier-anchored-line: the design/plan/plan-repair landing-barrier reply parser.
966
+ parseLandedProbe,
961
967
  // p16-non-js-portability: the gate-script search chain's operator note (ADR-002/AM-7) and the
962
968
  // dzBin absolutization (ADR-003). Named for the same reason as the three above.
963
969
  refusalNoteFor,
@@ -1036,6 +1042,7 @@ export type {
1036
1042
  ParsedBaselineCapture,
1037
1043
  ParsedLandingSignal,
1038
1044
  PlanGateVerdict,
1045
+ LandedProbeVerdict,
1039
1046
  PlanSnapshot,
1040
1047
  PlanGateCmdOpts,
1041
1048
  CodexReviewCommandInput,
@@ -1156,6 +1163,8 @@ export * from './bto-optimize.js';
1156
1163
  export * from './discrimination-gate.js';
1157
1164
  export * from './guard.js';
1158
1165
  export * from './guard-volume.js';
1166
+ // npm-homepage-guard (backlog e5d0d383): pure facts + fs reader for the `npm-homepage` guard rule.
1167
+ export * from './npm-homepage.js';
1159
1168
  // Root AGENTS.md policy projection: pure anchor extraction/render/hash drift +
1160
1169
  // the shared-emitter I/O entry point exported from operations.
1161
1170
  export * from './agents-policy.js';
@@ -1384,3 +1393,6 @@ export { decideTestReceipt, renderTestReceiptVerdict } from './test-receipt.js';
1384
1393
  export type { TestReceipt, TestReceiptVerdict, TestReceiptScopeState } from './test-receipt.js';
1385
1394
  export { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
1386
1395
  export type { BackupFreshnessVerdict } from './backup-freshness.js';
1396
+
1397
+ export { planReleasePackageAudit, judgeReleasePackageAudit, judgeReleaseCohortAudit } from './release-package-audit.js';
1398
+ export type { ReleasePackageAuditPlan, ReleasePackageAuditResult, ReleaseWorkspaceAuditReport } from './release-package-audit.js';
package/src/loop-plan.ts CHANGED
@@ -1489,6 +1489,10 @@ export function toTraceProjection(plan: LoopPlan): TraceProjection {
1489
1489
  /** One dispatching step as the scheduler needs it — everything it must know to build a prompt,
1490
1490
  * pick a model, spend budget, retry, and verify a deliverable landed. */
1491
1491
  export interface RunStepSpec {
1492
+ role?: string | null;
1493
+ tier?: string | null;
1494
+ mode?: string | null;
1495
+ estimate?: unknown;
1492
1496
  stepId: string;
1493
1497
  kind: 'agent' | 'gate';
1494
1498
  phase: string;
@@ -1574,6 +1578,10 @@ export function toRunProjection(plan: LoopPlan): RunProjection {
1574
1578
  stepId: s.stepId,
1575
1579
  kind: s.kind === 'gate' ? 'gate' : 'agent',
1576
1580
  phase: s.phase,
1581
+ role: typeof s['x-role'] === 'string' ? s['x-role'] : null,
1582
+ tier: typeof s['x-tier'] === 'string' ? s['x-tier'] : null,
1583
+ mode: typeof s['x-mode'] === 'string' ? s['x-mode'] : null,
1584
+ estimate: s['x-estimate'] ?? null,
1577
1585
  prompt: typeof s.prompt === 'string' ? s.prompt : null,
1578
1586
  model: typeof s.model === 'string' && s.model !== '' ? s.model : null,
1579
1587
  deliverable: s.deliverable ?? 'return-value',
@@ -121,6 +121,51 @@ export interface InjectVitestWorkerCeilingResult {
121
121
  /** segments where `vitest run` was found only by the LOOSE token-pair fallback (command position
122
122
  * unrecognised) — the CLI reports these so an odd wrapper shape is visible, not silent. */
123
123
  readonly looseSegments: number;
124
+ /**
125
+ * vitest-ceiling-injection-hardening FR-1: set when the command cannot be split with confidence —
126
+ * an UNTERMINATED quote leaves both the segment boundaries and the argument ends to guesswork (a
127
+ * swallowed `&&` would move a boundary). The command is then returned byte-identical, all counts
128
+ * are 0, and the reason is named here — never a best-effort injection. Absent on the normal path.
129
+ */
130
+ readonly notEstablished?: VitestInjectionNotEstablished;
131
+ }
132
+
133
+ /** vitest-ceiling-injection-hardening FR-1: why the injection was not attempted. */
134
+ export interface VitestInjectionNotEstablished {
135
+ readonly reason: 'unterminated-single-quote' | 'unterminated-double-quote' | 'unsupported-shell-construct';
136
+ /**
137
+ * set only for `unsupported-shell-construct` (astra r1, MAJOR 1): the construct the scanner does NOT
138
+ * model — reading the quotes inside it as ordinary quotes gave valid commands a false
139
+ * `unterminated-*` reason. Deliberately a refusal, not a parser: heuristic shell parsing does not converge.
140
+ */
141
+ readonly construct?: UnsupportedShellConstruct;
142
+ /** human-readable, names the quote kind / construct and the raw offset where it starts. */
143
+ readonly message: string;
144
+ }
145
+
146
+ /** shell constructs `splitUnquotedSegments` detects and REFUSES rather than models. */
147
+ export type UnsupportedShellConstruct =
148
+ | 'comment' // unquoted `#` at a word start
149
+ | 'heredoc' // unquoted `<<` (also `<<<`)
150
+ | 'command-substitution' // `$(` unquoted or inside double quotes
151
+ | 'backtick-substitution' // a backtick unquoted or inside double quotes
152
+ | 'parameter-expansion' // `${` unquoted or inside double quotes
153
+ | 'ansi-c-quoting'; // unquoted `$'`
154
+
155
+ /**
156
+ * vitest-ceiling-injection-hardening FR-2: the backslash policy. The gate spawns the command with
157
+ * `shell: true` (`/bin/sh` on POSIX, `cmd.exe` on win32), and the two disagree on `\`: `/bin/sh`
158
+ * treats it as an escape (so `C:\proj\vitest` unquoted really becomes `C:projvitest` and is not
159
+ * vitest), `cmd.exe` does not. `win32` here means ONLY: backslash is a literal path character.
160
+ * cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT modelled — quotes, operators and the
161
+ * unsupported-construct refusals stay POSIX-shaped on both (astra r1, MAJOR 2). Core never reads
162
+ * `process.platform` (core-boundary rule A), so the caller passes it; the default is `posix`.
163
+ */
164
+ export type VitestInjectionPlatform = 'posix' | 'win32';
165
+
166
+ export interface InjectVitestWorkerCeilingOptions {
167
+ /** default `'posix'` — HEAD behaviour. `'win32'`: backslash is a literal path character (never an escape, no line continuation); cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT modelled. */
168
+ readonly platform?: VitestInjectionPlatform;
124
169
  }
125
170
 
126
171
  interface RawSegment {
@@ -129,17 +174,54 @@ interface RawSegment {
129
174
  readonly terminator: string;
130
175
  }
131
176
 
177
+ interface SplitResult {
178
+ readonly segments: RawSegment[];
179
+ /** the quote left open at the end of the command, with the raw offset that opened it; null when all closed. */
180
+ readonly unterminated: { readonly quote: "'" | '"'; readonly offset: number } | null;
181
+ /** the first construct the scanner does not model, with its raw offset; null when none. Checked before `unterminated`. */
182
+ readonly unsupported: { readonly construct: UnsupportedShellConstruct; readonly offset: number } | null;
183
+ }
184
+
185
+ /** `$(` / `${` / backtick — active both unquoted and inside double quotes. */
186
+ function substitutionAt(cmd: string, i: number): UnsupportedShellConstruct | null {
187
+ const ch = cmd[i];
188
+ if (ch === '`') return 'backtick-substitution';
189
+ if (ch === '$' && cmd[i + 1] === '(') return 'command-substitution';
190
+ if (ch === '$' && cmd[i + 1] === '{') return 'parameter-expansion';
191
+ return null;
192
+ }
193
+
132
194
  /**
133
195
  * Split on unquoted `&&`/`||`/`;`/`|`, preserving each segment's own text (incl. surrounding
134
196
  * whitespace). POSIX-escape aware (fix-round 1, F1): outside quotes `\` makes the next character
135
197
  * literal (so it can neither open a quote nor start an operator); inside double quotes `\"` and `\\`
136
198
  * are recognised escapes that do NOT close the string; inside single quotes nothing is escaped.
199
+ * With `backslashEscapes` false (win32, vitest-ceiling-injection-hardening FR-2) backslash is a
200
+ * literal path character everywhere; cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT
201
+ * modelled. A quote still open at the end is REPORTED, never guessed past (FR-1); a construct the
202
+ * scanner does not model (comment, heredoc, `$(`, backtick, `${`, `$'`) is REFUSED before that (astra r1).
137
203
  */
138
- function splitUnquotedSegments(cmd: string): RawSegment[] {
204
+ function splitUnquotedSegments(cmd: string, backslashEscapes: boolean): SplitResult {
139
205
  const segments: RawSegment[] = [];
140
206
  let segStart = 0;
141
207
  let inSingle = false;
142
208
  let inDouble = false;
209
+ let quoteOpenedAt = -1;
210
+ // astra r2: raw indices of characters made literal by an unquoted POSIX `\` — an escaped space
211
+ // continues the word, so a `#` after it is NOT at a word start (`issue\ #123` is one word).
212
+ const escapedAt = new Set<number>();
213
+ /** true when position `at` starts a shell word: string start, or an UNESCAPED whitespace/operator
214
+ * before it; a `\<newline>` continuation is removed by the shell, so look past it. */
215
+ const atWordStart = (at: number): boolean => {
216
+ let prev = at - 1;
217
+ for (;;) {
218
+ if (prev < 0) return true;
219
+ if (escapedAt.has(prev) && cmd[prev] === '\n') { prev -= 2; continue; } // `\<LF>` continuation
220
+ if (escapedAt.has(prev) && cmd[prev] === '\r' && cmd[prev + 1] === '\n') { prev -= 2; continue; }
221
+ break;
222
+ }
223
+ return !escapedAt.has(prev) && /[\s;&|()<>]/u.test(cmd[prev]!);
224
+ };
143
225
  let i = 0;
144
226
  while (i < cmd.length) {
145
227
  const ch = cmd[i];
@@ -150,32 +232,47 @@ function splitUnquotedSegments(cmd: string): RawSegment[] {
150
232
  }
151
233
  if (inDouble) {
152
234
  if (ch === '"') { inDouble = false; i += 1; continue; }
153
- if (ch === '\\') {
235
+ if (ch === '\\' && backslashEscapes) {
154
236
  const next = cmd[i + 1];
155
- // at least \" and \\ (F1's floor) — an escaped quote must not close the double-quoted span.
156
- if (next === '"' || next === '\\') { i += 2; continue; }
237
+ // at least \" and \\ (F1's floor) — an escaped quote must not close the double-quoted span;
238
+ // \$ and \` are POSIX double-quote escapes too — an escaped `$(` is not a substitution.
239
+ if (next === '"' || next === '\\' || next === '$' || next === '`') { i += 2; continue; }
157
240
  i += 1;
158
241
  continue;
159
242
  }
243
+ const inner = substitutionAt(cmd, i);
244
+ if (inner !== null) return { segments, unterminated: null, unsupported: { construct: inner, offset: i } };
160
245
  i += 1;
161
246
  continue;
162
247
  }
248
+ if (ch === '\\' && backslashEscapes) {
249
+ // Outside any quote, POSIX makes the character AFTER `\` literal — skip both so it can never
250
+ // be mis-read as a quote-open, a construct or an operator boundary.
251
+ if (cmd[i + 1] !== undefined) escapedAt.add(i + 1);
252
+ if (cmd[i + 1] === '\r' && cmd[i + 2] === '\n') escapedAt.add(i + 2); // `\<CR><LF>` continuation
253
+ i += cmd[i + 1] === '\r' && cmd[i + 2] === '\n' ? 3 : cmd[i + 1] !== undefined ? 2 : 1;
254
+ continue;
255
+ }
256
+ // astra r1, MAJOR 1: constructs the scanner does NOT model are refused BEFORE quote tracking can
257
+ // misread the quotes inside them (a comment's apostrophe, a heredoc body, a nested substitution).
258
+ const construct: UnsupportedShellConstruct | null = substitutionAt(cmd, i)
259
+ ?? (ch === '$' && cmd[i + 1] === "'" ? 'ansi-c-quoting'
260
+ : ch === '<' && cmd[i + 1] === '<' ? 'heredoc'
261
+ : ch === '#' && atWordStart(i) ? 'comment'
262
+ : null);
263
+ if (construct !== null) return { segments, unterminated: null, unsupported: { construct, offset: i } };
163
264
  if (ch === "'") {
164
265
  inSingle = true;
266
+ quoteOpenedAt = i;
165
267
  i += 1;
166
268
  continue;
167
269
  }
168
270
  if (ch === '"') {
169
271
  inDouble = true;
272
+ quoteOpenedAt = i;
170
273
  i += 1;
171
274
  continue;
172
275
  }
173
- if (ch === '\\') {
174
- // Outside any quote, POSIX makes the character AFTER `\` literal — skip both so it can never
175
- // be mis-read as a quote-open or an operator boundary.
176
- i += cmd[i + 1] !== undefined ? 2 : 1;
177
- continue;
178
- }
179
276
  const terminatorMatch = /^(&&|\|\||;|\|)/.exec(cmd.slice(i));
180
277
  if (terminatorMatch) {
181
278
  segments.push({ text: cmd.slice(segStart, i), terminator: terminatorMatch[0] });
@@ -186,7 +283,10 @@ function splitUnquotedSegments(cmd: string): RawSegment[] {
186
283
  i += 1;
187
284
  }
188
285
  segments.push({ text: cmd.slice(segStart), terminator: '' });
189
- return segments;
286
+ const unterminated = inSingle ? { quote: "'" as const, offset: quoteOpenedAt }
287
+ : inDouble ? { quote: '"' as const, offset: quoteOpenedAt }
288
+ : null;
289
+ return { segments, unterminated, unsupported: null };
190
290
  }
191
291
 
192
292
  interface SegmentToken {
@@ -208,7 +308,7 @@ function isWhitespaceChar(ch: string | undefined): boolean {
208
308
  * `<char>` outside any quote, single-quoted content kept verbatim) while `token.end` keeps the RAW
209
309
  * source offset so `injectIntoSegment` can still splice into the ORIGINAL text unchanged elsewhere.
210
310
  */
211
- function tokenizeSegment(text: string): SegmentToken[] {
311
+ function tokenizeSegment(text: string, backslashEscapes: boolean): SegmentToken[] {
212
312
  const tokens: SegmentToken[] = [];
213
313
  let i = 0;
214
314
  while (i < text.length) {
@@ -229,7 +329,7 @@ function tokenizeSegment(text: string): SegmentToken[] {
229
329
  }
230
330
  if (inDouble) {
231
331
  if (ch === '"') { inDouble = false; i += 1; continue; }
232
- if (ch === '\\') {
332
+ if (ch === '\\' && backslashEscapes) {
233
333
  const next = text[i + 1];
234
334
  if (next === '"' || next === '\\') { value += next; i += 2; continue; }
235
335
  // not one of the two claimed double-quote escapes: the backslash is literal (F1's floor).
@@ -243,7 +343,7 @@ function tokenizeSegment(text: string): SegmentToken[] {
243
343
  }
244
344
  if (ch === "'") { inSingle = true; i += 1; continue; }
245
345
  if (ch === '"') { inDouble = true; i += 1; continue; }
246
- if (ch === '\\') {
346
+ if (ch === '\\' && backslashEscapes) {
247
347
  const next = text[i + 1];
248
348
  // POSIX line continuation (lead delta after Codex r2, MEDIUM): `\<newline>` (and `\<CR><LF>`)
249
349
  // is REMOVED by the shell, never a literal — a token must not swallow a newline as its value.
@@ -357,8 +457,8 @@ function findVitestRunLooseIndex(tokens: readonly SegmentToken[]): number | null
357
457
  return null;
358
458
  }
359
459
 
360
- function injectIntoSegment(text: string, maxWorkers: number): { readonly text: string; readonly isVitest: boolean; readonly injected: boolean; readonly loose: boolean } {
361
- const tokens = tokenizeSegment(text);
460
+ function injectIntoSegment(text: string, maxWorkers: number, backslashEscapes: boolean): { readonly text: string; readonly isVitest: boolean; readonly injected: boolean; readonly loose: boolean } {
461
+ const tokens = tokenizeSegment(text, backslashEscapes);
362
462
  const strictIdx = findVitestRunCommandIndex(tokens);
363
463
  const looseIdx = strictIdx === null ? findVitestRunLooseIndex(tokens) : null;
364
464
  const runTokenIdx = strictIdx ?? looseIdx;
@@ -373,13 +473,47 @@ function injectIntoSegment(text: string, maxWorkers: number): { readonly text: s
373
473
  return { text: injectedText, isVitest: true, injected: true, loose };
374
474
  }
375
475
 
376
- export function injectVitestWorkerCeiling(testCmd: string, maxWorkers: number): InjectVitestWorkerCeilingResult {
377
- const segments = splitUnquotedSegments(testCmd);
476
+ export function injectVitestWorkerCeiling(
477
+ testCmd: string,
478
+ maxWorkers: number,
479
+ options: InjectVitestWorkerCeilingOptions = {},
480
+ ): InjectVitestWorkerCeilingResult {
481
+ const backslashEscapes = options.platform !== 'win32';
482
+ const split = splitUnquotedSegments(testCmd, backslashEscapes);
483
+ if (split.unsupported !== null) {
484
+ const { construct, offset } = split.unsupported;
485
+ return {
486
+ cmd: testCmd,
487
+ vitestSegments: 0,
488
+ injected: 0,
489
+ looseSegments: 0,
490
+ notEstablished: {
491
+ reason: 'unsupported-shell-construct',
492
+ construct,
493
+ message: `unsupported shell construct '${construct}' at offset ${offset} — the scanner does not model it, so segment boundaries cannot be established; the command is left unchanged and --maxWorkers is NOT injected`,
494
+ },
495
+ };
496
+ }
497
+ if (split.unterminated !== null) {
498
+ const { quote, offset } = split.unterminated;
499
+ const kind = quote === '"' ? 'double' : 'single';
500
+ return {
501
+ cmd: testCmd,
502
+ vitestSegments: 0,
503
+ injected: 0,
504
+ looseSegments: 0,
505
+ notEstablished: {
506
+ reason: quote === '"' ? 'unterminated-double-quote' : 'unterminated-single-quote',
507
+ message: `unterminated ${kind} quote opened at offset ${offset} — segment boundaries cannot be established; the command is left unchanged and --maxWorkers is NOT injected`,
508
+ },
509
+ };
510
+ }
511
+ const segments = split.segments;
378
512
  let vitestSegments = 0;
379
513
  let injected = 0;
380
514
  let looseSegments = 0;
381
515
  const rebuilt = segments.map((segment) => {
382
- const result = injectIntoSegment(segment.text, maxWorkers);
516
+ const result = injectIntoSegment(segment.text, maxWorkers, backslashEscapes);
383
517
  if (result.isVitest) vitestSegments += 1;
384
518
  if (result.injected) injected += 1;
385
519
  if (result.loose) looseSegments += 1;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * npm-homepage — the facts behind the `dz guard` rule of the same name (backlog e5d0d383).
3
+ *
4
+ * Owner rule 2026-09-28 (`.claude/rules/npm-homepage.md`): every `packages/@dzhechkov/*` package, public or
5
+ * private, carries `homepage` = exactly `https://aicoding.space`. The GitHub links are NOT replaced — npm has
6
+ * one `homepage` field, so the site lives there and GitHub stays in `repository` (url + directory) and in
7
+ * `bugs.url`, and the package README carries both links. The rule therefore checks all of them: dropping the
8
+ * GitHub ones while "fixing" homepage is the exact regression the owner's clarification forbids.
9
+ *
10
+ * Every accepted value is an EXACT string from a closed list — no URL parsing, no normalisation (fix round 1,
11
+ * review r1 finding 1: a substring test accepted `https://evil.example/github.com/djd1m/dz-harness`). The lead
12
+ * measured all 57 manifests: each carries exactly the one value listed below.
13
+ *
14
+ * Two halves, so the evaluator is testable without a filesystem:
15
+ * - `npmHomepageFacts(records, discovery)` (here) is PURE: records in, per-package problem lists out;
16
+ * - `readNpmHomepageRecords(dir)` lives in harness-cli (`cli.ts`): it lists package directories (hidden ones
17
+ * included), parses `package.json`, reads `README.md`, and returns every entry it could NOT decide as a
18
+ * named discovery failure — a permission error is a failure, never "absent". It is NOT here because
19
+ * harness-core's IO ratchet (test/core-boundary.test.ts) forbids a new file importing node:fs —
20
+ * MEASURED 2026-09-28: 66 > pinned 65 when it lived here.
21
+ */
22
+
23
+ /** The owner's site. Compared by strict equality: a trailing slash, a path or an anchor is a violation. */
24
+ export const NPM_HOMEPAGE_URL = 'https://aicoding.space';
25
+ /** Accepted `repository.url` values — a closed list, compared by strict equality. One entry today. */
26
+ export const NPM_REPOSITORY_URLS: readonly string[] = ['git+https://github.com/djd1m/dz-harness.git'];
27
+ /** The one accepted `bugs.url`. */
28
+ export const NPM_BUGS_URL = 'https://github.com/djd1m/dz-harness/issues';
29
+ const README_SITE = NPM_HOMEPAGE_URL;
30
+ const README_SOURCE = 'https://github.com/djd1m/dz-harness';
31
+ const PACKAGES_PREFIX = 'packages/@dzhechkov/';
32
+
33
+ /** One package directory as the reader found it. */
34
+ export interface NpmHomepageRecord {
35
+ /** The directory name under `packages/@dzhechkov/` — the expected tail of `repository.directory`. */
36
+ readonly dir: string;
37
+ readonly json?: unknown;
38
+ readonly parseError?: string;
39
+ /** README.md text; `null` = the file does not exist; absent = not read (treated as missing). */
40
+ readonly readme?: string | null;
41
+ /** README.md exists but could not be read. */
42
+ readonly readmeError?: string;
43
+ }
44
+
45
+ /** An entry the reader could not decide (stat/read/existence failed for a reason other than "absent"). */
46
+ export interface NpmHomepageDiscoveryFailure {
47
+ readonly path: string;
48
+ readonly reason: string;
49
+ }
50
+
51
+ /** What the reader saw besides the records. `unreadableRoot` set ⇒ the rule is NOT ESTABLISHED. */
52
+ export interface NpmHomepageDiscovery {
53
+ readonly failures?: readonly NpmHomepageDiscoveryFailure[];
54
+ readonly unreadableRoot?: string;
55
+ }
56
+
57
+ /** The fact the guard rule reads: one entry per package; an empty `problems` list means compliant. */
58
+ export interface NpmHomepageFact {
59
+ readonly dir: string;
60
+ /** `name` from package.json when it is a string, else `packages/@dzhechkov/<dir>`. */
61
+ readonly name: string;
62
+ readonly problems: readonly string[];
63
+ }
64
+
65
+ /** The whole evidence set for the rule. */
66
+ export interface NpmHomepageFactSet {
67
+ readonly packages: readonly NpmHomepageFact[];
68
+ readonly discoveryFailures: readonly NpmHomepageDiscoveryFailure[];
69
+ readonly unreadableRoot?: string;
70
+ }
71
+
72
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
73
+
74
+ // Fix round 2 (review r2 finding 2): the two links must be WHOLE URLs outside code. A URL ends at end of line,
75
+ // whitespace, `)`, `]`, `>`, `,`, a quote, or a `.` followed by whitespace/end — so
76
+ // `https://aicoding.space.evil.example` and `https://aicoding.space/x` do not count. The source link may continue
77
+ // with `/` (a legitimate subpath such as `/tree/main/packages/…`), but not with `-imposter`.
78
+ const URL_END = String.raw`(?=$|[\s)\]>,"']|\.(?:\s|$))`;
79
+ const SITE_RE = new RegExp(`https://aicoding\\.space${URL_END}`);
80
+ const SOURCE_RE = new RegExp(`https://github\\.com/djd1m/dz-harness(?=/|${URL_END.slice(3)}`);
81
+ const FENCE_RE = /^\s{0,3}(```|~~~)/;
82
+
83
+ /** One README line, outside fenced code and outside inline-code spans, carrying BOTH whole links (any order). */
84
+ function readmeProblem(rec: NpmHomepageRecord): string | undefined {
85
+ if (rec.readmeError !== undefined) return `README.md could not be read (${rec.readmeError})`;
86
+ if (typeof rec.readme !== 'string') return 'README.md is missing — it must carry a line with both the site and the GitHub link';
87
+ let inFence = false;
88
+ let ok = false;
89
+ for (const line of rec.readme.split(/\r?\n/)) {
90
+ if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
91
+ if (inFence) continue;
92
+ const prose = line.replace(/`[^`]*`/g, ' ');
93
+ if (SITE_RE.test(prose) && SOURCE_RE.test(prose)) { ok = true; break; }
94
+ }
95
+ return ok ? undefined : `README.md has no line (outside code) carrying both ${README_SITE} and ${README_SOURCE} as whole links`;
96
+ }
97
+
98
+ /** Pure: evaluate each record against the field and README requirements. Private packages are NOT exempt. */
99
+ export function npmHomepageFacts(records: readonly NpmHomepageRecord[], discovery: NpmHomepageDiscovery = {}): NpmHomepageFactSet {
100
+ const out: NpmHomepageFact[] = [];
101
+ for (const rec of Array.isArray(records) ? records : []) {
102
+ const dir = typeof rec?.dir === 'string' ? rec.dir : '(unknown dir)';
103
+ const fallbackName = `${PACKAGES_PREFIX}${dir}`;
104
+ const readme = rec && typeof rec === 'object' ? readmeProblem(rec) : 'record is not an object';
105
+ if (rec?.parseError !== undefined) {
106
+ out.push({ dir, name: fallbackName, problems: [`package.json does not parse (${rec.parseError})`, ...(readme ? [readme] : [])] });
107
+ continue;
108
+ }
109
+ const j = rec?.json;
110
+ if (!isObject(j)) {
111
+ out.push({ dir, name: fallbackName, problems: ['package.json is not a JSON object', ...(readme ? [readme] : [])] });
112
+ continue;
113
+ }
114
+ const name = typeof j['name'] === 'string' && j['name'] !== '' ? j['name'] : fallbackName;
115
+ const problems: string[] = [];
116
+ if (j['homepage'] !== NPM_HOMEPAGE_URL) {
117
+ problems.push(`homepage = ${JSON.stringify(j['homepage'])}, must be exactly "${NPM_HOMEPAGE_URL}"`);
118
+ }
119
+ const repo = j['repository'];
120
+ const repoUrl = isObject(repo) ? repo['url'] : undefined;
121
+ if (typeof repoUrl !== 'string' || !NPM_REPOSITORY_URLS.includes(repoUrl)) {
122
+ problems.push(`repository.url = ${JSON.stringify(repoUrl)}, must be exactly one of ${JSON.stringify(NPM_REPOSITORY_URLS)}`);
123
+ }
124
+ const repoDir = isObject(repo) ? repo['directory'] : undefined;
125
+ if (repoDir !== `${PACKAGES_PREFIX}${dir}`) {
126
+ problems.push(`repository.directory = ${JSON.stringify(repoDir)}, must be "${PACKAGES_PREFIX}${dir}"`);
127
+ }
128
+ const bugs = j['bugs'];
129
+ const bugsUrl = isObject(bugs) ? bugs['url'] : undefined;
130
+ if (bugsUrl !== NPM_BUGS_URL) {
131
+ problems.push(`bugs.url = ${JSON.stringify(bugsUrl)}, must be "${NPM_BUGS_URL}"`);
132
+ }
133
+ if (readme !== undefined) problems.push(readme);
134
+ out.push({ dir, name, problems });
135
+ }
136
+ const failures = Array.isArray(discovery?.failures) ? [...discovery.failures] : [];
137
+ return {
138
+ packages: out,
139
+ discoveryFailures: failures,
140
+ ...(typeof discovery?.unreadableRoot === 'string' ? { unreadableRoot: discovery.unreadableRoot } : {}),
141
+ };
142
+ }