@khorsheed/dsh-ankh-guard 0.1.1 → 0.3.0

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 (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +75 -29
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +74 -29
  5. package/lib/cli.js +2349 -182
  6. package/lib/client.js +344 -0
  7. package/lib/exit-agent.js +5 -2
  8. package/lib/index.js +746 -40
  9. package/lib/invariant.js +1 -1
  10. package/lib/preflight-runner.js +125 -47
  11. package/lib/restart-context-DUyExi9O.js +1245 -0
  12. package/lib/restart-context-DlaDZWKq.js +1250 -0
  13. package/lib/{state-Dhx9VG44.js → state-CZMypGkB.js} +97 -13
  14. package/lib/test-seam-cli.js +24 -0
  15. package/lib/test-seam-dwvaKjRp.js +459 -0
  16. package/lib/test-seam.js +2 -0
  17. package/lib/types/browser-handoff.d.ts +55 -0
  18. package/lib/types/browser-handoff.js +489 -0
  19. package/lib/types/cli.d.ts +34 -4
  20. package/lib/types/cli.js +1421 -166
  21. package/lib/types/client/index.d.ts +15 -0
  22. package/lib/types/client/index.js +348 -0
  23. package/lib/types/deployment-proof.d.ts +24 -0
  24. package/lib/types/deployment-proof.js +314 -0
  25. package/lib/types/exit-agent.js +2 -0
  26. package/lib/types/git.d.ts +12 -3
  27. package/lib/types/git.js +69 -7
  28. package/lib/types/index.d.ts +66 -3
  29. package/lib/types/index.js +192 -41
  30. package/lib/types/launch-spec.d.ts +263 -0
  31. package/lib/types/launch-spec.js +823 -0
  32. package/lib/types/preflight-runner.d.ts +23 -12
  33. package/lib/types/preflight-runner.js +152 -57
  34. package/lib/types/processes.d.ts +38 -6
  35. package/lib/types/processes.js +236 -10
  36. package/lib/types/restart-context.d.ts +50 -0
  37. package/lib/types/restart-context.js +117 -6
  38. package/lib/types/restart-request.d.ts +32 -0
  39. package/lib/types/restart-request.js +128 -0
  40. package/lib/types/state-files.d.ts +30 -0
  41. package/lib/types/state-files.js +55 -0
  42. package/lib/types/state.d.ts +29 -2
  43. package/lib/types/state.js +52 -7
  44. package/lib/types/temp-artifact.d.ts +15 -0
  45. package/lib/types/temp-artifact.js +17 -0
  46. package/lib/types/test-seam-cli.d.ts +3 -0
  47. package/lib/types/test-seam-cli.js +27 -0
  48. package/lib/types/test-seam.d.ts +55 -0
  49. package/lib/types/test-seam.js +112 -0
  50. package/lib/types/transition.d.ts +118 -0
  51. package/lib/types/transition.js +717 -0
  52. package/package.json +30 -11
  53. package/scripts/dsh-watchdog.sh +1388 -80
  54. package/scripts/install-launchd.sh +43 -5
  55. package/scripts/install-systemd.sh +43 -5
  56. package/scripts/on-install.js +1 -1
  57. package/skills/dsh-self-restart-guard/SKILL.md +38 -12
  58. package/lib/processes-hCAmwma-.js +0 -127
  59. package/lib/restart-context-DmnQXNf-.js +0 -421
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Reusable evidence for a pure, same-launch restart.
3
+ *
4
+ * A green credential is deliberately short lived: it authorizes one stop
5
+ * close to the observed build/test run. Once that stop has completed
6
+ * readiness and canary, however, the exact deployed inputs are known-good.
7
+ * This module fingerprints those inputs so a later pure restart can reuse the
8
+ * expensive proof without turning "same git HEAD" into a weak proxy for
9
+ * "same deployment".
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, } from 'node:fs';
13
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
14
+ import { currentHead, isWorkingTreeClean } from "./git.js";
15
+ import { commandSha256 } from "./launch-spec.js";
16
+ import { loadState, setProvenDeployment, verifyCredential, } from "./state.js";
17
+ const PROFILE_FILES = [
18
+ 'cordis.patch.yml', 'cordis.yml', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
19
+ ];
20
+ const HOST_RUNTIME_FILES = [
21
+ 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
22
+ 'node_modules/.modules.yaml', 'node_modules/.pnpm-workspace-state-v1.json',
23
+ ];
24
+ const SKIPPED_PACKAGE_DIRS = new Set(['.git', 'coverage', 'node_modules']);
25
+ function hashChunk(hash, kind, logicalPath, value = '') {
26
+ hash.update(kind);
27
+ hash.update('\0');
28
+ hash.update(logicalPath);
29
+ hash.update('\0');
30
+ hash.update(value);
31
+ hash.update('\0');
32
+ }
33
+ /**
34
+ * Hash a runtime tree, following links so a linked package cannot drift
35
+ * outside the fingerprint. Real-directory de-duplication permits legal link
36
+ * cycles without recursion loops. Special/dangling/unreadable nodes fail
37
+ * closed.
38
+ */
39
+ function hashRuntimePath(hash, absolutePath, logicalPath, seenDirectories, packageRoot = false) {
40
+ const info = lstatSync(absolutePath);
41
+ if (info.isSymbolicLink()) {
42
+ const targetText = readlinkSync(absolutePath);
43
+ hashChunk(hash, 'link', logicalPath, targetText);
44
+ let target;
45
+ try {
46
+ target = realpathSync(absolutePath);
47
+ }
48
+ catch (error) {
49
+ throw new Error(`deployment fingerprint refused dangling link ${logicalPath}: ${String(error)}`);
50
+ }
51
+ hashRuntimePath(hash, target, `${logicalPath}/@target`, seenDirectories, packageRoot);
52
+ return;
53
+ }
54
+ if (info.isFile()) {
55
+ hashChunk(hash, 'file', logicalPath, readFileSync(absolutePath));
56
+ return;
57
+ }
58
+ if (!info.isDirectory()) {
59
+ throw new Error(`deployment fingerprint refused special file ${logicalPath}`);
60
+ }
61
+ const realDirectory = realpathSync(absolutePath);
62
+ if (seenDirectories.has(realDirectory)) {
63
+ hashChunk(hash, 'cycle', logicalPath);
64
+ return;
65
+ }
66
+ seenDirectories.add(realDirectory);
67
+ hashChunk(hash, 'dir', logicalPath);
68
+ for (const name of readdirSync(absolutePath).sort((left, right) => Buffer.from(left).compare(Buffer.from(right)))) {
69
+ if (packageRoot && SKIPPED_PACKAGE_DIRS.has(name)) {
70
+ hashChunk(hash, 'excluded-dir', `${logicalPath}/${name}`);
71
+ continue;
72
+ }
73
+ hashRuntimePath(hash, join(absolutePath, name), `${logicalPath}/${name}`, seenDirectories, packageRoot);
74
+ }
75
+ }
76
+ function hashOptionalPath(hash, root, relativePath) {
77
+ const absolutePath = join(root, relativePath);
78
+ if (!existsSync(absolutePath)) {
79
+ hashChunk(hash, 'absent', relativePath);
80
+ return;
81
+ }
82
+ hashRuntimePath(hash, absolutePath, relativePath, new Set());
83
+ }
84
+ function dependencyInstallPath(profileDir, dependency) {
85
+ return join(profileDir, 'node_modules', ...dependency.split('/'));
86
+ }
87
+ function fileDependencyPath(profileDir, specifier) {
88
+ if (!specifier.startsWith('file:'))
89
+ return undefined;
90
+ const pathname = specifier.slice('file:'.length);
91
+ return isAbsolute(pathname) ? pathname : resolve(profileDir, pathname);
92
+ }
93
+ /** Hash profile inputs, installed direct packages, and file: source archives. */
94
+ function profileRuntimeSha256(spec) {
95
+ const profileDir = join(spec.home, 'profiles', spec.profile);
96
+ const manifestPath = join(profileDir, 'package.json');
97
+ if (!existsSync(manifestPath))
98
+ throw new Error(`deployment fingerprint requires ${manifestPath}`);
99
+ let manifest;
100
+ try {
101
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
102
+ }
103
+ catch (error) {
104
+ throw new Error(`deployment fingerprint could not parse profile package.json: ${String(error)}`);
105
+ }
106
+ const hash = createHash('sha256');
107
+ for (const file of PROFILE_FILES)
108
+ hashOptionalPath(hash, profileDir, file);
109
+ const dependencies = Object.entries(manifest.dependencies ?? {})
110
+ .sort(([left], [right]) => Buffer.from(left).compare(Buffer.from(right)));
111
+ for (const [name, source] of dependencies) {
112
+ const installed = dependencyInstallPath(profileDir, name);
113
+ if (!existsSync(installed))
114
+ throw new Error(`deployment fingerprint requires installed profile dependency ${name}`);
115
+ hashRuntimePath(hash, installed, `installed/${name}`, new Set(), true);
116
+ const archive = fileDependencyPath(profileDir, source);
117
+ if (archive !== undefined) {
118
+ if (!existsSync(archive))
119
+ throw new Error(`deployment fingerprint requires file dependency archive for ${name}`);
120
+ hashRuntimePath(hash, archive, `source-archive/${name}`, new Set());
121
+ }
122
+ }
123
+ return hash.digest('hex');
124
+ }
125
+ /**
126
+ * Hash the host install metadata and any explicitly bound built execution
127
+ * surface. Source surfaces are content-bound by the clean harness git tree.
128
+ */
129
+ function hostRuntimeSha256(spec) {
130
+ const hash = createHash('sha256');
131
+ for (const file of HOST_RUNTIME_FILES)
132
+ hashOptionalPath(hash, spec.harnessRoot, file);
133
+ if (spec.preflight !== undefined) {
134
+ hashRuntimePath(hash, spec.preflight.runnerPath, 'preflight/runner', new Set());
135
+ hashRuntimePath(hash, spec.preflight.installAnchor, 'preflight/install-anchor', new Set());
136
+ if (spec.preflight.surface === 'built') {
137
+ hashRuntimePath(hash, dirname(spec.preflight.installAnchor), 'preflight/built-host-package', new Set(), true);
138
+ }
139
+ }
140
+ return hash.digest('hex');
141
+ }
142
+ function cleanRevision(root, role) {
143
+ const revision = currentHead(root);
144
+ if (revision === null)
145
+ throw new Error(`deployment fingerprint requires ${role} to be a git repository`);
146
+ if (!isWorkingTreeClean(root))
147
+ throw new Error(`deployment fingerprint requires a clean ${role}`);
148
+ return revision;
149
+ }
150
+ export function captureDeploymentFingerprint(spec) {
151
+ if (spec.preflight === undefined) {
152
+ throw new Error('deployment fingerprint requires an explicit source/built preflight execution binding');
153
+ }
154
+ if (spec.preflight.targetCommandSha256 !== commandSha256(spec.command)) {
155
+ throw new Error('deployment fingerprint refused a preflight binding for a different launch command');
156
+ }
157
+ if (fileSha256(spec.preflight.runnerPath) !== spec.preflight.runnerSha256) {
158
+ throw new Error('deployment fingerprint refused a changed preflight runner');
159
+ }
160
+ if (fileSha256(spec.preflight.installAnchor) !== spec.preflight.installAnchorSha256) {
161
+ throw new Error('deployment fingerprint refused a changed preflight install anchor');
162
+ }
163
+ const credentialRevision = cleanRevision(spec.credentialRepo, 'credential repository');
164
+ const harnessRevision = cleanRevision(spec.harnessRoot, 'harness root');
165
+ return {
166
+ version: 1,
167
+ credentialRevision,
168
+ harnessRevision,
169
+ launchSpecSha256: commandSha256(JSON.stringify(spec)),
170
+ profileSha256: profileRuntimeSha256(spec),
171
+ hostRuntimeSha256: hostRuntimeSha256(spec),
172
+ };
173
+ }
174
+ export function deploymentFingerprintSha256(fingerprint) {
175
+ return commandSha256(JSON.stringify(fingerprint));
176
+ }
177
+ function credentialCommandSha256(credential) {
178
+ return commandSha256(credential.command);
179
+ }
180
+ function fileSha256(path) {
181
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
182
+ }
183
+ function proofCredentialMatches(proof, credential) {
184
+ return credential !== undefined
185
+ && credential.revision === proof.credential.revision
186
+ && credential.recordedAt === proof.credential.recordedAt
187
+ && credential.scope === proof.credential.scope
188
+ && credentialCommandSha256(credential) === proof.credential.commandSha256;
189
+ }
190
+ export function verifyProvenDeployment(stateDir, spec) {
191
+ const state = loadState(stateDir);
192
+ const proof = state.provenDeployment;
193
+ if (proof === undefined)
194
+ return { ok: false, reason: 'no proven deployment recorded by a completed restart canary' };
195
+ if (!proofCredentialMatches(proof, state.credential)) {
196
+ return { ok: false, reason: 'proven deployment no longer matches the recorded build/test credential' };
197
+ }
198
+ let current;
199
+ try {
200
+ current = captureDeploymentFingerprint(spec);
201
+ }
202
+ catch (error) {
203
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
204
+ }
205
+ const currentSha256 = deploymentFingerprintSha256(current);
206
+ if (currentSha256 !== proof.fingerprintSha256 || JSON.stringify(current) !== JSON.stringify(proof.fingerprint)) {
207
+ return {
208
+ ok: false,
209
+ reason: `proven deployment fingerprint changed (${proof.fingerprintSha256.slice(0, 16)} -> ${currentSha256.slice(0, 16)}) — rebuild, test, and record again`,
210
+ };
211
+ }
212
+ return {
213
+ ok: true,
214
+ reason: `proven deployment valid (${proof.credential.scope} @ ${proof.credential.revision}, fingerprint ${proof.fingerprintSha256.slice(0, 16)})`,
215
+ authorization: {
216
+ version: 1,
217
+ kind: 'proven-deployment',
218
+ revision: proof.credential.revision,
219
+ evidenceSha256: proof.fingerprintSha256,
220
+ },
221
+ };
222
+ }
223
+ /** Fresh evidence wins; an exact prior deployment is the same-launch fallback. */
224
+ export function verifyRestartEvidence(stateDir, spec, maxAgeMinutes, now = Date.now()) {
225
+ const state = loadState(stateDir);
226
+ const credential = state.credential;
227
+ const fresh = verifyCredential(state, currentHead(spec.credentialRepo), now, maxAgeMinutes, isWorkingTreeClean(spec.credentialRepo));
228
+ if (fresh.ok && credential !== undefined) {
229
+ return {
230
+ ...fresh,
231
+ authorization: {
232
+ version: 1,
233
+ kind: 'fresh-credential',
234
+ revision: credential.revision,
235
+ evidenceSha256: credentialCommandSha256(credential),
236
+ },
237
+ };
238
+ }
239
+ const proven = verifyProvenDeployment(stateDir, spec);
240
+ if (proven.ok)
241
+ return proven;
242
+ return { ok: false, reason: `${fresh.reason}; pure-restart reuse unavailable: ${proven.reason}` };
243
+ }
244
+ /** Revalidate the exact evidence selected before the scheduled stop. */
245
+ export function verifyRestartAuthorization(stateDir, spec, authorization) {
246
+ const state = loadState(stateDir);
247
+ if (authorization.kind === 'proven-deployment') {
248
+ const proven = verifyProvenDeployment(stateDir, spec);
249
+ if (!proven.ok || proven.authorization?.evidenceSha256 !== authorization.evidenceSha256) {
250
+ return { ok: false, reason: `scheduled proven-deployment authorization changed: ${proven.reason}` };
251
+ }
252
+ return proven;
253
+ }
254
+ const credential = state.credential;
255
+ if (credential === undefined
256
+ || credential.revision !== authorization.revision
257
+ || credentialCommandSha256(credential) !== authorization.evidenceSha256) {
258
+ return { ok: false, reason: 'scheduled credential authorization no longer matches durable state' };
259
+ }
260
+ if (currentHead(spec.credentialRepo) !== credential.revision || !isWorkingTreeClean(spec.credentialRepo)) {
261
+ return { ok: false, reason: 'scheduled credential authorization no longer matches a clean repository HEAD' };
262
+ }
263
+ return { ok: true, reason: `scheduled fresh credential still matches ${credential.revision}`, authorization };
264
+ }
265
+ /**
266
+ * Promote a fresh credential after the boot's readiness/canary boundary. A
267
+ * proof-authorized pure restart retains the existing proof unchanged.
268
+ */
269
+ export function proveCurrentDeployment(stateDir, spec, authorization, now = Date.now()) {
270
+ const authorized = verifyRestartAuthorization(stateDir, spec, authorization);
271
+ if (!authorized.ok)
272
+ return authorized;
273
+ if (authorization.kind === 'proven-deployment') {
274
+ return { ...authorized, reason: `${authorized.reason}; existing deployment proof retained` };
275
+ }
276
+ const state = loadState(stateDir);
277
+ const credential = state.credential;
278
+ if (credential === undefined)
279
+ return { ok: false, reason: 'credential disappeared before deployment proof was recorded' };
280
+ let fingerprint;
281
+ try {
282
+ fingerprint = captureDeploymentFingerprint(spec);
283
+ }
284
+ catch (error) {
285
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
286
+ }
287
+ if (fingerprint.credentialRevision !== credential.revision) {
288
+ return { ok: false, reason: 'deployment fingerprint revision differs from the credential revision' };
289
+ }
290
+ const fingerprintSha256 = deploymentFingerprintSha256(fingerprint);
291
+ const proof = {
292
+ version: 1,
293
+ provenAt: now,
294
+ credential: {
295
+ revision: credential.revision,
296
+ recordedAt: credential.recordedAt,
297
+ scope: credential.scope,
298
+ commandSha256: credentialCommandSha256(credential),
299
+ },
300
+ fingerprint,
301
+ fingerprintSha256,
302
+ };
303
+ setProvenDeployment(stateDir, proof, now);
304
+ return {
305
+ ok: true,
306
+ reason: `recorded proven deployment ${fingerprintSha256.slice(0, 16)} for ${credential.revision}`,
307
+ authorization: {
308
+ version: 1,
309
+ kind: 'proven-deployment',
310
+ revision: credential.revision,
311
+ evidenceSha256: fingerprintSha256,
312
+ },
313
+ };
314
+ }
@@ -15,6 +15,7 @@
15
15
  import { writeFileSync } from 'node:fs';
