@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.23

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 (42) hide show
  1. package/README.md +32 -10
  2. package/README_ZH.md +32 -10
  3. package/lib/artifacts.js +342 -0
  4. package/lib/capabilities.js +6 -4
  5. package/lib/client/ActivityPanel.js +5 -2
  6. package/lib/client/agent-teams-card-definition.js +100 -6
  7. package/lib/client.js +90 -7
  8. package/lib/client.js.map +1 -1
  9. package/lib/command.js +3 -2
  10. package/lib/git-artifact-stamp.json +2 -2
  11. package/lib/harness-compat.js +70 -21
  12. package/lib/index.js +26 -3
  13. package/lib/lineage.js +198 -0
  14. package/lib/mailbox.js +59 -34
  15. package/lib/members.js +301 -53
  16. package/lib/profiles.js +3 -2
  17. package/lib/quality-gates.js +850 -65
  18. package/lib/scheduler.js +22 -3
  19. package/lib/snapshot.js +22 -1
  20. package/lib/state.js +279 -13
  21. package/lib/tool-policies.js +187 -0
  22. package/lib/tools.js +370 -89
  23. package/lib/types/artifacts.d.ts +66 -0
  24. package/lib/types/client/activity-monitor.d.ts +6 -0
  25. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  26. package/lib/types/harness-compat.d.ts +56 -14
  27. package/lib/types/index.d.ts +26 -1
  28. package/lib/types/lineage.d.ts +92 -0
  29. package/lib/types/members.d.ts +112 -1
  30. package/lib/types/profiles.d.ts +6 -0
  31. package/lib/types/quality-gates.d.ts +216 -10
  32. package/lib/types/scheduler.d.ts +2 -1
  33. package/lib/types/snapshot.d.ts +20 -0
  34. package/lib/types/state.d.ts +22 -5
  35. package/lib/types/tool-policies.d.ts +119 -0
  36. package/lib/types/tools.d.ts +12 -1
  37. package/lib/types/types.d.ts +95 -0
  38. package/lib/types/workspace-audit.d.ts +27 -0
  39. package/lib/workspace-audit.js +52 -0
  40. package/package.json +12 -5
  41. package/release-notes/v0.1.23.md +34 -0
  42. package/scripts/legacy-session-source-repair.mjs +344 -0
@@ -7,6 +7,7 @@
7
7
  * survives harness restarts.
8
8
  * @module dsh-agent-teams/types
9
9
  */
10
+ import type { MemberToolFilter } from './tool-policies.ts';
10
11
  /** Task lifecycle statuses in progression order. */
11
12
  export type TaskStatus = 'pending' | 'claimed' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
12
13
  /** Statuses after which a task can no longer be claimed or worked on. */
@@ -44,6 +45,49 @@ export interface CommandResult {
44
45
  exitCode?: number;
45
46
  evidence?: string;
46
47
  }
48
+ /**
49
+ * Identity of one workspace path at one instant. A verdict is evidence about a
50
+ * revision, so recording the bytes it judged (not only the task id) lets the
51
+ * delivery check refuse to advertise a revision no review ever saw.
52
+ */
53
+ export interface ArtifactDigest {
54
+ /** Workspace-relative POSIX path. */
55
+ path: string;
56
+ /** Whether a regular file existed when the digest was taken. */
57
+ present: boolean;
58
+ /** Lowercase hex sha256 of the file bytes; absent unless the file was read. */
59
+ sha256?: string;
60
+ /** File size in bytes; absent unless the file was read. */
61
+ size?: number;
62
+ /** File mtime in epoch ms; lets a legacy record detect a later edit. */
63
+ mtimeMs?: number;
64
+ /**
65
+ * The path exists but could not be read or safely resolved (permissions,
66
+ * symlink escaping the workspace, I/O error). Never conflated with "absent":
67
+ * unreadable bytes cannot be claimed as unchanged.
68
+ */
69
+ unreadable?: boolean;
70
+ /** Why the path could not be read, when `unreadable` is set. */
71
+ reason?: string;
72
+ }
73
+ /**
74
+ * A declared scope that could not be fully enumerated (file cap reached,
75
+ * unreadable directory, path that is not workspace-relative). Recorded so a
76
+ * partial artifact list is never mistaken for complete coverage.
77
+ */
78
+ export interface ArtifactScopeGap {
79
+ /** The pattern or path that could not be fully resolved. */
80
+ pattern: string;
81
+ reason: string;
82
+ }
83
+ /** One set of artifact digests captured at a single instant. */
84
+ export interface ArtifactSnapshot {
85
+ /** Epoch ms when these bytes were read. */
86
+ at: number;
87
+ artifacts: ArtifactDigest[];
88
+ /** Present when the recorded coverage is known to be incomplete. */
89
+ gaps?: ArtifactScopeGap[];
90
+ }
47
91
  /** Profile / team review-loop limits. */
