@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.
- package/.dz-manifest.json +153 -33
- package/README.md +150 -4
- package/dist/confirmation-file-gate.d.ts +20 -0
- package/dist/confirmation-file-gate.d.ts.map +1 -0
- package/dist/confirmation-file-gate.js +76 -0
- package/dist/confirmation-file-gate.js.map +1 -0
- package/dist/contract-checklist.d.ts +1 -0
- package/dist/contract-checklist.d.ts.map +1 -1
- package/dist/contract-checklist.js +7 -4
- package/dist/contract-checklist.js.map +1 -1
- package/dist/core-boundary.d.ts +11 -0
- package/dist/core-boundary.d.ts.map +1 -0
- package/dist/core-boundary.js +41 -0
- package/dist/core-boundary.js.map +1 -0
- package/dist/feature-adr-landing.d.ts +4 -2
- package/dist/feature-adr-landing.d.ts.map +1 -1
- package/dist/feature-adr-landing.js +5 -3
- package/dist/feature-adr-landing.js.map +1 -1
- package/dist/feature-tier.d.ts +4 -0
- package/dist/feature-tier.d.ts.map +1 -0
- package/dist/feature-tier.js +55 -0
- package/dist/feature-tier.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +27 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +56 -0
- package/dist/journal.js.map +1 -0
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +10 -1
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/patterns.d.ts +5 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +16 -0
- package/dist/patterns.js.map +1 -1
- package/dist/publish.d.ts +19 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +38 -16
- package/dist/publish.js.map +1 -1
- package/dist/run-cleanup.d.ts +26 -0
- package/dist/run-cleanup.d.ts.map +1 -0
- package/dist/run-cleanup.js +33 -0
- package/dist/run-cleanup.js.map +1 -0
- package/dist/run-registry.d.ts +61 -0
- package/dist/run-registry.d.ts.map +1 -0
- package/dist/run-registry.js +163 -0
- package/dist/run-registry.js.map +1 -0
- package/package.json +7 -7
- package/sbom.json +332 -32
- package/src/confirmation-file-gate.ts +85 -0
- package/src/contract-checklist.ts +11 -5
- package/src/core-boundary.ts +43 -0
- package/src/feature-adr-landing.ts +8 -4
- package/src/feature-tier.ts +56 -0
- package/src/index.ts +9 -1
- package/src/journal.ts +54 -0
- package/src/loop-blobs.generated.ts +10 -1
- package/src/patterns.ts +18 -0
- package/src/publish.ts +49 -13
- package/src/run-cleanup.ts +38 -0
- 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
|
-
|
|
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,
|
|
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
|
|
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 &&
|
|
57
|
+
if (live && recordedPidAlive === false) {
|
|
54
58
|
return { verdict: 'dead-worker', reason: 'recorded-pid-absent' }
|
|
55
59
|
}
|
|
56
|
-
if (live &&
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|