16
16
  import { isDirectInvocation } from "./defaults.js";
17
17
  import { findPidOnPort } from "./processes.js";
18
+ import { registerCurrentTestProcess } from "./test-seam.js";
18
19
  /**
19
20
  * One exit attempt: SIGTERM the listener on {@link port} and record the
20
21
  * outcome. Extracted from the timer so tests can drive it directly.
@@ -49,5 +50,6 @@ export function exitAgentMain(env = process.env) {
49
50
  }
50
51
  // Direct invocation only: imported (tests, bundling) it must never fire.
51
52
  if (isDirectInvocation(import.meta.url)) {
53
+ registerCurrentTestProcess();
52
54
  exitAgentMain();
53
55
  }
@@ -5,18 +5,27 @@
5
5
  * @returns the full HEAD sha, or null.
6
6
  */
7
7
  export declare function currentHead(repoDir: string): string | null;
8
+ /**
9
+ * Porcelain entries for every tracked or untracked working-tree change.
10
+ * `null` means git could not inspect the checkout; callers must fail closed.
11
+ */
12
+ export declare function workingTreeChanges(repoDir: string): string[] | null;
13
+ /** Whether the checkout has no staged, unstaged, or untracked changes. */
14
+ export declare function isWorkingTreeClean(repoDir: string): boolean;
8
15
  /** Result of a checkpoint commit. */
