@dzhechkov/harness-core 0.8.24 → 0.8.26

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 (64) hide show
  1. package/.dz-manifest.json +153 -33
  2. package/README.md +150 -4
  3. package/dist/confirmation-file-gate.d.ts +20 -0
  4. package/dist/confirmation-file-gate.d.ts.map +1 -0
  5. package/dist/confirmation-file-gate.js +76 -0
  6. package/dist/confirmation-file-gate.js.map +1 -0
  7. package/dist/contract-checklist.d.ts +1 -0
  8. package/dist/contract-checklist.d.ts.map +1 -1
  9. package/dist/contract-checklist.js +7 -4
  10. package/dist/contract-checklist.js.map +1 -1
  11. package/dist/core-boundary.d.ts +11 -0
  12. package/dist/core-boundary.d.ts.map +1 -0
  13. package/dist/core-boundary.js +41 -0
  14. package/dist/core-boundary.js.map +1 -0
  15. package/dist/feature-adr-landing.d.ts +4 -2
  16. package/dist/feature-adr-landing.d.ts.map +1 -1
  17. package/dist/feature-adr-landing.js +5 -3
  18. package/dist/feature-adr-landing.js.map +1 -1
  19. package/dist/feature-tier.d.ts +4 -0
  20. package/dist/feature-tier.d.ts.map +1 -0
  21. package/dist/feature-tier.js +55 -0
  22. package/dist/feature-tier.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/journal.d.ts +27 -0
  28. package/dist/journal.d.ts.map +1 -0
  29. package/dist/journal.js +56 -0
  30. package/dist/journal.js.map +1 -0
  31. package/dist/loop-blobs.generated.d.ts +1 -1
  32. package/dist/loop-blobs.generated.d.ts.map +1 -1
  33. package/dist/loop-blobs.generated.js +10 -1
  34. package/dist/loop-blobs.generated.js.map +1 -1
  35. package/dist/patterns.d.ts +5 -0
  36. package/dist/patterns.d.ts.map +1 -1
  37. package/dist/patterns.js +16 -0
  38. package/dist/patterns.js.map +1 -1
  39. package/dist/publish.d.ts +19 -0
  40. package/dist/publish.d.ts.map +1 -1
  41. package/dist/publish.js +38 -16
  42. package/dist/publish.js.map +1 -1
  43. package/dist/run-cleanup.d.ts +26 -0
  44. package/dist/run-cleanup.d.ts.map +1 -0
  45. package/dist/run-cleanup.js +33 -0
  46. package/dist/run-cleanup.js.map +1 -0
  47. package/dist/run-registry.d.ts +61 -0
  48. package/dist/run-registry.d.ts.map +1 -0
  49. package/dist/run-registry.js +163 -0
  50. package/dist/run-registry.js.map +1 -0
  51. package/package.json +7 -7
  52. package/sbom.json +332 -32
  53. package/src/confirmation-file-gate.ts +85 -0
  54. package/src/contract-checklist.ts +11 -5
  55. package/src/core-boundary.ts +43 -0
  56. package/src/feature-adr-landing.ts +8 -4
  57. package/src/feature-tier.ts +56 -0
  58. package/src/index.ts +9 -1
  59. package/src/journal.ts +54 -0
  60. package/src/loop-blobs.generated.ts +10 -1
  61. package/src/patterns.ts +18 -0
  62. package/src/publish.ts +49 -13
  63. package/src/run-cleanup.ts +38 -0
  64. package/src/run-registry.ts +153 -0