48
92
  export interface ReviewPolicy {
49
93
  requirementsMinRounds?: number;
@@ -121,9 +165,47 @@ export interface TeamTask {
121
165
  /** Supplemental observations, attributed to their original execution generation. */
122
166
  supplementalEvidence?: TaskEvidence[];
123
167
  reviewedTaskId?: string;
168
+ /** Execution generation of the reviewed task at the moment of judgment. */
124
169
  reviewedAttempt?: number;
170
+ /**
171
+ * Bytes this verdict judged, read from the workspace when the gate closed.
172
+ * A later edit, deletion or re-attempt invalidates the verdict.
173
+ */
174
+ reviewedArtifacts?: ArtifactDigest[];
175
+ /** Scope of that verdict that could not be fully enumerated, if any. */
176
+ reviewedGaps?: ArtifactScopeGap[];
177
+ /**
178
+ * Scope patterns the verdict bound (e.g. `lib/`). A file that appears under
179
+ * one of them later was never reviewed, so the verdict must not claim it.
180
+ */
181
+ reviewedScope?: string[];
182
+ /**
183
+ * Drift observed while this verdict was being formed (the workspace changed
184
+ * between claim and close). The verdict cannot be read as binding a stable
185
+ * revision.
186
+ */
187
+ reviewedDuringDrift?: string[];
188
+ /**
189
+ * Workspace snapshot taken when this verdict's attempt started, so a close
190
+ * can tell whether it judged a revision that was changing underneath it.
191
+ */
192
+ verdictStartSnapshot?: ArtifactSnapshot;
193
+ /**
194
+ * Workspace snapshot an auto-generated contract (acceptance / inScope /
195
+ * verify) was derived from. When a cited artifact changes underneath it the
196
+ * work order is marked stale instead of being read as authoritative.
197
+ */
198
+ contractSnapshot?: ArtifactSnapshot;
125
199
  /** Repair source: the implementation / previous successful artifact. */
126
200
  sourceTaskId?: string;
201
+ /**
202
+ * The failed gate task(s) whose findings this repair fixes. Recorded as an
203
+ * explicit causal edge because `dependencies` cannot carry it: a failed task
204
+ * never reaches `completed`, so a dependency on it would make the repair
205
+ * permanently unclaimable. Delivery reads this to tell a closed loop from
206
+ * genuinely unresolved work (issue #175A).
207
+ */
208
+ findingSourceTaskIds?: string[];
127
209
  sourceFindingIds?: string[];
128
210
  /** User-constraint / goal items this task claims to cover. */
129
211
  coverageOf?: string[];
@@ -157,6 +239,19 @@ export interface TeamMember {
157
239
  activeModel?: string;
158
240
  /** Whether the fallback route is currently active. */
159
241
  fallbackActive?: boolean;
242
+ /**
243
+ * Owner-declared tool-policy id this member selected (`#59`), absent for a
244
+ * member without a policy (the unchanged default behavior). The Captain
245
+ * model may only select a configured id; it never submits raw tool lists.
246
+ */
247
+ toolPolicy?: string;
248
+ /**
249
+ * The immutable effective tool filter resolved when the member was created
250
+ * and handed to the child's `toolFilter`. `team.json` keeps it for audit,
251
+ * status, and recovery diagnostics; the continuable descriptor remains the
252
+ * authority that reapplies the filter to a cold-resumed child.
253
+ */
254
+ toolFilter?: MemberToolFilter;
160
255
  joinedAt: number;
161
256
  status: MemberStatus;
162
257
  /** Execution admission is closed while a failed/pending handoff is drained. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The single workspace audit shared by every surface that reports a delivery
3
+ * or quality-loop conclusion.
4
+ *
5
+ * `agent_teams_status`, the activity-panel route and any Web API consumer must
6
+ * read the same current bytes: if only one of them compared recorded verdicts
7
+ * against the workspace, the other would keep advertising a passing gate over
8
+ * a revision no review ever saw (issue #161).
9
+ *
10
+ * The audit consumes the artifact reader's *complete* result — digests, the
11
+ * enumeration gaps and unreadable paths — because a partial read must never
12
+ * look like a clean one: a scope that outgrew its cap after the review would
13
+ * otherwise be reported as fully verified.
14
+ * @module dsh-agent-teams/workspace-audit
15
+ */
16
+ import type { DeliveryAudit } from './quality-gates.ts';
17
+ import type { TeamState } from './types.ts';
18
+ /**
19
+ * Read the current workspace bytes a team's recorded verdicts and generated
20
+ * work orders depend on.
21
+ * @param workspace - absolute path of the owning workspace.
22
+ * @param team - the durable team record.
23
+ * @returns digests of every recorded path, the current contents of the scopes
24
+ * the verdicts bound (so a new file cannot slip into a broad scope), and every
25
+ * gap that makes this audit incomplete.
26
+ */
27
+ export declare function auditTeamWorkspace(workspace: string, team: TeamState): DeliveryAudit;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The single workspace audit shared by every surface that reports a delivery
3
+ * or quality-loop conclusion.
4
+ *
5
+ * `agent_teams_status`, the activity-panel route and any Web API consumer must
6
+ * read the same current bytes: if only one of them compared recorded verdicts
7
+ * against the workspace, the other would keep advertising a passing gate over
8
+ * a revision no review ever saw (issue #161).
9
+ *
10
+ * The audit consumes the artifact reader's *complete* result — digests, the
11
+ * enumeration gaps and unreadable paths — because a partial read must never
12
+ * look like a clean one: a scope that outgrew its cap after the review would
13
+ * otherwise be reported as fully verified.
14
+ * @module dsh-agent-teams/workspace-audit
15
+ */
16
+ import { readArtifactScope } from "./artifacts.js";
17
+ import { deliveryArtifactPaths, deliveryScopePatterns } from "./quality-gates.js";
18
+ /**
19
+ * Read the current workspace bytes a team's recorded verdicts and generated
20
+ * work orders depend on.
21
+ * @param workspace - absolute path of the owning workspace.
22
+ * @param team - the durable team record.
23
+ * @returns digests of every recorded path, the current contents of the scopes
24
+ * the verdicts bound (so a new file cannot slip into a broad scope), and every
25
+ * gap that makes this audit incomplete.
26
+ */
27
+ export function auditTeamWorkspace(workspace, team) {
28
+ const paths = deliveryArtifactPaths(team);
29
+ const scopePatterns = deliveryScopePatterns(team);
30
+ const pathRead = paths.length === 0 ? { artifacts: [], gaps: [] } : readArtifactScope(workspace, paths);
31
+ const scopeRead = scopePatterns.length === 0 ? { artifacts: [], gaps: [] } : readArtifactScope(workspace, scopePatterns);
32
+ const gaps = [...pathRead.gaps, ...scopeRead.gaps];
33
+ const seen = new Set(gaps.map((gap) => `${gap.pattern}\u0000${gap.reason}`));
34
+ const recordGap = (pattern, reason) => {
35
+ const key = `${pattern}\u0000${reason}`;
36
+ if (seen.has(key))
37
+ return;
38
+ seen.add(key);
39
+ gaps.push({ pattern, reason });
40
+ };
41
+ for (const artifact of [...pathRead.artifacts, ...scopeRead.artifacts]) {
42
+ // An unreadable path is not a gap in enumeration but a gap in evidence:
43
+ // report it the same way so no surface treats it as a clean read.
44
+ if (artifact.unreadable === true)
45
+ recordGap(artifact.path, artifact.reason ?? 'path could not be read');
46
+ }
47
+ return {
48
+ artifacts: pathRead.artifacts,
49
+ expandedScope: scopeRead.artifacts.map((artifact) => ({ path: artifact.path, present: artifact.present })),
50
+ ...gaps.length === 0 ? {} : { gaps },
51
+ };
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanmicoder/dsh-agent-teams",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "AgentTeams for DeepSeek Harness: multi-agent team collaboration (captain, members, tasks with dependencies, messaging) driven by natural language, with a tree monitor in the web GUI",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,7 +29,8 @@
29
29
  "compatibility.json",
30
30
  "scripts/doctor.mjs",
31
31
  "scripts/compatibility.mjs",
32
- "pnpm-workspace.yaml"
32
+ "pnpm-workspace.yaml",
33
+ "scripts/legacy-session-source-repair.mjs"
33
34
  ],
34
35
  "license": "MIT",
35
36
  "author": {
@@ -228,8 +229,10 @@
228
229
  "update:skills": "node scripts/update-skills.mjs",
229
230
  "test:skills-updater": "node --test scripts/update-skills.test.mjs",
230
231
  "verify:skill": "node scripts/sync-skill.mjs --check",
231
- "verify": "node scripts/verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && pnpm verify:quality-gates && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && node --test scripts/issue-159.test.mjs && pnpm verify:web-routes && pnpm verify:release && pnpm verify:readme && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility && pnpm verify:capabilities && pnpm verify:member-spawn-recovery && pnpm test:git-install && pnpm verify:source-runner && pnpm verify:git-artifact",
232
- "verify:quality-gates": "node scripts/quality-gates-tdd.mjs && node --test scripts/quality-gates-repair-scope.test.mjs scripts/quality-gates-amend.test.mjs",
232
+ "verify": "node scripts/verify.mjs && node scripts/fallback-tdd.mjs && node scripts/member-failure-tdd.mjs && pnpm verify:issue-regressions && pnpm verify:quality-gates && node scripts/lifecycle-verify.mjs && node scripts/stress-verify.mjs && node --test scripts/issue-159.test.mjs && pnpm verify:issue-203 && pnpm verify:corrupt-state && pnpm verify:web-routes && pnpm verify:release && pnpm verify:readme && pnpm verify:skill && pnpm verify:harness-contract && pnpm verify:http-body && pnpm verify:compatibility && pnpm verify:capabilities && pnpm verify:tool-policies && pnpm verify:member-spawn-recovery && pnpm test:git-install && pnpm verify:source-runner && pnpm verify:lineage-identity && pnpm verify:legacy-session-repair && pnpm verify:git-artifact",
233
+ "verify:quality-gates": "node scripts/quality-gates-tdd.mjs && node --test scripts/quality-gates-repair-scope.test.mjs scripts/quality-gates-amend.test.mjs scripts/quality-gates-content-bound.test.mjs scripts/quality-gates-work-order.test.mjs scripts/quality-gates-repair-wiring.test.mjs scripts/quality-gates-excluded-scope.test.mjs scripts/quality-gates-cross-binding.test.mjs",
234
+ "verify:issue-203": "node --test scripts/issue-203-card.test.mjs",
235
+ "verify:corrupt-state": "node --test scripts/corrupt-teamjson.test.mjs",
233
236
  "verify:web-routes": "node scripts/web-routes-verify.mjs",
234
237
  "verify:release": "node --test scripts/release-metadata.test.mjs",
235
238
  "verify:readme": "node scripts/readme-version.mjs && node --test scripts/readme-version.test.mjs",
@@ -238,9 +241,13 @@
238
241
  "verify:http-body": "node --test scripts/http-body.test.mjs",
239
242
  "doctor": "node scripts/doctor.mjs",
240
243
  "verify:capabilities": "node --test scripts/capabilities.test.mjs",
244
+ "verify:tool-policies": "node --test scripts/tool-policies.test.mjs",
241
245
  "verify:member-spawn-recovery": "node --test scripts/member-spawn-recovery.test.mjs",
242
246
  "test:git-install": "node --test scripts/git-artifacts.test.mjs",
243
247
  "verify:source-runner": "node --test scripts/harness-source-verify.test.mjs",
244
- "verify:git-artifact": "node scripts/git-artifacts.mjs"
248
+ "verify:git-artifact": "node scripts/git-artifacts.mjs",
249
+ "verify:issue-regressions": "node --test scripts/issue-188.test.mjs scripts/issue-189.test.mjs scripts/issue-205.test.mjs",
250
+ "verify:legacy-session-repair": "node --test scripts/legacy-session-source-repair.test.mjs",
251
+ "verify:lineage-identity": "node --test scripts/lineage-identity.test.mjs"
245
252
  }
246
253
  }
@@ -0,0 +1,34 @@
1
+ # AgentTeams v0.1.23
2
+
3
+ Stable plugin release on npm `latest`. The recommended host remains DeepSeek Harness `0.2.0-rc.2`; the exact eight-host support matrix is unchanged.
4
+
5
+ ## Changes
6
+
7
+ - Fix Unicode team cards and legacy event folding so member rosters use the same team identity as persisted state (#203).
8
+ - Reject invalid finding line numbers before persistence and normalize recoverable historical values (#188).
9
+ - Resolve member models together with their configured provider, and preserve explicit provider selections (#189).
10
+ - Record member turn failures that arrive after task settlement as durable captain notes (#205).
11
+ - Bind review and verification verdicts to the actual artifact bytes and complete repair lineage; changed files block delivery until covered by a sound verdict (#161).
12
+ - Preserve repair contracts, expose stale source warnings, and create repair/reverification follow-ups for failed verification without deadlocking on failed dependencies (#174, #175).
13
+ - Escalate findings outside the permitted repair scope with a reason instead of creating impossible repair tasks (#183).
14
+ - Preserve captain identity across warm and cold forks, including multihop lineage and member wake-ups (#176).
15
+ - Write session source metadata in the current format and ship an explicit, non-destructive historical recovery utility with source backups (#160).
16
+ - Add owner-configured member tool policies. Members select a policy ID; the four required coordination tools remain available. This controls tool availability and is not an operating-system sandbox (#59).
17
+
18
+ ## Install
19
+
20
+ In Plugins → Add plugin, enter `@nanmicoder/dsh-agent-teams@0.1.23`. For CLI profiles:
21
+
22
+ ```sh
23
+ dsh plugin --profile web add --save-exact @nanmicoder/dsh-agent-teams@0.1.23
24
+ ```
25
+
26
+ Restart the target profile after installation. A global CLI update does not change a desktop application's embedded host.
27
+
28
+ ## Verification scope
29
+
30
+ The source candidate passed typechecking, build, the full project verification suite, and 121 added regression cases. Local macOS arm64 acceptance used the official Harness `0.2.0-rc.2` Loader, real workspaces, and the existing `deepseek-account / deepseek-flash` login without new API keys. It exercised actual member file writes, failed verification followed by repair and independent rechecking, read-only review, cold fork recovery, and content-drift delivery blocking. Integration acceptance included a captain-driven role/contract correction; the original failed observations are retained.
31
+
32
+ Publication requires Ubuntu/Windows static checks and the eight-host runtime matrix against one immutable tarball. CI uses deterministic model fixtures with real host implementations; those checks are separate from the local real-provider acceptance. Real account HTTP 429 responses, packaged Electron, and Windows product execution were not covered by that local acceptance.
33
+
34
+ See [the issue triage and acceptance records](../docs/issue-maintenance-2026-10-10/README.md) for individual evidence and limits. #103's original reproduction already passed on the baseline; no speculative scheduler change is included. Third-party plugin reports and the broader #70/#71 feature requests are outside this release.
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Repair one historical AgentTeams session generation for #160.
4
+ *
5
+ * The released v2→v3 migration edge audits every message source against a
6
+ * closed list of released kinds, so a stored pre-v3 generation that contains
7
+ * the plugin's own producer kind (`agent-teams-command`) is refused in full by
8
+ * every later Harness (`SessionFormatUnsupportedMigrationError: cannot safely
9
+ * transform unclassified message source`). No plugin can change that read path:
10
+ * the published catalog is assembled from direct imports and runs before any
11
+ * plugin mounts. This tool is the reviewable, non-destructive recovery path:
12
+ *
13
+ * - read-only unless `--apply` is passed;
14
+ * - the stored source generation is never modified: it is copied to a backup
15
+ * beside it (exclusive create, so an existing backup is never overwritten)
16
+ * before anything is published, and its hash is compared before/after;
17
+ * - a generation the installed catalog already migrates is left untouched;
18
+ * - only source kinds outside the released vocabulary are normalized to the
19
+ * released plugin wrapper (`{ kind: 'plugin', plugin: 'dsh-agent-teams' }`,
20
+ * the shape the fixed plugin writes on those hosts). The released wrapper's
21
+ * audit admits exactly those two members, so extra historical source fields
22
+ * (goal, profile, ...) are *not* carried into the repaired generation: they
23
+ * are listed in the report and remain only in the untouched source
24
+ * generation and its byte-identical backup. Message content, ordering and
25
+ * every other row value are preserved;
26
+ * - the artifact is then migrated and validated by the *installed* Harness
27
+ * catalog, re-encoded as the current generation through the installed
28
+ * encoder, verified from its exact bytes, and published exclusively
29
+ * (temporary file + link, never replacing an existing generation).
30
+ *
31
+ * Usage:
32
+ * node scripts/legacy-session-source-repair.mjs --session-dir <dir> [--apply]
33
+ * [--kinds agent-teams-command]
34
+ *
35
+ * Exit codes: 0 success (dry runs report `"applied": false`), 1 refusal or
36
+ * failure (nothing published).
37
+ */
38
+ import { constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
39
+ import { createRequire } from 'node:module'
40
+ import { dirname, join, resolve } from 'node:path'
41
+ import { pathToFileURL } from 'node:url'
42
+ import { createHash } from 'node:crypto'
43
+ import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
44
+
45
+ /**
46
+ * Resolve one Harness package through the installed host cohort. The format
47
+ * packages are transitive to the exact `@deepseek-ai/dsh-session` this checkout
48
+ * pins, so they are resolved from that package's own dependency scope instead
49
+ * of duplicating a private copy of the format contract.
50
+ */
51
+ function harnessImport(name) {
52
+ const require = createRequire(import.meta.url)
53
+ const sessionDir = dirname(require.resolve('@deepseek-ai/dsh-session/package.json'))
54
+ const nested = createRequire(join(sessionDir, 'package.json'))
55
+ return import(pathToFileURL(nested.resolve(name)).href)
56
+ }
57
+
58
+ const DEFAULT_KINDS = ['agent-teams-command']
59
+ const BACKUP_SUFFIX = '.pre-160-repair.bak'
60
+
61
+ /** Lowercase hex SHA-256 of one file's exact bytes. */
62
+ function sha256(path) {
63
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
64
+ }
65
+
66
+ function fail(message) {
67
+ console.error(`legacy session source repair: ${message}`)
68
+ process.exitCode = 1
69
+ return undefined
70
+ }
71
+
72
+ function parseArguments(argv) {
73
+ const options = { apply: false, kinds: DEFAULT_KINDS.slice(), sessionDir: undefined }
74
+ for (let index = 0; index < argv.length; index += 1) {
75
+ const flag = argv[index]
76
+ if (flag === '--apply') options.apply = true
77
+ else if (flag === '--session-dir') options.sessionDir = argv[++index]
78
+ else if (flag === '--kinds') options.kinds = String(argv[++index] ?? '').split(',').map(value => value.trim()).filter(Boolean)
79
+ else return fail(`unknown argument ${flag}`)
80
+ }
81
+ if (options.sessionDir === undefined || options.sessionDir === '') return fail('--session-dir is required')
82
+ if (options.kinds.length === 0) return fail('--kinds must name at least one source kind')
83
+ return options
84
+ }
85
+
86
+ /** Stored generation files in one session directory, newest generation last. */
87
+ function storedGenerations(sessionDir) {
88
+ if (!existsSync(sessionDir)) return []
89
+ return readdirSync(sessionDir)
90
+ .map(name => {
91
+ const match = name.match(/^session\.v(\d+)\.jsonl(\.zstd)?$/)
92
+ return match === null ? undefined : { name, version: Number(match[1]), zstd: match[2] === '.zstd' }
93
+ })
94
+ .filter(entry => entry !== undefined)
95
+ .sort((left, right) => left.version - right.version)
96
+ }
97
+
98
+ /**
99
+ * Measure one complete Zstandard frame starting at `start` (RFC 8878):
100
+ * magic, frame header descriptor, optional window/dictionary/content-size
101
+ * fields, the block chain, and the optional checksum. Node's zstd APIs decode
102
+ * only the first frame of the store's concatenated container, and searching for
103
+ * the magic bytes would split on compressed payload that happens to contain
104
+ * them, so every frame is measured exactly.
105
+ */
106
+ function zstdFrameLength(buffer, start) {
107
+ // The magic is the byte sequence 28 B5 2F FD, i.e. 0xFD2FB528 little-endian.
108
+ if (start + 5 > buffer.length || buffer.readUInt32LE(start) !== 0xfd2fb528) {
109
+ throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${start}`)
110
+ }
111
+ const descriptor = buffer[start + 4]
112
+ if (((descriptor >> 3) & 1) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${start}`)
113
+ const contentSizeFlag = descriptor >> 6
114
+ const singleSegment = (descriptor >> 5) & 1
115
+ const checksum = (descriptor >> 2) & 1
116
+ const dictionarySize = [0, 1, 2, 4][descriptor & 3]
117
+ let cursor = start + 5 + (singleSegment === 1 ? 0 : 1) + dictionarySize
118
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment === 1 ? 1 : 0) : [0, 2, 4, 8][contentSizeFlag]
119
+ let contentSize
120
+ if (contentSizeBytes === 1) contentSize = buffer.readUInt8(cursor)
121
+ else if (contentSizeBytes === 2) contentSize = buffer.readUInt16LE(cursor) + 256
122
+ else if (contentSizeBytes === 4) contentSize = buffer.readUInt32LE(cursor)
123
+ else if (contentSizeBytes === 8) contentSize = Number(buffer.readBigUInt64LE(cursor))
124
+ cursor += contentSizeBytes
125
+ for (;;) {
126
+ if (cursor + 3 > buffer.length) throw new Error(`corrupt Zstandard session log: truncated block header at byte ${cursor}`)
127
+ const blockHeader = buffer.readUIntLE(cursor, 3)
128
+ cursor += 3
129
+ const last = blockHeader & 1
130
+ const blockType = (blockHeader >> 1) & 3
131
+ const blockSize = blockHeader >> 3
132
+ if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${cursor}`)
133
+ // RFC 8878 §3.1.1.2.2/3: Block_Size counts the block's *content*; an RLE
134
+ // block stores that content as a single repeated byte, so its payload is
135
+ // one byte regardless of Block_Size.
136
+ cursor += blockType === 1 ? 1 : blockSize
137
+ if (last === 1) break
138
+ }
139
+ if (checksum === 1) cursor += 4
140
+ if (cursor > buffer.length) throw new Error('corrupt Zstandard session log: truncated final frame')
141
+ if (contentSize !== undefined && contentSize !== 0) {
142
+ const decoded = zstdDecompressSync(buffer.subarray(start, cursor))
143
+ if (decoded.byteLength !== contentSize) throw new Error('corrupt Zstandard session log: frame content size disagrees with its header')
144
+ }
145
+ return cursor - start
146
+ }
147
+
148
+ /**
149
+ * Decode the store's concatenated-frame container: the first frame holds
150
+ * exactly the header line, later frames hold event batches.
151
+ */
152
+ export function decodeStoredFrames(buffer) {
153
+ let offset = 0
154
+ let text = ''
155
+ while (offset < buffer.length) {
156
+ const length = zstdFrameLength(buffer, offset)
157
+ text += zstdDecompressSync(buffer.subarray(offset, offset + length)).toString('utf8')
158
+ offset += length
159
+ }
160
+ return text
161
+ }
162
+
163
+ /** Physical log text: the first line is the header, the rest are event rows. */
164
+ function readPhysicalLog(path, zstd) {
165
+ const raw = readFileSync(path)
166
+ const text = zstd ? decodeStoredFrames(raw) : raw.toString('utf8')
167
+ const lines = text.split('\n')
168
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
169
+ return { bytes: raw, header: JSON.parse(lines[0]), rows: lines.slice(1).map(line => JSON.parse(line)) }
170
+ }
171
+
172
+ /** Normalize one out-of-vocabulary message source to the released plugin wrapper. */
173
+ function normalizeSources(value, kinds, counter, depth = 0) {
174
+ if (depth > 12 || value === null || typeof value !== 'object') return value
175
+ if (Array.isArray(value)) return value.map(item => normalizeSources(item, kinds, counter, depth + 1))
176
+ const record = { ...value }
177
+ const source = record['source']
178
+ if (source !== null && typeof source === 'object' && !Array.isArray(source)
179
+ && typeof source['kind'] === 'string' && kinds.includes(source['kind'])) {
180
+ // The released wrapper's source audit admits exactly {kind, plugin}, so any
181
+ // extra historical metadata on this source cannot travel into the repaired
182
+ // generation. It is reported instead, and stays readable in the untouched
183
+ // source generation and its byte-identical backup.
184
+ const dropped = Object.keys(source).filter(key => key !== 'kind' && key !== 'plugin')
185
+ record['source'] = { kind: 'plugin', plugin: 'dsh-agent-teams' }
186
+ counter.normalized += 1
187
+ for (const key of dropped) counter.droppedFields.add(key)
188
+ }
189
+ for (const [key, item] of Object.entries(record)) {
190
+ if (key === 'source') continue
191
+ record[key] = normalizeSources(item, kinds, counter, depth + 1)
192
+ }
193
+ return record
194
+ }
195
+
196
+ /** Write the current-generation artifact with the store's checksummed frame container. */
197
+ function encodeFrames(headerLine, rowLines) {
198
+ const frame = text => zstdCompressSync(Buffer.from(text, 'utf8'), { params: { [zlibConstants.ZSTD_c_checksumFlag]: 1 } })
199
+ const frames = [frame(`${headerLine}\n`)]
200
+ for (let index = 0; index < rowLines.length; index += 500) {
201
+ frames.push(frame(`${rowLines.slice(index, index + 500).join('\n')}\n`))
202
+ }
203
+ return Buffer.concat(frames)
204
+ }
205
+
206
+ async function repair(options) {
207
+ const sessionDir = resolve(options.sessionDir)
208
+ const catalogModule = await harnessImport('@deepseek-ai/dsh-session-format-catalog')
209
+ const catalog = catalogModule.sessionFormatCatalog
210
+ const withChildren = catalogModule.createSessionFormatCatalogWithChildren([])
211
+
212
+ const generations = storedGenerations(sessionDir)
213
+ if (generations.length === 0) return fail(`no stored session generation under ${sessionDir}`)
214
+ const source = generations[generations.length - 1]
215
+ if (source.version >= catalog.currentVersion) {
216
+ return fail(`stored generation v${source.version} is already current (v${catalog.currentVersion}); nothing to repair`)
217
+ }
218
+ const publishedName = `session.v${catalog.currentVersion}.jsonl${source.zstd ? '.zstd' : ''}`
219
+ const publishedPath = join(sessionDir, publishedName)
220
+ if (existsSync(publishedPath)) return fail(`${publishedName} already exists; refusing to overwrite it`)
221
+
222
+ const { header, rows } = readPhysicalLog(join(sessionDir, source.name), source.zstd)
223
+
224
+ // Only logs the installed catalog cannot migrate are touched. A generation
225
+ // whose sources are already readable (v3 producer kinds included) is a no-op
226
+ // so this tool never rewrites unrelated provenance.
227
+ const readableAsIs = (() => {
228
+ try {
229
+ const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
230
+ for (const row of rows) restore.decodeRow(row)
231
+ restore.finish()
232
+ return true
233
+ } catch {
234
+ return false
235
+ }
236
+ })()
237
+ if (readableAsIs) {
238
+ return {
239
+ sessionDir,
240
+ sourceGeneration: source.version,
241
+ sourceFile: source.name,
242
+ sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
243
+ targetGeneration: catalog.currentVersion,
244
+ applied: false,
245
+ skipped: 'already-readable',
246
+ }
247
+ }
248
+
249
+ const counter = { normalized: 0, droppedFields: new Set() }
250
+ const normalizedRows = rows.map(row => normalizeSources(row, options.kinds, counter))
251
+ if (counter.normalized === 0) {
252
+ return fail(`${source.name} is refused by the installed Harness but carries none of ${options.kinds.join(', ')}; nothing this tool may repair`)
253
+ }
254
+
255
+ // Migrate and validate with the installed catalog: the whole artifact must be
256
+ // accepted before anything is published.
257
+ let artifact
258
+ try {
259
+ const restore = withChildren.createRestore(header, { recovery: 'strict', validation: 'current' })
260
+ for (const row of normalizedRows) restore.decodeRow(row)
261
+ artifact = restore.finish()
262
+ } catch (error) {
263
+ return fail(`the installed Harness still refuses ${source.name} after source normalization: ${error?.constructor?.name}: ${error?.message}`)
264
+ }
265
+
266
+ const currentHeader = catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount)
267
+ const currentRows = artifact.events.map(event => catalog.encodeCurrentEvent(event))
268
+ const report = {
269
+ sessionDir,
270
+ sourceGeneration: source.version,
271
+ sourceFile: source.name,
272
+ sourceBytes: readFileSync(join(sessionDir, source.name)).byteLength,
273
+ normalizedSources: counter.normalized,
274
+ normalizedDroppedSourceFields: [...counter.droppedFields].sort(),
275
+ events: artifact.events.length,
276
+ targetGeneration: catalog.currentVersion,
277
+ published: publishedName,
278
+ applied: false,
279
+ }
280
+ if (!options.apply) return report
281
+
282
+ const sourcePath = join(sessionDir, source.name)
283
+ const sourceHashBefore = sha256(sourcePath)
284
+ const backupPath = join(sessionDir, `${source.name}${BACKUP_SUFFIX}`)
285
+ // Exclusive copy (`COPYFILE_EXCL`): an existing backup is never overwritten,
286
+ // and a racing second run fails here instead of silently replacing it.
287
+ try {
288
+ copyFileSync(sourcePath, backupPath, fsConstants.COPYFILE_EXCL)
289
+ } catch (error) {
290
+ if (error?.code === 'EEXIST') return fail(`${backupPath} already exists; refusing to overwrite a backup`)
291
+ throw error
292
+ }
293
+ report.backup = `${source.name}${BACKUP_SUFFIX}`
294
+
295
+ const encoded = encodeFrames(JSON.stringify(currentHeader), currentRows.map(row => JSON.stringify(row)))
296
+ // Read the exact bytes back before publishing: a repair that cannot be read
297
+ // again must not replace a diagnosable state with an unreadable one.
298
+ const verify = (() => {
299
+ const text = decodeStoredFrames(encoded)
300
+ const lines = text.split('\n').filter(line => line !== '')
301
+ const restored = catalog.createRestore(JSON.parse(lines[0]), { recovery: 'strict', validation: 'current' })
302
+ for (const line of lines.slice(1)) restored.decodeRow(JSON.parse(line))
303
+ return restored.finish()
304
+ })()
305
+ if (verify.events.length !== artifact.events.length) {
306
+ return fail(`re-encoded artifact lost events (${verify.events.length} of ${artifact.events.length}); nothing published`)
307
+ }
308
+ // Publish exclusively: the complete artifact is first written to a temporary
309
+ // file in the same directory, verified there, and only then linked to its
310
+ // final name — a half-written current generation can never be visible to the
311
+ // host, and an existing one is never replaced.
312
+ const temporaryPath = join(sessionDir, `${publishedName}.tmp-${process.pid}`)
313
+ // Exclusive create: residue from a crashed run is never overwritten.
314
+ try {
315
+ writeFileSync(temporaryPath, encoded, { flag: 'wx' })
316
+ } catch (error) {
317
+ if (error?.code === 'EEXIST') return fail(`${temporaryPath} exists from an earlier interrupted run; remove it and retry`)
318
+ throw error
319
+ }
320
+ try {
321
+ linkSync(temporaryPath, publishedPath)
322
+ } catch (error) {
323
+ rmSync(temporaryPath, { force: true })
324
+ if (error?.code === 'EEXIST') return fail(`${publishedName} appeared while repairing; refusing to overwrite it`)
325
+ throw error
326
+ }
327
+ rmSync(temporaryPath, { force: true })
328
+ report.applied = true
329
+ report.publishedBytes = encoded.byteLength
330
+ report.sourceUnchanged = sha256(sourcePath) === sourceHashBefore
331
+ report.backupUnchanged = sha256(backupPath) === sourceHashBefore
332
+ return report
333
+ }
334
+
335
+ // CLI body: skipped when this module is imported for its container decoder.
336
+ const invokedDirectly = process.argv[1] !== undefined
337
+ && pathToFileURL(process.argv[1]).href === import.meta.url
338
+ if (invokedDirectly) {
339
+ const options = parseArguments(process.argv.slice(2))
340
+ if (options !== undefined) {
341
+ const report = await repair(options)
342
+ if (report !== undefined) console.log(JSON.stringify(report, null, 2))
343
+ }
344
+ }