9
16
  export type CheckpointCommitResult = {
10
17
  ok: true;
11
18
  sha: string;
12
19
  artifacts: string[];
20
+ createdCommit: boolean;
13
21
  } | {
14
22
  ok: false;
15
23
  error: string;
16
24
  };
17
25
  /**
18
- * Commit the whole working tree as a checkpoint snapshot (empty commits
19
- * allowed — a clean tree still records a rollback point).
26
+ * Record a clean HEAD directly, or commit the whole dirty working tree as an
27
+ * explicitly approved checkpoint snapshot. Dirty commits use a temporary git
28
+ * index: a hook/commit failure leaves the caller's real index untouched.
20
29
  * @param repoDir - repository directory.
21
30
  * @param message - checkpoint commit message.
22
31
  * @param artifactPattern - staged paths matching this are reported as
@@ -24,7 +33,7 @@ export type CheckpointCommitResult = {
24
33
  * SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
25
34
  * @returns the new HEAD sha, or a failure reason.
26
35
  */
27
- export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp): CheckpointCommitResult;
36
+ export declare function commitCheckpoint(repoDir: string, message: string, artifactPattern?: RegExp, includeDirty?: boolean): CheckpointCommitResult;
28
37
  /**
29
38
  * Roll the checkout back to a checkpoint commit WITHOUT losing work: the
30
39
  * discarded HEAD becomes a `guard-backup-*` branch, and uncommitted tracked
package/lib/types/git.js CHANGED
@@ -4,6 +4,9 @@
4
4
  * are synchronous child-process invocations scoped to the repo directory.
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
+ import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join, resolve } from 'node:path';
7
10
  /**
8
11
  * The repository's current HEAD, or null when the directory is not inside a
9
12
  * git repository (or git itself is unavailable).
@@ -23,8 +26,27 @@ export function currentHead(repoDir) {
23
26
  }
24
27
  }
25
28
  /**
26
- * Commit the whole working tree as a checkpoint snapshot (empty commits
27
- * allowed — a clean tree still records a rollback point).
29
+ * Porcelain entries for every tracked or untracked working-tree change.
30
+ * `null` means git could not inspect the checkout; callers must fail closed.
31
+ */
32
+ export function workingTreeChanges(repoDir) {
33
+ try {
34
+ const out = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' });
35
+ return out.split('\0').filter(entry => entry !== '');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Whether the checkout has no staged, unstaged, or untracked changes. */
42
+ export function isWorkingTreeClean(repoDir) {
43
+ const changes = workingTreeChanges(repoDir);
44
+ return changes !== null && changes.length === 0;
45
+ }
46
+ /**
47
+ * Record a clean HEAD directly, or commit the whole dirty working tree as an
48
+ * explicitly approved checkpoint snapshot. Dirty commits use a temporary git
49
+ * index: a hook/commit failure leaves the caller's real index untouched.
28
50
  * @param repoDir - repository directory.
29
51
  * @param message - checkpoint commit message.
30
52
  * @param artifactPattern - staged paths matching this are reported as
@@ -32,22 +54,62 @@ export function currentHead(repoDir) {
32
54
  * SRC_ARTIFACT_PATTERN in defaults.ts; omit for none).
33
55
  * @returns the new HEAD sha, or a failure reason.
34
56
  */
35
- export function commitCheckpoint(repoDir, message, artifactPattern) {
57
+ export function commitCheckpoint(repoDir, message, artifactPattern, includeDirty = false) {
58
+ let tempDir;
36
59
  try {
37
- execFileSync('git', ['add', '-A'], { cwd: repoDir, stdio: 'pipe' });
38
- const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, encoding: 'utf8' });
60
+ const changes = workingTreeChanges(repoDir);
61
+ if (changes === null)
62
+ return { ok: false, error: 'git checkpoint failed: working tree status is unavailable' };
63
+ if (changes.length > 0 && !includeDirty) {
64
+ const shown = changes.slice(0, 10).map(entry => ` ${entry}`).join('\n');
65
+ const more = changes.length > 10 ? `\n … (${changes.length - 10} more)` : '';
66
+ return {
67
+ ok: false,
68
+ error: `checkpoint refused: working tree has ${changes.length} change(s); review them, then rerun with --include-dirty to commit the complete snapshot:\n${shown}${more}`,
69
+ };
70
+ }
71
+ const previousHead = currentHead(repoDir);
72
+ if (previousHead === null)
73
+ return { ok: false, error: 'git checkpoint failed: current HEAD is unavailable' };
74
+ if (changes.length === 0)
75
+ return { ok: true, sha: previousHead, artifacts: [], createdCommit: false };
76
+ const indexPathRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
77
+ const indexPath = resolve(repoDir, indexPathRaw);
78
+ tempDir = mkdtempSync(join(tmpdir(), 'ankh-guard-index-'));
79
+ const tempIndex = join(tempDir, 'index');
80
+ copyFileSync(indexPath, tempIndex);
81
+ const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
82
+ execFileSync('git', ['add', '-A'], { cwd: repoDir, env, stdio: 'pipe' });
83
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: repoDir, env, encoding: 'utf8' });
39
84
  const artifacts = artifactPattern === undefined
40
85
  ? []
41
86
  : staged.split('\n').filter(file => artifactPattern.test(file));
42
- execFileSync('git', ['commit', '--allow-empty', '-m', message], { cwd: repoDir, stdio: 'pipe' });
87
+ execFileSync('git', ['commit', '-m', message], { cwd: repoDir, env, stdio: 'pipe' });
43
88
  const sha = currentHead(repoDir);
44
89
  if (sha === null)
45
90
  return { ok: false, error: 'checkpoint commit succeeded but HEAD became unreadable' };
46
- return { ok: true, sha, artifacts };
91
+ try {
92
+ // The complete snapshot is now HEAD; make the real index match it. This
93
+ // is the successful-path equivalent of the old `git add -A && commit`,
94
+ // while the temporary index protected the failure path.
95
+ execFileSync('git', ['reset', '--mixed', 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
96
+ }
97
+ catch (error) {
98
+ try {
99
+ execFileSync('git', ['update-ref', 'HEAD', previousHead, sha], { cwd: repoDir, stdio: 'pipe' });
100
+ }
101
+ catch { /* best effort */ }
102
+ return { ok: false, error: `checkpoint commit could not settle the real index: ${String(error)}` };
103
+ }
104
+ return { ok: true, sha, artifacts, createdCommit: true };
47
105
  }
48
106
  catch (error) {
49
107
  return { ok: false, error: `git checkpoint failed: ${String(error)}` };
50
108
  }
109
+ finally {
110
+ if (tempDir !== undefined)
111
+ rmSync(tempDir, { recursive: true, force: true });
112
+ }
51
113
  }
52
114
  /**
53
115
  * Roll the checkout back to a checkpoint commit WITHOUT losing work: the
@@ -18,7 +18,53 @@
18
18
  */
19
19
  import type { Context } from '@deepseek-ai/cordis';
20
20
  import z from '@deepseek-ai/schemastery';
21
+ import { type RestartRequest, type RestartRequestResult } from './restart-request.ts';
21
22
  import { type GuardState, type VerifyResult } from './state.ts';
23
+ /**
24
+ * The slice of a persisted session the preset derivation reads. Structural
25
+ * rather than the rc host's `PresetBearingSession`: the 0.1.2 host deleted
26
+ * that type together with `resolveSessionPreset`.
27
+ */
28
+ interface PersistedPresetSource {
29
+ header: {
30
+ agentPreset?: string | null;
31
+ };
32
+ events: readonly {
33
+ type: string;
34
+ data?: unknown;
35
+ }[];
36
+ }
37
+ /**
38
+ * The two preset-derivation surfaces a host may carry: the rc line exports
39
+ * `resolveSessionPreset`; the 0.1.2 line replaced it (and the
40
+ * `PresetBearingSession` type) with the `agentPresetProjectionDefinition`
41
+ * unit. Probed per call, never from a version string.
42
+ */
43
+ export interface PresetDerivationSurface {
44
+ agentPresetProjectionDefinition?: {
45
+ init(header: {
46
+ agentPreset?: string | null;
47
+ }): string | null;
48
+ apply(state: string | null, event: {
49
+ type: string;
50
+ data?: unknown;
51
+ }): string | null;
52
+ };
53
+ resolveSessionPreset?: (session: PersistedPresetSource) => string | undefined;
54
+ }
55
+ /**
56
+ * Which preset a session actually runs, newest selection winning. The rc host
57
+ * exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
58
+ * replaced it with `agentPresetProjectionDefinition` (init from the header,
59
+ * fold `agent-preset/selected` events) — same semantics, so the derivation
60
+ * feature-detects either surface and never touches the version string. A host
61
+ * with neither yields undefined: the resume falls back to the deployment's
62
+ * default preset, the same outcome a preset-less session had before.
63
+ * @param host - the agent-presets module namespace, structurally probed.
64
+ * @param session - the session's header and event log.
65
+ * @returns the preset id, or `undefined` when the session names none.
66
+ */
67
+ export declare function deriveSessionPreset(host: PresetDerivationSurface, session: PersistedPresetSource): string | undefined;
22
68
  /** Plugin configuration. */
23
69
  export interface SelfRestartGuardConfig {
24
70
  /** Credential freshness window in minutes (default 10). */
@@ -75,6 +121,7 @@ export type CheckpointResult = {
75
121
  ok: true;
76
122
  sha: string;
77
123
  artifacts: string[];
124
+ createdCommit: boolean;
78
125
  } | {
79
126
  ok: false;
80
127
  error: string;
@@ -90,11 +137,13 @@ export interface SelfRestartGuard {
90
137
  */
91
138
  verify(): VerifyResult;
92
139
  /**
93
- * Record a green credential for the current HEAD.
140
+ * Record an externally proven green credential for the current clean HEAD.
141
+ * This synchronous service is a trusted orchestrator seam; agent/CLI flows
142
+ * must use `record --run -- PROGRAM` so the guard observes the exit status.
94
143
  * @param scope - what passed, e.g. 'build+test'.
95
144
  * @param options - optional command that produced the green state.
96
145
  * @returns the persisted state including the new credential.
97
- * @throws outside a git repository (no HEAD to bind to).
146
+ * @throws outside a git repository or while the checkout is dirty.
98
147
  */
99
148
  record(scope: string, options?: {
100
149
  command?: string;
@@ -110,7 +159,9 @@ export interface SelfRestartGuard {
110
159
  */
111
160
  status(): GuardState;
112
161
  /**
113
- * Commit the whole tree as a pre-batch checkpoint and remember it.
162
+ * Remember the existing clean HEAD as a pre-batch checkpoint. Dirty trees
163
+ * are refused; the CLI's reviewed `--include-dirty` path is deliberately
164
+ * unavailable through this convenience service.
114
165
  * @param message - batch description; defaults to 'batch snapshot'.
115
166
  * @returns the checkpoint commit sha, or a failure reason.
116
167
  */
@@ -134,6 +185,17 @@ export interface SelfRestartGuard {
134
185
  canary(options?: {
135
186
  port?: number;
136
187
  }): Promise<CanaryResult>;
188
+ /**
189
+ * Trigger a guarded restart onto a new launch command (a UI-grade
190
+ * mode/profile switch). Dispatches on supervision: no live watchdog → the
191
+ * restart verb's stop→start→canary; supervised → reconfigure's transactional
192
+ * cutover (the only safe way to change the launch command under a live
193
+ * watchdog). The full gate chain applies — credential, composition
194
+ * preflight, marker/lock — and a refusal never stops the running instance.
195
+ * @param request - start: the successor launch command; profile: the dsh profile to preflight; initiator: the session id the restart report returns to (required — the UI caller knows the real session, never invent one).
196
+ * @returns the structured verdict; terminal hint text never crosses this seam.
197
+ */
198
+ requestRestart(request: RestartRequest): Promise<RestartRequestResult>;
137
199
  }
138
200
  declare module '@deepseek-ai/cordis' {
139
201
  interface Context {
@@ -150,4 +212,5 @@ export declare const inject: string[];
150
212
  * @param config - validated plugin config.
151
213
  */
152
214
  export declare function apply(ctx: Context, config: SelfRestartGuardConfig): void;
215
+ export {};
153
216
  //# sourceMappingURL=index.d.ts.map