@@ -0,0 +1,85 @@
1
+ export type ConfirmationFileGateResult =
2
+ | { readonly verdict: 'pass'; readonly checked: readonly string[] }
3
+ | { readonly verdict: 'fail'; readonly missing: readonly string[] }
4
+ | { readonly verdict: 'skipped'; readonly reason: 'no-adr' }
5
+ | { readonly verdict: 'refused'; readonly reason: string };
6
+
7
+ export type ConfirmationFileExists = (path: string) => boolean;
8
+
9
+ const CONFIRMATION_HEADING = '## Confirmation';
10
+ const TEST_FILE_TOKEN = /[A-Za-z0-9@._*-]+(?:\/[A-Za-z0-9@._*-]+)+\.(?:[cm]?[jt]sx?|py|sh)/g;
11
+
12
+ function confirmationSections(text: string): string[] {
13
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
14
+ const starts: number[] = [];
15
+ for (let index = 0; index < lines.length; index++) {
16
+ const line = lines[index] ?? '';
17
+ if (line === CONFIRMATION_HEADING || line.startsWith(`${CONFIRMATION_HEADING} `)) {
18
+ starts.push(index);
19
+ }
20
+ }
21
+ if (starts.length !== 1) return [];
22
+ const start = starts[0] as number;
23
+ let end = lines.length;
24
+ for (let index = start + 1; index < lines.length; index++) {
25
+ if (/^## (?!#)\S/.test(lines[index] ?? '')) {
26
+ end = index;
27
+ break;
28
+ }
29
+ }
30
+ return [lines.slice(start + 1, end).join('\n')];
31
+ }
32
+
33
+ function testPaths(section: string): string[] {
34
+ const paths: string[] = [];
35
+ for (const match of section.matchAll(TEST_FILE_TOKEN)) {
36
+ const path = match[0];
37
+ const testNamed = /(?:^|\/)(?:test|tests)\//.test(path)
38
+ || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path)
39
+ || /(?:^|\/)[A-Za-z0-9@._-]+-test\.(?:py|sh)$/.test(path);
40
+ if (testNamed && !paths.includes(path)) paths.push(path);
41
+ }
42
+ return paths;
43
+ }
44
+
45
+ /**
46
+ * Pure Step-8 policy. The caller owns filesystem access and injects a predicate that returns true
47
+ * only for a readable regular file. Throwing is a named refusal, never laundered into a skip.
48
+ */
49
+ export function checkConfirmationFiles(
50
+ adrTexts: readonly string[],
51
+ exists: ConfirmationFileExists,
52
+ ): ConfirmationFileGateResult {
53
+ if (adrTexts.length === 0) return { verdict: 'skipped', reason: 'no-adr' };
54
+
55
+ const paths: string[] = [];
56
+ for (let index = 0; index < adrTexts.length; index++) {
57
+ const sections = confirmationSections(adrTexts[index] ?? '');
58
+ if (sections.length !== 1) {
59
+ return { verdict: 'refused', reason: `ADR ${index + 1} has no unique readable Confirmation section` };
60
+ }
61
+ const parsed = testPaths(sections[0] as string);
62
+ if (parsed.length === 0) {
63
+ return { verdict: 'refused', reason: `ADR ${index + 1} Confirmation contains no parseable test path` };
64
+ }
65
+ for (const path of parsed) {
66
+ if (path.includes('*')) {
67
+ return { verdict: 'refused', reason: `cannot inspect ${path}: test path is not literal` };
68
+ }
69
+ if (!paths.includes(path)) paths.push(path);
70
+ }
71
+ }
72
+
73
+ const missing: string[] = [];
74
+ for (const path of paths) {
75
+ try {
76
+ if (!exists(path)) missing.push(path);
77
+ } catch (error) {
78
+ const detail = error instanceof Error ? error.message : String(error);
79
+ return { verdict: 'refused', reason: `cannot inspect ${path}: ${detail}` };
80
+ }
81
+ }
82
+ return missing.length > 0
83
+ ? { verdict: 'fail', missing }
84
+ : { verdict: 'pass', checked: paths };
85
+ }
@@ -21,6 +21,7 @@ export interface ContractSourceArtifact {
21
21
  export interface ContractChecklistSource {
22
22
  readonly requirements: ContractSourceArtifact;
23
23
  readonly adrs: readonly ContractSourceArtifact[];
24
+ readonly adrsOptional?: boolean;
24
25
  }
25
26
 
26
27
  export interface ContractItem {
@@ -127,6 +128,7 @@ export interface ContractVerification {
127
128
  }
128
129
 
129
130
  const REQUIREMENTS_HEADING = '## Acceptance criteria';
131
+ const REQUIREMENTS_HEADINGS = [REQUIREMENTS_HEADING, '## Критерии приёмки'] as const;
130
132
  const CONFIRMATION_HEADING = '## Confirmation';
131
133
  const VERDICT_HEADING = '## Contract checklist';
132
134
  const REQUIREMENTS_FORMAT_LINE = 'Format: Every acceptance criterion below is exactly one physical line matching `^AC-([1-9][0-9]*): (\\S.*)$`; identifiers are contiguous from `AC-1`, and only the literal H2 `## Acceptance criteria` establishes this source section.';
@@ -139,10 +141,11 @@ function linesOf(text: string): string[] {
139
141
  return text.replace(/\r\n?/g, '\n').split('\n');
140
142
  }
141
143
 
142
- function h2Indexes(lines: readonly string[], heading: string): number[] {
144
+ function h2Indexes(lines: readonly string[], heading: string | ((line: string) => boolean)): number[] {
143
145
  const indexes: number[] = [];
144
146
  for (let index = 0; index < lines.length; index++) {
145
- if (lines[index] === heading) indexes.push(index);
147
+ const line = lines[index] ?? '';
148
+ if (typeof heading === 'string' ? line === heading : heading(line)) indexes.push(index);
146
149
  }
147
150
  return indexes;
148
151
  }
@@ -176,7 +179,9 @@ function acceptanceItems(
176
179
  diagnostics: ContractDiagnostic[],
177
180
  ): PendingContractItem[] {
178
181
  const lines = linesOf(artifact.text);
179
- const headings = h2Indexes(lines, REQUIREMENTS_HEADING);
182
+ const headings = h2Indexes(lines, (line) => REQUIREMENTS_HEADINGS.includes(
183
+ line as (typeof REQUIREMENTS_HEADINGS)[number],
184
+ ));
180
185
  if (headings.length !== 1) {
181
186
  diagnostics.push(diagnostic(
182
187
  headings.length === 0 ? 'requirements-section-missing' : 'requirements-section-duplicate',
@@ -275,7 +280,8 @@ function confirmationItem(
275
280
  return null;
276
281
  }
277
282
  const lines = linesOf(artifact.text);
278
- const headings = h2Indexes(lines, CONFIRMATION_HEADING);
283
+ const headings = h2Indexes(lines, (line) => line === CONFIRMATION_HEADING
284
+ || line.startsWith(`${CONFIRMATION_HEADING} `));
279
285
  if (headings.length !== 1) {
280
286
  diagnostics.push(diagnostic(
281
287
  headings.length === 0 ? 'confirmation-section-missing' : 'confirmation-section-duplicate',
@@ -349,7 +355,7 @@ export function extractContractChecklist(input: ContractChecklistSource): Contra
349
355
  const requirements = acceptanceItems(input.requirements, diagnostics);
350
356
  validateAcceptanceIds(requirements, input.requirements, diagnostics);
351
357
 
352
- if (input.adrs.length === 0) {
358
+ if (input.adrs.length === 0 && input.adrsOptional !== true) {
353
359
  diagnostics.push(diagnostic(
354
360
  'adr-input-empty',
355
361
  'at least one direct canonical ADR Markdown file is required',
@@ -0,0 +1,43 @@
1
+ import ts from 'typescript';
2
+
3
+ /** Internal test scanner: parsing keeps comments and literal text out of code visits. */
4
+ export function findProcessAccessInCode(source: string): Array<{ line: number; kind: string }> {
5
+ const file = ts.createSourceFile('boundary.ts', source, ts.ScriptTarget.Latest, true);
6
+ const hits: Array<{ line: number; kind: string }> = [];
7
+ function visit(node: ts.Node): void {
8
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)
9
+ && node.expression.text === 'process' && ['argv', 'exit'].includes(node.name.text)) {
10
+ hits.push({ line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1, kind: node.name.text });
11
+ }
12
+ ts.forEachChild(node, visit);
13
+ }
14
+ visit(file);
15
+ return hits;
16
+ }
17
+
18
+ /** Count import declarations, import-equals, require and dynamic imports, not mentions. */
19
+ export function countIoImports(
20
+ source: string,
21
+ modules: readonly string[] = ['node:fs', 'fs', 'node:child_process', 'child_process', 'node:https', 'https'],
22
+ ): { files: number; imports: number } {
23
+ const file = ts.createSourceFile('boundary.ts', source, ts.ScriptTarget.Latest, true);
24
+ let imports = 0;
25
+ function visit(node: ts.Node): void {
26
+ let specifier: ts.Node | undefined;
27
+ if (ts.isImportDeclaration(node)) specifier = node.moduleSpecifier;
28
+ else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
29
+ specifier = node.moduleReference.expression;
30
+ } else if (ts.isCallExpression(node)
31
+ && (node.expression.kind === ts.SyntaxKind.ImportKeyword
32
+ || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
33
+ specifier = node.arguments[0];
34
+ }
35
+ if (specifier && ts.isStringLiteralLike(specifier)
36
+ && modules.some((name) => specifier.text === name || specifier.text.startsWith(`${name}/`))) {
37
+ imports += 1;
38
+ }
39
+ ts.forEachChild(node, visit);
40
+ }
41
+ visit(file);
42
+ return { files: imports > 0 ? 1 : 0, imports };
43
+ }
@@ -1,3 +1,5 @@
1
+ import { probePid } from './run-registry.js';
2
+
1
3
  /** Pure decisions for the Step-7.5 Codex companion liveness barrier. */
2
4
 
3
5
  export const DEFAULT_CODE_LANDING_CEILING_MS = 7_200_000;
@@ -24,7 +26,8 @@ export type CodeLandingLivenessReason =
24
26
 
25
27
  export interface CodeLandingLivenessInput {
26
28
  readonly companionStatus: unknown;
27
- readonly recordedPidAlive: boolean | null;
29
+ readonly recordedPidAlive?: boolean | null;
30
+ readonly recordedPid?: number;
28
31
  readonly targetsChanged: boolean | null;
29
32
  readonly elapsedMs: number;
30
33
  readonly ceilingMs: number;
@@ -43,17 +46,18 @@ export interface CodeLandingLivenessDecision {
43
46
  readonly reason: CodeLandingLivenessReason;
44
47
  }
45
48
 
46
- export function decideCodeLandingLiveness(input: CodeLandingLivenessInput): CodeLandingLivenessDecision {
49
+ export function decideCodeLandingLiveness(input: CodeLandingLivenessInput, pidProbe = probePid): CodeLandingLivenessDecision {
47
50
  const status = typeof input.companionStatus === 'string' ? input.companionStatus.trim().toLowerCase() : ''
48
51
  const elapsedMs = Number.isFinite(input.elapsedMs) ? Math.max(0, input.elapsedMs) : 0
49
52
  const ceilingMs = Number.isFinite(input.ceilingMs) && input.ceilingMs > 0 ? input.ceilingMs : DEFAULT_CODE_LANDING_CEILING_MS
53
+ const recordedPidAlive = input.recordedPidAlive === undefined ? (input.recordedPid === undefined ? null : pidProbe(input.recordedPid)) : input.recordedPidAlive
50
54
  const live = status === 'running' || status === 'queued'
51
55
  const terminal = status === 'completed' || status === 'failed' || status === 'cancelled'
52
56
 
53
- if (live && input.recordedPidAlive === false) {
57
+ if (live && recordedPidAlive === false) {
54
58
  return { verdict: 'dead-worker', reason: 'recorded-pid-absent' }
55
59
  }
56
- if (live && input.recordedPidAlive === true) {
60
+ if (live && recordedPidAlive === true) {
57
61
  if (elapsedMs >= ceilingMs) return { verdict: 'inconclusive', reason: 'ceiling-exceeded' }
58
62
  return { verdict: 'coder-running', reason: 'recorded-pid-alive' }
59
63
  }
@@ -0,0 +1,56 @@
1
+ import type { FeatureTier } from './guard-volume.js';
2
+
3
+ function tiersAfterMarkers(line: string): FeatureTier[] {
4
+ if (!/^\s*(?:##\s*)?(?:\*\*)?(?:Tier|Тир)(?=$|[\s:*])/iu.test(line)) return [];
5
+ const tiers: FeatureTier[] = [];
6
+ for (const marker of line.matchAll(/Tier|Тир/giu)) {
7
+ const markerIndex = marker.index ?? 0;
8
+ const previous = line[markerIndex - 1];
9
+ if (previous !== undefined && /[\p{L}\p{N}_]/u.test(previous)) continue;
10
+ const tail = line.slice(markerIndex + marker[0].length);
11
+ // A template line lists several tiers after one marker («Tier: S / M / L / XL», «Tier: S|M») — every
12
+ // listed token is a candidate, so the caller sees the ambiguity instead of the first token (QE 08c #1).
13
+ const match = tail.match(/^(?:\s*:\s*|\s+)(?:\*\*)?\s*(XL|[SML])((?:\s*[/|,]\s*(?:XL|[SML]))*)(?=$|[\s.*)—–-])/iu);
14
+ if (match?.[1] !== undefined) {
15
+ tiers.push(match[1].toUpperCase() as FeatureTier);
16
+ for (const extra of (match[2] ?? '').matchAll(/XL|[SML]/giu)) tiers.push(extra[0].toUpperCase() as FeatureTier);
17
+ }
18
+ }
19
+ return tiers;
20
+ }
21
+
22
+ function tierAtContinuationStart(line: string): FeatureTier | null {
23
+ const match = line.match(/^\s*(?:\*\*)?\s*(XL|[SML])(?=$|[\s:.*)—–-])/iu);
24
+ return match?.[1] === undefined ? null : match[1].toUpperCase() as FeatureTier;
25
+ }
26
+
27
+ export function parseFeatureTier(text: string): FeatureTier | null {
28
+ const lines = text.split(/\r?\n/);
29
+ const candidates = new Set<FeatureTier>();
30
+
31
+ for (let index = 0; index < lines.length; index += 1) {
32
+ const line = lines[index]!;
33
+ for (const inline of tiersAfterMarkers(line)) candidates.add(inline);
34
+
35
+ if (!/^\s*##\s*(?:Tier|Тир)\s*$/iu.test(line)) continue;
36
+ const continuation = lines.slice(index + 1).find((next) => next.trim() !== '');
37
+ if (continuation === undefined) continue;
38
+ const continuedTiers = tiersAfterMarkers(continuation);
39
+ if (continuedTiers.length > 0) {
40
+ for (const nextTier of continuedTiers) candidates.add(nextTier);
41
+ } else {
42
+ const nextTier = tierAtContinuationStart(continuation);
43
+ if (nextTier !== null) candidates.add(nextTier);
44
+ }
45
+ }
46
+
47
+ return candidates.size === 1 ? [...candidates][0]! : null;
48
+ }
49
+
50
+ export function readFeatureTier(
51
+ read: (rel: string) => string | null,
52
+ slug: string,
53
+ ): FeatureTier | null {
54
+ const text = read(`features/${slug}/00_complexity_assessment.md`);
55
+ return text === null ? null : parseFeatureTier(text);
56
+ }
package/src/index.ts CHANGED
@@ -134,7 +134,7 @@ export { tokenize, stemToken, stems } from './stem.js';
134
134
  export * from './package-skill-layouts.js';
135
135
  export { recommend } from './recommend.js';
136
136
  export { pretrain } from './pretrain.js';
137
- export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, recordLessonForms, loadStorePatternsSync, loadStoreRecords, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
137
+ export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, recordLessonForms, loadStorePatternsSync, loadStoreRecords, findExactLesson, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
138
138
  export { normalizeLessonForms, validateClassTemplate, lessonPairIdOf, mergeLessonFormHits, mergeLessonMatchedForms } from './lesson-generalization.js';
139
139
  export { withStoreLock, withStoreLockSync, storeLockPath, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
140
140
  export type { StoreLockOptions } from './store-lock.js';
@@ -464,6 +464,7 @@ export type {
464
464
  ContractVerificationCounts,
465
465
  ContractVerification,
466
466
  } from './contract-checklist.js';
467
+ export * from './feature-tier.js';
467
468
  export {
468
469
  DOMAIN_LIFT_EXACT,
469
470
  DOMAIN_LIFT_RELATED,
@@ -1153,3 +1154,10 @@ export type { ClaudeHookAssetOptions } from './claude-hooks-assets.js';
1153
1154
  export { harnessCoreDistDir, harnessCoreModuleDir } from './harness-core-location.js';
1154
1155
 
1155
1156
  export { maskMarkdown } from './markdown-masker.js';
1157
+ export * from './confirmation-file-gate.js';
1158
+
1159
+ export * from './run-registry.js';
1160
+
1161
+ export { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed } from './journal.js';
1162
+ export type { JournalKind, JournalEvent, JournalLine, JournalIo } from './journal.js';
1163
+ export * from './run-cleanup.js';
package/src/journal.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Existing Markdown day files; no migration or alternate event store. */
2
+ export const JOURNAL_KINDS = ['decision', 'verdict', 'run', 'error', 'block'] as const;
3
+ export type JournalKind = typeof JOURNAL_KINDS[number];
4
+ const labels: Record<JournalKind, string> = {
5
+ decision: 'решение', verdict: 'вердикт', run: 'запуск', error: 'ошибка', block: 'блокировка',
6
+ };
7
+ export interface JournalEvent { time: string; kind: JournalKind; text: string; ref: string }
8
+ export type JournalLine = ({ status: 'parsed'; raw: string } & JournalEvent) | { status: 'unparsed'; raw: string };
9
+ export interface JournalIo { read(path: string): string; append(path: string, body: string): void }
10
+
11
+ export function formatLine(event: JournalEvent): string {
12
+ if (!JOURNAL_KINDS.includes(event.kind)) throw new Error(`Категория: ${JOURNAL_KINDS.join(', ')}`);
13
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(event.time)) throw new Error('Время должно быть HH:MM UTC');
14
+ if (!event.text.trim() || /[\r\n\0]/.test(event.text + event.ref) || event.ref.includes('·')) {
15
+ throw new Error('Текст и след должны занимать одну строку; след не содержит ·');
16
+ }
17
+ return `- ${event.time} · ${labels[event.kind]}: ${event.text.trim()} · ${event.ref.trim()}`;
18
+ }
19
+
20
+ export function parseLine(raw: string): JournalLine {
21
+ const unparsed = { status: 'unparsed', raw } as const;
22
+ const first = raw.indexOf('·'); const last = raw.lastIndexOf('·');
23
+ if (first < 0 || last === first) return unparsed;
24
+ const time = /^- ((?:[01]\d|2[0-3]):[0-5]\d)\s*$/.exec(raw.slice(0, first))?.[1];
25
+ const body = raw.slice(first + 1, last).trim(); const colon = body.indexOf(':');
26
+ if (!time || colon < 0) return unparsed;
27
+ const category = body.slice(0, colon).trim();
28
+ const kind = JOURNAL_KINDS.find(k => category === k || category === labels[k]
29
+ || (k === 'decision' && category.startsWith('решение владельца'))
30
+ || (k === 'error' && category === 'ошибка ведущего')
31
+ || (k === 'run' && ['падение', 'запуск / падение'].includes(category)));
32
+ if (!kind || !body.slice(colon + 1).trim()) return unparsed;
33
+ return { status: 'parsed', raw, time, kind, text: body.slice(colon + 1).trim(), ref: raw.slice(last + 1).trim() };
34
+ }
35
+
36
+ /** Inclusive UTC window ending on at; week means the trailing seven calendar days. */
37
+ export function selectWindow(days: readonly string[], at: string, week: boolean): string[] {
38
+ const end = new Date(`${at}T00:00:00Z`);
39
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(at) || !Number.isFinite(end.getTime()) || end.toISOString().slice(0, 10) !== at) {
40
+ throw new Error('Дата должна быть существующим днём YYYY-MM-DD');
41
+ }
42
+ const start = new Date(end.getTime() - (week ? 6 : 0) * 86400000).toISOString().slice(0, 10);
43
+ return days.filter(day => day >= start && day <= at).sort();
44
+ }
45
+
46
+ /** The receipt is based on bytes read AFTER append, never on append returning normally. */
47
+ export function appendWitnessed(io: JournalIo, path: string, line: string): void {
48
+ io.append(path, `${line}\n`);
49
+ try {
50
+ if (!io.read(path).endsWith(`${line}\n`)) throw new Error('хвост не совпадает');
51
+ } catch (error) {
52
+ throw new Error(`Запись не засвидетельствована: ${String(error)}`);
53
+ }
54
+ }
@@ -25,7 +25,7 @@ export interface LoopBlob {
25
25
  code: string;
26
26
  }
27
27
 
28
- export const LOOP_BLOB_NAMES = ["checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","stage-line","trace","loop-semantics","ha-consult-router"] as const;
28
+ export const LOOP_BLOB_NAMES = ["run-registry","checkpoints","training-pairs","model-resolver","usage-probes","codex-dispatch","challenge-panel","stage-line","trace","loop-semantics","ha-consult-router"] as const;
29
29
 
30
30
  /** Workflow files the regen-diff gate covers TODAY (AM-5 honest scope): exactly the files
31
31
  * carrying BEGIN BLOB markers. Stage B (whole-file regeneration of feature-adr.js) is a
@@ -40,6 +40,15 @@ export const BLOB_COVERAGE_MANIFEST: { coveredWorkflows: string[] } = {
40
40
  };
41
41
 
42
42
  export const BLOBS: Record<string, LoopBlob> = {
43
+ "run-registry": {
44
+ name: "run-registry",
45
+ version: "1.0.0",
46
+ contentHash: "a4187c278260e8f8285fc494da2a6d82923efe4465fabba20ce1d3141df7fef3",
47
+ sourcePath: "packages/@dzhechkov/harness-core/src/run-registry.ts",
48
+ requires: [],
49
+ exports: ["runRecordCommand"],
50
+ code: "function runRecordCommand(dz, root, event, runId, slug, pid, parentRunId, outcome) {\n const quote = (s) => \"'\" + s.replace(/'/g, \"'\\\\''\") + \"'\";\n let cmd = dz + ' runs-record --project ' + quote(root) + ' --event ' + quote(event) + (runId ? ' --run-id ' + quote(runId) : '');\n if (event === 'started') {\n cmd += ' --kind feature-adr --slug ' + quote(slug);\n cmd += ' --pid ' + quote(pid === null ? 'host' : String(pid));\n if (parentRunId)\n cmd += ' --parent-run-id ' + quote(parentRunId);\n }\n if (event === 'finished')\n cmd += ' --outcome ' + quote(outcome);\n return cmd + ' --json';\n}",
51
+ },
43
52
  "checkpoints": {
44
53
  name: "checkpoints",
45
54
  version: "1.2.0",
package/src/patterns.ts CHANGED
@@ -642,6 +642,24 @@ export function loadStoreRecords(projectRoot: string): MemoryRecord[] {
642
642
  }
643
643
  }
644
644
 
645
+ /** Find the earliest lesson whose normalized text and optional domain match exactly. */
646
+ export function findExactLesson(
647
+ records: readonly MemoryRecord[],
648
+ text: string,
649
+ domain?: string,
650
+ ): { id: string; quarantined: boolean } | null {
651
+ const normalizedText = text.trim().replace(/\s+/g, ' ');
652
+ let earliest: MemoryRecord | undefined;
653
+ for (const record of records) {
654
+ if (record.text.trim().replace(/\s+/g, ' ') !== normalizedText) continue;
655
+ if (domain !== undefined && record.metadata?.['domain'] !== domain) continue;
656
+ if (earliest === undefined || record.timestamp < earliest.timestamp) earliest = record;
657
+ }
658
+ return earliest === undefined
659
+ ? null
660
+ : { id: earliest.id, quarantined: earliest.metadata?.['qStatus'] === 'quarantined' };
661
+ }
662
+
645
663
  /** Write one record through the backend cascade. CALLER HOLDS THE STORE LOCK — every
646
664
  * caller (reinforce / updateReinforcementState / promote) wraps its whole read-modify-write
647
665
  * in `withStoreLock`; taking the (non-reentrant) lock here as well would deadlock. */
package/src/publish.ts CHANGED
@@ -11,6 +11,9 @@ import { join as pathJoin, relative as pathRelative, resolve as pathResolve } fr
11
11
  import { decidePublishSigning, decidePostSigningVerification } from './publish-signing.js';
12
12
  import { join } from 'node:path';
13
13
  import { execSync } from 'node:child_process';
14
+ // Structural twin of node's ExecSyncOptionsWithStringEncoding — a type-only import of node:child_process
15
+ // still counts as an IO import for the core-boundary ratchet (measured 66 → 67), so the shape is spelled here.
16
+ type ExecSyncOptionsWithStringEncoding = NonNullable<Parameters<typeof execSync>[1]> & { encoding: 'utf-8' };
14
17
 
15
18
  import { claimCheck } from './claim-check.js';
16
19
 
@@ -21,6 +24,8 @@ export interface PublishResult {
21
24
  readonly newVersion: string;
22
25
  readonly status: 'published' | 'skipped' | 'error';
23
26
  readonly error?: string | undefined;
27
+ /** Live publish only: how many registry probes were needed to confirm the exact new version. */
28
+ readonly registryProbes?: number | undefined;
24
29
  /**
25
30
  * Pre-publish claim-check summary for this package's README, present only when the
26
31
  * opt-in `claimCheck` gate ran (`'warn'`/`'block'`). Additive: absent by default so an
@@ -85,9 +90,11 @@ export function compareVersions(a: string, b: string): number {
85
90
  * has never been published (or npm is unreachable). Used to bump from
86
91
  * max(local, published) so a locally-reverted version can't collide (audit #10).
87
92
  */
88
- function publishedVersion(name: string): string | undefined {
93
+ type PublishExec = (command: string, options: ExecSyncOptionsWithStringEncoding) => string;
94
+
95
+ function publishedVersion(name: string, exec: PublishExec = execSync): string | undefined {
89
96
  try {
90
- const out = execSync(`npm view ${name} version`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
97
+ const out = exec(`npm view ${name} version --prefer-online`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
91
98
  return /^\d+\.\d+\.\d+/.test(out) ? out : undefined;
92
99
  } catch {
93
100
  return undefined; // 404 (never published) or offline → fall back to local
@@ -95,8 +102,8 @@ function publishedVersion(name: string): string | undefined {
95
102
  }
96
103
 
97
104
  /** The higher of the local version and the npm-published version (audit #10). */
98
- function maxPublished(name: string, localVersion: string): string {
99
- const pub = publishedVersion(name);
105
+ function maxPublished(name: string, localVersion: string, exec: PublishExec = execSync): string {
106
+ const pub = publishedVersion(name, exec);
100
107
  return pub !== undefined && compareVersions(pub, localVersion) > 0 ? pub : localVersion;
101
108
  }
102
109
 
@@ -153,9 +160,9 @@ export function findUnpublishedWorkspaceFloors(opts: {
153
160
  }
154
161
 
155
162
  /** Registry probe: is exactly `name@version` published? Empty output / 404 / offline ⇒ no. */
156
- function versionPublished(name: string, version: string): boolean {
163
+ function versionPublished(name: string, version: string, exec: PublishExec = execSync): boolean {
157
164
  try {
158
- const out = execSync(`npm view ${name}@${version} version`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
165
+ const out = exec(`npm view ${name}@${version} version --prefer-online`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8', timeout: 20000 }).trim();
159
166
  return out === version;
160
167
  } catch {
161
168
  return false;
@@ -541,11 +548,22 @@ export function publishPackages(
541
548
  * preflight under dry-run, which is how the wiring test drives it without network.
542
549
  */
543
550
  probeFloor?: ((name: string, version: string) => boolean) | undefined;
551
+ /** Subprocess injection for tests; the default is Node's synchronous executor. */
552
+ exec?: PublishExec | undefined;
553
+ /** Post-publish receipt probe. The default asks npm for exactly `name@version`. */
554
+ probe?: ((name: string, version: string) => boolean) | undefined;
555
+ /** Pause injection between receipt probes. The default blocks for the requested milliseconds. */
556
+ sleep?: ((milliseconds: number) => void) | undefined;
544
557
  } = {},
545
558
  ): PublishReport {
546
559
  // Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
547
560
  // package 7 of 45 (recalled lesson: a failed publish that retries with a bump orphans version numbers).
548
561
  const publishCmd = publishArgv(opts.provenance ?? 'auto', process.env);
562
+ const exec = opts.exec ?? execSync;
563
+ const probe = opts.probe ?? ((name: string, version: string) => versionPublished(name, version, exec));
564
+ const sleep = opts.sleep ?? ((milliseconds: number): void => {
565
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
566
+ });
549
567
 
550
568
  const packages = discoverPackages(monorepoRoot);
551
569
  const results: PublishResult[] = [];
@@ -570,7 +588,7 @@ export function publishPackages(
570
588
  // Bump from max(local, npm-published) so a locally-reverted version can't
571
589
  // collide with an already-published one (audit #10). Dry-run stays offline
572
590
  // (local only) to keep previews fast and network-free.
573
- const base = opts.dryRun ? oldVersion : maxPublished(pkg.name, oldVersion);
591
+ const base = opts.dryRun ? oldVersion : maxPublished(pkg.name, oldVersion, exec);
574
592
  const newVersion = bumpPatch(base);
575
593
 
576
594
  // Preflight: refuse to publish a pack whose `files` whitelist would silently
@@ -678,7 +696,9 @@ export function publishPackages(
678
696
  // Build if has build script
679
697
  const parsed = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { scripts?: Record<string, string> };
680
698
  if (parsed.scripts?.['build']) {
681
- execSync('pnpm build', { cwd: pkg.dir, stdio: 'pipe', encoding: 'utf-8' });
699
+ const buildOptions = { cwd: pkg.dir, stdio: 'pipe' as const, encoding: 'utf-8' as const };
700
+ if (opts.exec) opts.exec('pnpm build', buildOptions);
701
+ else execSync('pnpm build', buildOptions);
682
702
  }
683
703
 
684
704
  // Re-sign AFTER the bump, the README sync and the build, and BEFORE the tarball is built.
@@ -755,14 +775,30 @@ export function publishPackages(
755
775
  }
756
776
 
757
777
  // Publish
758
- execSync(publishCmd, {
778
+ const publishOptions = {
759
779
  cwd: pkg.dir,
760
- stdio: 'pipe',
761
- encoding: 'utf-8',
780
+ stdio: 'pipe' as const,
781
+ encoding: 'utf-8' as const,
762
782
  env: { ...process.env },
763
- });
783
+ };
784
+ if (opts.exec) opts.exec(publishCmd, publishOptions);
785
+ else execSync(publishCmd, publishOptions);
786
+
787
+ let registryProbes = 0;
788
+ let confirmed = false;
789
+ while (registryProbes < 30) {
790
+ registryProbes++;
791
+ if (probe(pkg.name, newVersion)) {
792
+ confirmed = true;
793
+ break;
794
+ }
795
+ if (registryProbes < 30) sleep(10_000);
796
+ }
797
+ if (!confirmed) {
798
+ throw new Error(`registry did not confirm ${pkg.name}@${newVersion} after ${registryProbes} probes`);
799
+ }
764
800
 
765
- results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary });
801
+ results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, claimCheck: claimCheckSummary });
766
802
  landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
767
803
  } catch (err) {
768
804
  // The version was written BEFORE build+publish; on any failure restore the
@@ -0,0 +1,38 @@
1
+ /** Pure cleanup decisions: the CLI gathers facts and executes the explicit apply selection. */
2
+ export type WorktreeFact = {
3
+ path: string; branch: string | null; detached: boolean; isMain: boolean;
4
+ merged: boolean | null; dirtyFiles: string[]; lastCommitTs: number | null;
5
+ };
6
+ export type WorktreeCleanupPlan = {
7
+ now: number;
8
+ remove: WorktreeFact[];
9
+ keep: { fact: WorktreeFact; reason: 'main' | 'unmerged' | `dirty (${number})` | 'younger than retention' | 'merged-unknown' }[];
10
+ };
11
+
12
+ export function planWorktreeCleanup(facts: WorktreeFact[], opts: { now: number; retentionMs: number }): WorktreeCleanupPlan {
13
+ const plan: WorktreeCleanupPlan = { now: opts.now, remove: [], keep: [] };
14
+ for (const fact of facts) {
15
+ let reason: WorktreeCleanupPlan['keep'][number]['reason'] | undefined;
16
+ if (fact.isMain) reason = 'main';
17
+ else if (fact.dirtyFiles.length > 0) reason = `dirty (${fact.dirtyFiles.length})`;
18
+ else if (fact.merged === null) reason = 'merged-unknown';
19
+ else if (!fact.merged) reason = 'unmerged';
20
+ else if (fact.lastCommitTs === null || !(opts.now - fact.lastCommitTs > opts.retentionMs)) reason = 'younger than retention';
21
+ if (reason) plan.keep.push({ fact, reason });
22
+ else plan.remove.push(fact);
23
+ }
24
+ return plan;
25
+ }
26
+
27
+ /** The apply boundary is independently mutable while all filesystem work stays in the CLI. */
28
+ export function worktreeRemovalsToApply(plan: WorktreeCleanupPlan, apply: boolean): WorktreeFact[] {
29
+ return apply ? plan.remove : [];
30
+ }
31
+
32
+ export function renderCleanupPlan(plan: WorktreeCleanupPlan): string[] {
33
+ return [
34
+ ...plan.remove.map(fact => `remove ${fact.path} (${fact.branch ?? 'detached'}, ${Math.floor((plan.now - fact.lastCommitTs!) / 86400000)}d)`),
35
+ ...plan.keep.map(({ fact, reason }) => `keep ${fact.path} — ${reason}` +
36
+ (reason.startsWith('dirty (') ? ': ' + fact.dirtyFiles.slice(0, 5).join(', ') + (fact.dirtyFiles.length > 5 ? ' …' : '') : '')),
37
+ ];
38
+ }