@dzhechkov/harness-core 0.8.41 → 0.8.42
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 +48 -28
- package/README.md +22 -2
- package/dist/guard.d.ts +4 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +60 -3
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/publish.d.ts +14 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +61 -44
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +15 -1
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +40 -13
- package/dist/release-line.js.map +1 -1
- package/dist/session-retro.d.ts +8 -5
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +36 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/tree-sync.d.ts +5 -0
- package/dist/tree-sync.d.ts.map +1 -0
- package/dist/tree-sync.js +13 -0
- package/dist/tree-sync.js.map +1 -0
- package/package.json +8 -8
- package/sbom.json +77 -27
- package/src/guard.ts +62 -3
- package/src/index.ts +2 -1
- package/src/publish.ts +67 -43
- package/src/release-line.ts +60 -16
- package/src/session-retro.ts +51 -15
- package/src/tree-sync.ts +17 -0
package/src/guard.ts
CHANGED
|
@@ -119,6 +119,7 @@ export interface GuardFacts {
|
|
|
119
119
|
readonly readmes: readonly { readonly path: string; readonly text: string | null }[];
|
|
120
120
|
readonly coreVersion: string | null;
|
|
121
121
|
readonly cliVersion: string | null;
|
|
122
|
+
readonly workspaceVersions?: Readonly<Record<string, string | null>>;
|
|
122
123
|
};
|
|
123
124
|
/** Publish-only raw volume facts. Absence preserves the legacy result shape. */
|
|
124
125
|
readonly volume?: VolumeShadowInput;
|
|
@@ -161,7 +162,10 @@ export interface GuardFacts {
|
|
|
161
162
|
readonly featureBacklog?: {
|
|
162
163
|
readonly baseline: string;
|
|
163
164
|
readonly features: readonly { readonly slug: string; readonly createdIso: string; readonly waiver?: string }[];
|
|
165
|
+
/** Тексты записей бэклога и непустые причины переходов. */
|
|
164
166
|
readonly backlogTexts: readonly string[];
|
|
167
|
+
/** Летопись ЕСТЬ, но не прочиталась: «<путь>: <причина>». */
|
|
168
|
+
readonly transitionLogError?: string;
|
|
165
169
|
};
|
|
166
170
|
/** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
|
|
167
171
|
readonly drift?: readonly string[];
|
|
@@ -428,6 +432,50 @@ function inspectReleaseLines(
|
|
|
428
432
|
});
|
|
429
433
|
continue;
|
|
430
434
|
}
|
|
435
|
+
if (evidence.workspaceVersions !== undefined) {
|
|
436
|
+
const stale: string[] = [];
|
|
437
|
+
const actual: string[] = [];
|
|
438
|
+
const versions: string[] = [];
|
|
439
|
+
const unknown = (detail: string): void => {
|
|
440
|
+
observations.push({
|
|
441
|
+
schemaVersion: 'volume-shadow/v1', rule: 'release-line-in-sync' as never,
|
|
442
|
+
metric: 'release-line-version-sync', scope: readme.path, status: 'unknown', value: null,
|
|
443
|
+
unit: 'artifact_set', signal: false, operands: {}, method: 'release-line-regex/v1', detail,
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
for (const token of found.tokens) {
|
|
447
|
+
if (!Object.hasOwn(evidence.workspaceVersions, token.name)) {
|
|
448
|
+
unknown(`пакет \`${token.name}\` не собирается в этом монорепо — сверить не с чем`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const version = evidence.workspaceVersions[token.name];
|
|
452
|
+
if (typeof version !== 'string') {
|
|
453
|
+
unknown(`${token.name} package.json version не прочитана — сверить не с чем`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
actual.push(`${token.name} v${token.version}`);
|
|
457
|
+
versions.push(`${token.name} v${version}`);
|
|
458
|
+
if (token.version !== version) {
|
|
459
|
+
stale.push(`строка релиза говорит ${token.name} v${token.version}, package.json — v${version}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (found.wrapped) unknown('цепочка перенесена на следующую строку — продолжение вне охвата правила');
|
|
463
|
+
if (actual.length > 0) {
|
|
464
|
+
const mismatch = stale.length > 0;
|
|
465
|
+
const detail = mismatch
|
|
466
|
+
? `${readme.path}: ${stale.join('; ')}`
|
|
467
|
+
: `${readme.path}: строка релиза совпадает с package.json (${versions.join(', ')})`;
|
|
468
|
+
observations.push({
|
|
469
|
+
schemaVersion: 'volume-shadow/v1', rule: 'release-line-in-sync' as never,
|
|
470
|
+
metric: 'release-line-version-sync', scope: readme.path,
|
|
471
|
+
status: mismatch ? 'outside-reference' : 'within-reference', value: actual,
|
|
472
|
+
unit: 'artifact_set', signal: mismatch, operands: { actual, expected: versions },
|
|
473
|
+
method: 'release-line-regex/v1', detail,
|
|
474
|
+
});
|
|
475
|
+
if (mismatch) violations.push({ rule: 'release-line-in-sync', severity, detail });
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
431
479
|
if (expected === null) {
|
|
432
480
|
const missing = [
|
|
433
481
|
...(evidence.coreVersion === null ? ['harness-core package.json version'] : []),
|
|
@@ -499,7 +547,7 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
|
|
|
499
547
|
{ id: 'backlog-covers-features', severity: 'soft', ops: ['publish', 'consolidate'], description: 'каталог фичи, заведённый после базовой даты, назван записью бэклога — либо несёт именованную оговорку с причиной' },
|
|
500
548
|
{ id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
|
|
501
549
|
{ id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
|
|
502
|
-
{ id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the
|
|
550
|
+
{ id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the package.json version of every workspace package the line names' },
|
|
503
551
|
{ id: 'signature-fresh', severity: 'soft', ops: ['publish', 'code'], description: 'a pack whose files changed in this diff still verifies against its signed .dz-manifest.json — a stale signature is named before publish, not at the gate' },
|
|
504
552
|
{ id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
|
|
505
553
|
{ id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
|
|
@@ -810,9 +858,10 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
|
|
|
810
858
|
return [{
|
|
811
859
|
rule: 'backlog-covers-features',
|
|
812
860
|
severity: sev,
|
|
813
|
-
detail: `${uncovered.length} фич(и) заведены после ${ev.baseline} и не названы ни
|
|
861
|
+
detail: `${uncovered.length} фич(и) заведены после ${ev.baseline} и не названы ни записью бэклога, ни причиной перехода: `
|
|
814
862
|
+ `${uncovered.slice(0, 8).join(', ')}${uncovered.length > 8 ? '…' : ''}`
|
|
815
|
-
+ ` —
|
|
863
|
+
+ ` — назови слаг в записи (dz backlog add) ЛИБО в причине закрытия`
|
|
864
|
+
+ ` (dz backlog ship <id> --reason "<слаг>: что сделано") ЛИБО впиши оговорку в features/<slug>/README.md строкой`
|
|
816
865
|
+ ` "Backlog: не заведено — <причина>". Оговорка без причины не считается.`,
|
|
817
866
|
}];
|
|
818
867
|
},
|
|
@@ -1338,6 +1387,16 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
|
|
|
1338
1387
|
// (missing contents ⇒ nothing reported), but a skip nobody can see is fail-SILENT. One aggregate
|
|
1339
1388
|
// note, computed AFTER the verdict so it can never block or warn: information, not a violation.
|
|
1340
1389
|
const notes: string[] = [];
|
|
1390
|
+
// Only `checked`: when the chronicle read fails, the collector still supplies `featureBacklog`
|
|
1391
|
+
// (the error field can only live INSIDE that object), so HAS_INPUT holds and the rule is never
|
|
1392
|
+
// in `notEstablished` with an error to report. The second half was dead by construction —
|
|
1393
|
+
// MEASURED by mutation, Step 8, 22.09.2026: removing it turned no test red.
|
|
1394
|
+
if (checked.includes('backlog-covers-features')) {
|
|
1395
|
+
const error = facts.featureBacklog?.transitionLogError;
|
|
1396
|
+
if (typeof error === 'string' && error.trim() !== '') {
|
|
1397
|
+
notes.push(`backlog-covers-features: летопись переходов не прочитана (${error}) — покрытие проверено только по текстам записей бэклога`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1341
1400
|
if (notEstablished.includes('rounds-traced')) {
|
|
1342
1401
|
const fact = facts.codeCommitsSinceLastRound;
|
|
1343
1402
|
if (fact?.enabled === false) notes.push('rounds-traced: skipped (.dz/config.json rounds.traced=false)');
|
package/src/index.ts
CHANGED
|
@@ -772,7 +772,8 @@ export type {
|
|
|
772
772
|
} from './event-chain.js';
|
|
773
773
|
export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
774
774
|
export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
|
|
775
|
-
export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
|
|
775
|
+
export { RELEASE_LINE_RE, parseReleaseLine, findReleaseLine, rewriteReleaseLine, shortPackageName } from './release-line.js';
|
|
776
|
+
export type { ReleaseLineToken, ReleaseLineParse, ReleaseLineMatch } from './release-line.js';
|
|
776
777
|
export * from './course-staleness.js';
|
|
777
778
|
export { fetchAllDownloads } from './downloads.js';
|
|
778
779
|
export type { PackageDownloads, DownloadsReport } from './downloads.js';
|
package/src/publish.ts
CHANGED
|
@@ -22,7 +22,7 @@ type ExecSyncOptionsWithStringEncoding = NonNullable<Parameters<typeof execSync>
|
|
|
22
22
|
import { createHash } from 'node:crypto';
|
|
23
23
|
|
|
24
24
|
import { claimCheck } from './claim-check.js';
|
|
25
|
-
import { rewriteReleaseLine, isReleaseLineToken } from './release-line.js';
|
|
25
|
+
import { findReleaseLine, rewriteReleaseLine, isReleaseLineToken, shortPackageName } from './release-line.js';
|
|
26
26
|
import { packedTarballName } from './packed-install-smoke.js';
|
|
27
27
|
|
|
28
28
|
export type ProbeOutcome = {
|
|
@@ -147,6 +147,13 @@ export interface MirrorEpilogue {
|
|
|
147
147
|
readonly warning?: string | undefined;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
export interface ReleaseLineReport {
|
|
151
|
+
readonly path: string; // 'README.md' | 'packages/@dzhechkov/harness-cli/README.md'
|
|
152
|
+
readonly rewritten: readonly { readonly name: string; readonly from: string; readonly to: string }[];
|
|
153
|
+
readonly kept: readonly { readonly name: string; readonly version: string }[];
|
|
154
|
+
readonly wrapped: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
150
157
|
/** Full publish report. */
|
|
151
158
|
export interface PublishReport {
|
|
152
159
|
readonly packages: readonly PublishResult[];
|
|
@@ -156,6 +163,7 @@ export interface PublishReport {
|
|
|
156
163
|
readonly dryRun: boolean;
|
|
157
164
|
/** Repo-relative README paths whose first joint core/CLI release line was rewritten. */
|
|
158
165
|
readonly releaseLineSynced: readonly string[];
|
|
166
|
+
readonly releaseLineReport: readonly ReleaseLineReport[];
|
|
159
167
|
/** Post-publication sync failures are warnings: registry-confirmed packages cannot be unpublished. */
|
|
160
168
|
readonly warnings?: readonly string[] | undefined;
|
|
161
169
|
/** CLI-owned command-hook outcome after a live publish; no transport logic lives in core. */
|
|
@@ -1510,52 +1518,67 @@ export function publishPackages(
|
|
|
1510
1518
|
}
|
|
1511
1519
|
|
|
1512
1520
|
const releaseLineSynced: string[] = [];
|
|
1521
|
+
const releaseLineReport: ReleaseLineReport[] = [];
|
|
1513
1522
|
const warnings: string[] = [];
|
|
1514
|
-
const
|
|
1515
|
-
const
|
|
1516
|
-
(result)
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
try {
|
|
1525
|
-
const parsed = JSON.parse(readFileSync(pathJoin(pkg.dir, 'package.json'), 'utf8')) as { version?: unknown };
|
|
1526
|
-
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
1527
|
-
} catch {
|
|
1528
|
-
return null;
|
|
1523
|
+
const versions: Record<string, string> = {};
|
|
1524
|
+
for (const result of results.filter((result) => result.status === 'published')) {
|
|
1525
|
+
versions[shortPackageName(result.name)] = result.newVersion;
|
|
1526
|
+
}
|
|
1527
|
+
// Dry-run marks would-publish packages skipped, but records successful planning in landedInBatch.
|
|
1528
|
+
const planned: Record<string, string> = opts.dryRun === true ? {} : versions;
|
|
1529
|
+
if (opts.dryRun === true) {
|
|
1530
|
+
for (const result of results) {
|
|
1531
|
+
if (landedInBatch.has(result.name) && result.error === undefined) {
|
|
1532
|
+
planned[shortPackageName(result.name)] = result.newVersion;
|
|
1529
1533
|
}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const syncReleaseLine = Object.keys(versions).length > 0 && opts.dryRun !== true && opts.bumpOnly !== true;
|
|
1537
|
+
if (Object.keys(planned).length > 0 && opts.bumpOnly !== true) {
|
|
1538
|
+
const readmes = [
|
|
1539
|
+
{ path: 'README.md', absolute: pathJoin(monorepoRoot, 'README.md') },
|
|
1540
|
+
{
|
|
1541
|
+
path: 'packages/@dzhechkov/harness-cli/README.md',
|
|
1542
|
+
absolute: pathJoin(monorepoRoot, 'packages', '@dzhechkov', 'harness-cli', 'README.md'),
|
|
1543
|
+
},
|
|
1544
|
+
] as const;
|
|
1545
|
+
for (const readme of readmes) {
|
|
1546
|
+
try {
|
|
1547
|
+
const original = readFileSync(readme.absolute, 'utf8');
|
|
1548
|
+
const found = findReleaseLine(original);
|
|
1549
|
+
if (found === null) {
|
|
1550
|
+
// A repo without a joint release line is the NORMAL case for every consumer monorepo —
|
|
1551
|
+
// only a run that was actually about to WRITE has something to report here. The report-only
|
|
1552
|
+
// pass (dry-run) records an empty plan and says nothing, which is what keeps `dz publish`'s
|
|
1553
|
+
// output free of the word the FR-11 proxy watches for (cli.test.ts, "independent of the
|
|
1554
|
+
// release feature"). MEASURED 2026-09-22: widening the gate without this made every dry-run
|
|
1555
|
+
// in a line-less repo print a release-line warning.
|
|
1556
|
+
if (syncReleaseLine) warnings.push(`release-line sync skipped ${readme.path}: release line not found`);
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const rewritten: { name: string; from: string; to: string }[] = [];
|
|
1560
|
+
const kept: { name: string; version: string }[] = [];
|
|
1561
|
+
for (const token of found.tokens) {
|
|
1562
|
+
if (Object.hasOwn(planned, token.name) && planned[token.name] !== token.version) {
|
|
1563
|
+
rewritten.push({ name: token.name, from: token.version, to: planned[token.name]! });
|
|
1564
|
+
} else {
|
|
1565
|
+
kept.push({ name: token.name, version: token.version });
|
|
1550
1566
|
}
|
|
1551
|
-
if (updated === original) continue;
|
|
1552
|
-
const tmp = readme.absolute + '.sync-tmp';
|
|
1553
|
-
writeFileSync(tmp, updated);
|
|
1554
|
-
renameSync(tmp, readme.absolute);
|
|
1555
|
-
releaseLineSynced.push(readme.path);
|
|
1556
|
-
} catch (error) {
|
|
1557
|
-
warnings.push(`release-line sync failed ${readme.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1558
1567
|
}
|
|
1568
|
+
releaseLineReport.push({ path: readme.path, rewritten, kept, wrapped: found.wrapped });
|
|
1569
|
+
const updated = rewriteReleaseLine(original, planned);
|
|
1570
|
+
if (!syncReleaseLine || updated === null || updated === original) continue;
|
|
1571
|
+
const tmp = readme.absolute + '.sync-tmp';
|
|
1572
|
+
writeFileSync(tmp, updated);
|
|
1573
|
+
renameSync(tmp, readme.absolute);
|
|
1574
|
+
releaseLineSynced.push(readme.path);
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
// Same rule as the not-found branch above: only the pass that was actually going to WRITE
|
|
1577
|
+
// reports. A README that does not exist at all is the normal state of a consumer monorepo,
|
|
1578
|
+
// and a report-only pass must not turn that into a warning naming the release line
|
|
1579
|
+
// (FR-11 proxy, harness-cli/test/cli.test.ts — MEASURED 2026-09-22: ENOENT on both READMEs
|
|
1580
|
+
// of a tmp fixture made every dry-run print two release-line warnings).
|
|
1581
|
+
if (syncReleaseLine) warnings.push(`release-line sync failed ${readme.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1559
1582
|
}
|
|
1560
1583
|
}
|
|
1561
1584
|
}
|
|
@@ -1577,6 +1600,7 @@ export function publishPackages(
|
|
|
1577
1600
|
errors: results.filter((r) => r.status === 'error').length,
|
|
1578
1601
|
dryRun: opts.dryRun === true,
|
|
1579
1602
|
releaseLineSynced,
|
|
1603
|
+
releaseLineReport,
|
|
1580
1604
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
1581
1605
|
};
|
|
1582
1606
|
}
|
package/src/release-line.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export const RELEASE_LINE_RE = /`harness-core v(\d+\.\d+\.\d+)` · `harness-cli v(\d+\.\d+\.\d+)`/;
|
|
2
2
|
|
|
3
|
-
export interface
|
|
3
|
+
export interface ReleaseLineToken {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly version: string;
|
|
6
|
+
readonly start: number;
|
|
7
|
+
readonly end: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReleaseLineParse {
|
|
11
|
+
readonly tokens: readonly ReleaseLineToken[];
|
|
12
|
+
readonly chainEnd: number;
|
|
13
|
+
readonly wrapped: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReleaseLineMatch extends ReleaseLineParse {
|
|
4
17
|
readonly index: number;
|
|
5
18
|
readonly line: string;
|
|
6
19
|
readonly core: string;
|
|
@@ -11,25 +24,43 @@ export function findReleaseLine(text: string): ReleaseLineMatch | null {
|
|
|
11
24
|
const lines = text.split('\n');
|
|
12
25
|
for (let index = 0; index < lines.length; index++) {
|
|
13
26
|
const line = lines[index]!;
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
return { index, line, core:
|
|
27
|
+
const parsed = parseReleaseLine(line);
|
|
28
|
+
if (parsed !== null) {
|
|
29
|
+
return { index, line, core: parsed.tokens[0]!.version, cli: parsed.tokens[1]!.version, ...parsed };
|
|
17
30
|
}
|
|
18
31
|
}
|
|
19
32
|
return null;
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
export function rewriteReleaseLine(text: string,
|
|
35
|
+
export function rewriteReleaseLine(text: string, versions: Readonly<Record<string, string>>): string | null;
|
|
36
|
+
export function rewriteReleaseLine(text: string, core: string, cli: string): string | null;
|
|
37
|
+
export function rewriteReleaseLine(
|
|
38
|
+
text: string,
|
|
39
|
+
versionsOrCore: Readonly<Record<string, string>> | string,
|
|
40
|
+
cli?: string,
|
|
41
|
+
): string | null {
|
|
42
|
+
const versions: Readonly<Record<string, string>> = typeof arguments[1] === 'string'
|
|
43
|
+
? { 'harness-core': versionsOrCore as string, 'harness-cli': cli! }
|
|
44
|
+
: versionsOrCore as Readonly<Record<string, string>>;
|
|
23
45
|
const found = findReleaseLine(text);
|
|
24
46
|
if (found === null) return null;
|
|
47
|
+
let line = found.line;
|
|
48
|
+
// Tokens are ordered by start; work from the right so length changes preserve earlier offsets.
|
|
49
|
+
for (let index = found.tokens.length - 1; index >= 0; index--) {
|
|
50
|
+
const token = found.tokens[index]!;
|
|
51
|
+
if (!Object.hasOwn(versions, token.name)) continue;
|
|
52
|
+
const versionStart = token.end - 1 - token.version.length;
|
|
53
|
+
line = line.slice(0, versionStart) + versions[token.name]! + line.slice(token.end - 1);
|
|
54
|
+
}
|
|
25
55
|
const lines = text.split('\n');
|
|
26
|
-
lines[found.index] =
|
|
27
|
-
RELEASE_LINE_RE,
|
|
28
|
-
`\`harness-core v${core}\` · \`harness-cli v${cli}\``,
|
|
29
|
-
);
|
|
56
|
+
lines[found.index] = line;
|
|
30
57
|
return lines.join('\n');
|
|
31
58
|
}
|
|
32
59
|
|
|
60
|
+
export function shortPackageName(name: string): string {
|
|
61
|
+
return name.replace(/^@[^/]+\//, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* A generic RELEASE-LINE token: a backtick-quoted `<pkg-short-name> vX` pair, anywhere on a line —
|
|
35
66
|
* the shape `RELEASE_LINE_RE` names for the joint `harness-core`/`harness-cli` pair, generalised to
|
|
@@ -40,6 +71,24 @@ export function rewriteReleaseLine(text: string, core: string, cli: string): str
|
|
|
40
71
|
*/
|
|
41
72
|
export const GENERIC_RELEASE_TOKEN_RE = /`[a-z][a-z0-9-]*\s+v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?`/;
|
|
42
73
|
|
|
74
|
+
export function parseReleaseLine(line: string): ReleaseLineParse | null {
|
|
75
|
+
const joint = RELEASE_LINE_RE.exec(line);
|
|
76
|
+
if (joint === null) return null;
|
|
77
|
+
// Keep the original sticky chain boundary: prose ends the release line's permission.
|
|
78
|
+
const chainTail = new RegExp(`(?:\\s*·\\s*${GENERIC_RELEASE_TOKEN_RE.source})*`, 'y');
|
|
79
|
+
chainTail.lastIndex = joint.index + joint[0].length;
|
|
80
|
+
const tail = chainTail.exec(line);
|
|
81
|
+
const chainEnd = joint.index + joint[0].length + (tail?.[0].length ?? 0);
|
|
82
|
+
const tokens: ReleaseLineToken[] = [];
|
|
83
|
+
const chain = line.slice(joint.index, chainEnd);
|
|
84
|
+
for (const match of chain.matchAll(new RegExp(GENERIC_RELEASE_TOKEN_RE.source, 'g'))) {
|
|
85
|
+
const [name, version] = match[0].slice(1, -1).split(/\s+v/);
|
|
86
|
+
const start = joint.index + match.index;
|
|
87
|
+
tokens.push({ name: name!, version: version!, start, end: start + match[0].length });
|
|
88
|
+
}
|
|
89
|
+
return { tokens, chainEnd, wrapped: /\s*·\s*$/.test(line.slice(chainEnd)) };
|
|
90
|
+
}
|
|
91
|
+
|
|
43
92
|
/**
|
|
44
93
|
* Is the OLD-VERSION occurrence at `[start, end)` in `line` sitting inside a `` `<name> vX` ``
|
|
45
94
|
* backtick token? A POSITIVE override for `planReadmeVersionSync`'s citation heuristic: a token
|
|
@@ -53,11 +102,6 @@ export function isReleaseLineToken(line: string, start: number, end: number): bo
|
|
|
53
102
|
// Codex r3 HIGH (lead): the permission is the joint pair PLUS the CONTIGUOUS ` · `<name> vX``
|
|
54
103
|
// chain that follows it — not the whole line. `` `harness-core vX` · `harness-cli vY` — historically
|
|
55
104
|
// `memory vZ` `` moves the first two and keeps the third (prose broke the chain).
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const chainTail = new RegExp(`(?:\\s*·\\s*${GENERIC_RELEASE_TOKEN_RE.source})*`, 'y');
|
|
59
|
-
chainTail.lastIndex = joint.index + joint[0].length;
|
|
60
|
-
const tail = chainTail.exec(line);
|
|
61
|
-
const chainEnd = joint.index + joint[0].length + (tail?.[0].length ?? 0);
|
|
62
|
-
return joint.index <= start && end <= chainEnd;
|
|
105
|
+
const p = parseReleaseLine(line);
|
|
106
|
+
return p !== null && p.tokens[0]!.start <= start && end <= p.chainEnd;
|
|
63
107
|
}
|
package/src/session-retro.ts
CHANGED
|
@@ -547,6 +547,14 @@ export const RETRO_PENDING_FILE = 'retro-pending.json';
|
|
|
547
547
|
const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
|
|
548
548
|
/** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
|
|
549
549
|
const SENTINEL_FRESH_MS = 30 * 60 * 1000;
|
|
550
|
+
const MAX_AWAITING_TEACHES = 16;
|
|
551
|
+
|
|
552
|
+
export interface AdmissionDebt {
|
|
553
|
+
readonly snippet: string;
|
|
554
|
+
/** Paired call ids issued against THIS debt, in registration order (oldest first).
|
|
555
|
+
* Absent, rather than an empty array, when no calls await confirmation. */
|
|
556
|
+
readonly awaiting?: readonly string[];
|
|
557
|
+
}
|
|
550
558
|
|
|
551
559
|
export interface RetroPendingSentinel {
|
|
552
560
|
readonly schema: 1;
|
|
@@ -554,6 +562,7 @@ export interface RetroPendingSentinel {
|
|
|
554
562
|
readonly transcript: string; // absolute transcript path — the debt belongs to THIS session
|
|
555
563
|
readonly snippet: string; // ≤200 chars around the admission marker
|
|
556
564
|
readonly ts: string; // ISO time of the scan that recorded the debt
|
|
565
|
+
readonly awaiting?: readonly string[];
|
|
557
566
|
}
|
|
558
567
|
|
|
559
568
|
/** Where the Stop-hook scan got its transcript path — or why it has none. */
|
|
@@ -643,20 +652,27 @@ const RETRO_SCAN_LOCK_TIMEOUT_MS = 2_000;
|
|
|
643
652
|
*/
|
|
644
653
|
export function foldAdmissionDebt(
|
|
645
654
|
events: readonly SessionEvent[],
|
|
646
|
-
prior:
|
|
647
|
-
):
|
|
655
|
+
prior: AdmissionDebt | null,
|
|
656
|
+
): AdmissionDebt | null {
|
|
648
657
|
let pending = prior;
|
|
649
658
|
// EVERY teach issued against the live debt and still awaiting its own result, by `tool_use_id`.
|
|
650
659
|
// Round 3, P1-2: a single `lastPaid` slot lost the FIRST of two parallel teaches — the second call
|
|
651
660
|
// saw an already-cleared `pending` and overwrote the slot with null, so when both results came back
|
|
652
661
|
// receipt-less neither could re-arm and the debt was silently forgiven. A set, and settlement moved
|
|
653
662
|
// to the RECEIPT, removes the whole class: the CALL now registers a candidate and changes nothing.
|
|
654
|
-
|
|
663
|
+
// Old or malformed sentinels mean no candidates, without skipping the rest of the scan.
|
|
664
|
+
const saved = prior?.awaiting;
|
|
665
|
+
const awaiting = new Set<string>(
|
|
666
|
+
Array.isArray(saved) && saved.every((id): id is string => typeof id === 'string') ? saved : [],
|
|
667
|
+
);
|
|
655
668
|
for (const e of events) {
|
|
656
669
|
if (isTeachCommand(e)) {
|
|
657
670
|
if (typeof e.toolUseId === 'string') {
|
|
658
671
|
// Registered, not settled. The debt stands until this call's own result carries a receipt.
|
|
659
|
-
if (pending !== null)
|
|
672
|
+
if (pending !== null) {
|
|
673
|
+
awaiting.add(e.toolUseId);
|
|
674
|
+
if (awaiting.size > MAX_AWAITING_TEACHES) awaiting.delete(awaiting.values().next().value!);
|
|
675
|
+
}
|
|
660
676
|
} else {
|
|
661
677
|
// No pairing key ⇒ no result can ever confirm OR refute this call, so it pays on the command
|
|
662
678
|
// alone — the pre-ADR-004 behaviour, kept deliberately so every id-less fixture and every
|
|
@@ -679,10 +695,16 @@ export function foldAdmissionDebt(
|
|
|
679
695
|
const snippet = admissionSnippet(e.text);
|
|
680
696
|
// A NEW admission supersedes every teach still in flight: those calls were issued against the
|
|
681
697
|
// OLDER debt, so their receipts must not settle this one (ADR-001 D4 asymmetry).
|
|
682
|
-
if (snippet !== null) {
|
|
698
|
+
if (snippet !== null) {
|
|
699
|
+
if (pending === null || pending.snippet !== snippet) awaiting.clear();
|
|
700
|
+
pending = { snippet };
|
|
701
|
+
}
|
|
683
702
|
}
|
|
684
703
|
}
|
|
685
|
-
return pending
|
|
704
|
+
return pending === null ? null : {
|
|
705
|
+
snippet: pending.snippet,
|
|
706
|
+
...(awaiting.size > 0 ? { awaiting: [...awaiting] } : {}),
|
|
707
|
+
};
|
|
686
708
|
}
|
|
687
709
|
|
|
688
710
|
const writeJsonAtomic = (path: string, value: unknown): void => {
|
|
@@ -735,11 +757,14 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
|
|
|
735
757
|
// Prior debt carries over ONLY for the same session; a stale sentinel (another session's debt)
|
|
736
758
|
// is dropped — the PreCompact/SessionEnd retro of THAT session was its collector, and injecting
|
|
737
759
|
// an old session's debt into a new one is noise (acid A9).
|
|
738
|
-
let prior:
|
|
760
|
+
let prior: AdmissionDebt | null = null;
|
|
739
761
|
let hadSentinel = false;
|
|
740
762
|
try {
|
|
741
763
|
const s = JSON.parse(readFileSync(pendingPath, 'utf8')) as Partial<RetroPendingSentinel>;
|
|
742
|
-
if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
|
|
764
|
+
if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
|
|
765
|
+
prior = { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) };
|
|
766
|
+
hadSentinel = true;
|
|
767
|
+
}
|
|
743
768
|
else { try { unlinkSync(pendingPath); } catch { /* already gone */ } }
|
|
744
769
|
} catch { /* no sentinel */ }
|
|
745
770
|
|
|
@@ -778,7 +803,7 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
|
|
|
778
803
|
const next = foldAdmissionDebt(events, prior);
|
|
779
804
|
const newOffset = offset + consumed;
|
|
780
805
|
mkdirSync(dzDir, { recursive: true });
|
|
781
|
-
|
|
806
|
+
let outcome: TailScanOutcome;
|
|
782
807
|
if (next !== null) {
|
|
783
808
|
const sentinel: RetroPendingSentinel = {
|
|
784
809
|
schema: 1,
|
|
@@ -786,15 +811,26 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
|
|
|
786
811
|
transcript: transcriptPath,
|
|
787
812
|
snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
788
813
|
ts: nowIso ?? new Date().toISOString(),
|
|
814
|
+
...(next.awaiting !== undefined ? { awaiting: next.awaiting } : {}),
|
|
789
815
|
};
|
|
790
816
|
writeJsonAtomic(pendingPath, sentinel);
|
|
791
|
-
|
|
817
|
+
outcome = { status: 'pending', snippet: sentinel.snippet, scannedBytes: consumed, offset: newOffset };
|
|
818
|
+
} else if (hadSentinel) {
|
|
819
|
+
// Step-8 finding (23.09.2026, HIGH): swallowing EVERY unlink failure here and then advancing
|
|
820
|
+
// the offset anyway recreates the very loss this feature removes — the sentinel survives, the
|
|
821
|
+
// receipt that paid it is consumed, and every later scan re-arms the SAME stale snippet
|
|
822
|
+
// forever. Only "already gone" is benign; any other failure must leave the bytes replayable,
|
|
823
|
+
// so it propagates and the offset write below never runs. This makes the CLEAR branch
|
|
824
|
+
// symmetric with the ARM branch, which already blocks the offset on a failed write.
|
|
825
|
+
try { unlinkSync(pendingPath); }
|
|
826
|
+
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
827
|
+
outcome = { status: 'cleared', scannedBytes: consumed, offset: newOffset };
|
|
828
|
+
} else {
|
|
829
|
+
outcome = { status: 'none', scannedBytes: consumed, offset: newOffset };
|
|
792
830
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
}
|
|
797
|
-
return { status: 'none', scannedBytes: consumed, offset: newOffset };
|
|
831
|
+
// Commit the debt BEFORE its offset: an interruption must leave these bytes replayable.
|
|
832
|
+
writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
|
|
833
|
+
return outcome;
|
|
798
834
|
} catch {
|
|
799
835
|
return { status: 'none', scannedBytes: 0, offset: 0 };
|
|
800
836
|
}
|
package/src/tree-sync.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** File states shared by skill-tree producers. Extra target files are preserved. */
|
|
2
|
+
export type FileState = 'match' | 'missing' | 'diverged' | 'extra';
|
|
3
|
+
|
|
4
|
+
/** Compare relative-path → SHA-256 maps without reading or changing either tree. */
|
|
5
|
+
export function compareTrees(
|
|
6
|
+
source: ReadonlyMap<string, string>,
|
|
7
|
+
target: ReadonlyMap<string, string>,
|
|
8
|
+
): Map<string, FileState> {
|
|
9
|
+
const states = new Map<string, FileState>();
|
|
10
|
+
for (const [path, hash] of source) {
|
|
11
|
+
states.set(path, !target.has(path) ? 'missing' : target.get(path) === hash ? 'match' : 'diverged');
|
|
12
|
+
}
|
|
13
|
+
for (const path of target.keys()) {
|
|
14
|
+
if (!source.has(path)) states.set(path, 'extra');
|
|
15
|
+
}
|
|
16
|
+
return states;
|
|
17
|
+
}
|