@fitlab-ai/agent-infra 0.11.5 → 0.11.6

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/dist/lib/merge.js +62 -10
  2. package/dist/lib/platform/github-provider.js +9 -14
  3. package/dist/lib/platform/github-release-notes.js +80 -11
  4. package/dist/lib/platform/none-provider.js +1 -1
  5. package/dist/lib/platform/provider-contract.d.ts +39 -16
  6. package/dist/lib/platform/provider-contract.js +1 -1
  7. package/dist/lib/platform/provider-validation.js +83 -13
  8. package/dist/lib/platform/release-notes.js +7 -8
  9. package/dist/lib/sandbox/capture.js +1 -1
  10. package/dist/lib/sandbox/commands/create.js +46 -1
  11. package/dist/lib/sandbox/commands/enter.js +1 -1
  12. package/dist/lib/sandbox/commands/start.js +1 -1
  13. package/dist/lib/sandbox/config.js +10 -0
  14. package/dist/lib/sandbox/control/lifecycle.js +18 -13
  15. package/dist/lib/sandbox/task-resolver.js +62 -2
  16. package/dist/lib/task/resolve-ref.js +3 -3
  17. package/dist/lib/task/write.js +121 -1
  18. package/dist/lifecycle-build-manifest.json +3 -3
  19. package/lib/merge.ts +59 -10
  20. package/lib/platform/github-provider.ts +11 -18
  21. package/lib/platform/github-release-notes.ts +89 -14
  22. package/lib/platform/none-provider.ts +1 -1
  23. package/lib/platform/provider-contract.ts +38 -12
  24. package/lib/platform/provider-validation.ts +83 -12
  25. package/lib/platform/release-notes.ts +11 -12
  26. package/lib/sandbox/capture.ts +1 -1
  27. package/lib/sandbox/commands/create.ts +52 -1
  28. package/lib/sandbox/commands/enter.ts +1 -1
  29. package/lib/sandbox/commands/start.ts +1 -1
  30. package/lib/sandbox/config.ts +12 -0
  31. package/lib/sandbox/control/lifecycle.ts +22 -13
  32. package/lib/sandbox/task-resolver.ts +51 -2
  33. package/lib/task/resolve-ref.ts +3 -3
  34. package/lib/task/write.ts +115 -1
  35. package/package.json +1 -1
  36. package/templates/.agents/rules/evidence-reporting.en.md +1 -2
  37. package/templates/.agents/rules/evidence-reporting.zh-CN.md +1 -2
  38. package/templates/.agents/skills/archive-tasks/SKILL.en.md +2 -2
  39. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +2 -2
  40. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +109 -6
  41. package/templates/.agents/skills/create-release-note/SKILL.en.md +17 -11
  42. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +17 -11
@@ -18,6 +18,7 @@ const DEFAULTS = Object.freeze({
18
18
  tools: ['agent-infra'],
19
19
  refreshIntervalDays: 7,
20
20
  dockerfile: null,
21
+ initCommand: null,
21
22
  vm: {
22
23
  cpu: null,
23
24
  memory: null,
@@ -35,6 +36,7 @@ type SandboxConfigInput = {
35
36
  customTools?: unknown;
36
37
  refreshIntervalDays?: unknown;
37
38
  dockerfile?: string | null;
39
+ initCommand?: unknown;
38
40
  vm?: Record<string, unknown>;
39
41
  };
40
42
 
@@ -66,6 +68,7 @@ export type SandboxConfig = {
66
68
  delivery?: { remote: string; baseRef: string };
67
69
  refreshIntervalDays: number;
68
70
  dockerfile: string | null;
71
+ initCommand: string | null;
69
72
  vm: SandboxVmConfig;
70
73
  };
71
74
 
@@ -103,6 +106,7 @@ function cloneDefaults(): SandboxConfigInput & { vm: SandboxVmConfig; runtimes:
103
106
  tools: [...DEFAULTS.tools],
104
107
  refreshIntervalDays: DEFAULTS.refreshIntervalDays,
105
108
  dockerfile: DEFAULTS.dockerfile,
109
+ initCommand: DEFAULTS.initCommand,
106
110
  vm: { ...DEFAULTS.vm }
107
111
  };
108
112
  }
@@ -149,6 +153,13 @@ export function loadConfig({
149
153
  ? [...sandbox.runtimes]
150
154
  : defaults.runtimes;
151
155
  const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
156
+ if (sandbox.initCommand !== undefined &&
157
+ (typeof sandbox.initCommand !== 'string' || sandbox.initCommand.trim().length === 0)) {
158
+ throw new Error('sandbox.initCommand must be a non-empty string when configured');
159
+ }
160
+ const initCommand = typeof sandbox.initCommand === 'string'
161
+ ? sandbox.initCommand
162
+ : null;
152
163
 
153
164
  if (!dockerfile) {
154
165
  let enginesNode: string | undefined;
@@ -200,6 +211,7 @@ export function loadConfig({
200
211
  defaults.refreshIntervalDays
201
212
  ),
202
213
  dockerfile,
214
+ initCommand,
203
215
  vm: {
204
216
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
205
217
  memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
@@ -1298,6 +1298,10 @@ export async function quiesceSandboxControlRoot(
1298
1298
  timeoutMs?: number;
1299
1299
  timing?: SandboxControlTimingPolicy;
1300
1300
  identityProbe?: ProcessIdentityProbe;
1301
+ executionProcessControl?: Readonly<{
1302
+ isAlive: (execution: SandboxControlExecution) => boolean;
1303
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => boolean;
1304
+ }>;
1301
1305
  } = {}
1302
1306
  ): Promise<'missing' | 'stale' | 'stopped'> {
1303
1307
  const resolvedRoot = path.resolve(root);
@@ -1338,58 +1342,63 @@ export async function quiesceSandboxControlRoot(
1338
1342
  if (!manifest && brokerLive) throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
1339
1343
 
1340
1344
  const platform = options.platform ?? process.platform;
1345
+ const processControl = options.executionProcessControl ?? {
1346
+ isAlive: (execution: SandboxControlExecution) => executionAlive(execution, platform, identityProbe),
1347
+ terminate: (execution: SandboxControlExecution, phase: 'graceful' | 'force') => phase === 'graceful'
1348
+ ? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
1349
+ : terminateSandboxControlExecution(execution, {
1350
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1351
+ })
1352
+ };
1341
1353
  let executions = manifest ? readExecutions(manifest) : [];
1342
1354
  const owner: OwnerIdentity | null = brokerLive ? broker : statusLive ? statusOwner : null;
1343
1355
  if (!owner) {
1344
1356
  if (manifest && !broker && !status) throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
1345
1357
  for (const execution of executions) {
1346
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1358
+ processControl.terminate(execution, 'graceful');
1347
1359
  }
1348
1360
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1349
1361
  while (Date.now() < softDeadlineAt) {
1350
- if (!executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1362
+ if (!executions.some((execution) => processControl.isAlive(execution))) break;
1351
1363
  await new Promise((resolve) => setTimeout(resolve, 25));
1352
1364
  }
1353
1365
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1354
1366
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1355
1367
  for (const execution of executions) {
1356
- if (!terminateSandboxControlExecution(execution, {
1357
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1358
- })) {
1368
+ if (!processControl.terminate(execution, 'force')) {
1359
1369
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
1360
1370
  }
1361
1371
  }
1362
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1372
+ if (executions.some((execution) => processControl.isAlive(execution))) {
1363
1373
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1364
1374
  }
1365
1375
  return broker || status ? 'stale' : 'missing';
1366
1376
  }
1367
1377
 
1368
- const remaining = (): number => Math.max(0, deadlineAt - Date.now());
1369
1378
  signalOwner(owner, platform, false);
1370
1379
  for (const execution of executions) {
1371
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1380
+ processControl.terminate(execution, 'graceful');
1372
1381
  }
1373
1382
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1374
1383
  while (Date.now() < softDeadlineAt) {
1375
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) break;
1384
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) break;
1376
1385
  await new Promise((resolve) => setTimeout(resolve, 25));
1377
1386
  }
1378
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1387
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
1379
1388
  return 'stopped';
1380
1389
  }
1381
1390
  if (manifest) executions = mergeExecutions(executions, readExecutions(manifest));
1382
1391
  if (Date.now() >= deadlineAt) throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1383
1392
  for (const execution of executions) {
1384
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
1393
+ processControl.terminate(execution, 'force');
1385
1394
  }
1386
1395
  signalOwner(owner, platform, true);
1387
1396
  while (Date.now() < deadlineAt) {
1388
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) return 'stopped';
1397
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) return 'stopped';
1389
1398
  await new Promise((resolve) => setTimeout(resolve, 25));
1390
1399
  }
1391
1400
  if (ownerLive(owner, identityProbe)) throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
1392
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1401
+ if (executions.some((execution) => processControl.isAlive(execution))) throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1393
1402
  return 'stopped';
1394
1403
  }
1395
1404
 
@@ -36,7 +36,9 @@ function stripQuotes(value: string): string {
36
36
 
37
37
  function readTaskContent(repoRoot: string, taskId: string): { content: string; state: TaskWorkspaceState; taskMd: string } {
38
38
  for (const state of TASK_WORKSPACE_STATES) {
39
- const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
39
+ const taskPath = state === 'archive'
40
+ ? path.join(repoRoot, '.agents', 'workspace', state, ...findArchiveTaskParts(repoRoot, taskId), 'local', 'task.md')
41
+ : path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
40
42
  if (fs.existsSync(taskPath)) {
41
43
  return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
42
44
  }
@@ -45,7 +47,7 @@ function readTaskContent(repoRoot: string, taskId: string): { content: string; s
45
47
  }
46
48
 
47
49
  function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspace[] {
48
- return TASK_WORKSPACE_STATES.flatMap((state) => {
50
+ const hot = TASK_WORKSPACE_STATES.filter((state) => state !== 'archive').flatMap((state) => {
49
51
  const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
50
52
  if (!fs.existsSync(taskMd)) return [];
51
53
  const content = fs.readFileSync(taskMd, 'utf8');
@@ -56,6 +58,32 @@ function taskWorkspaceCandidates(repoRoot: string, taskId: string): TaskWorkspac
56
58
  taskMd
57
59
  }];
58
60
  });
61
+ const parts = findArchiveTaskParts(repoRoot, taskId);
62
+ if (parts.length === 0) return hot;
63
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', 'archive', ...parts, 'local', 'task.md');
64
+ if (!fs.existsSync(taskMd)) return hot;
65
+ const content = fs.readFileSync(taskMd, 'utf8');
66
+ return [...hot, {
67
+ taskId,
68
+ branch: resolveBranchFromTaskContent(content, taskId),
69
+ state: 'archive',
70
+ taskMd
71
+ }];
72
+ }
73
+
74
+ function findArchiveTaskParts(repoRoot: string, taskId: string): string[] {
75
+ const archiveRoot = path.join(repoRoot, '.agents', 'workspace', 'archive');
76
+ if (!fs.existsSync(archiveRoot)) return [];
77
+ for (const year of fs.readdirSync(archiveRoot).sort().reverse()) {
78
+ if (!/^\d{4}$/.test(year)) continue;
79
+ for (const month of fs.readdirSync(path.join(archiveRoot, year)).sort().reverse()) {
80
+ if (!/^\d{2}$/.test(month)) continue;
81
+ for (const day of fs.readdirSync(path.join(archiveRoot, year, month)).sort().reverse()) {
82
+ if (/^\d{2}$/.test(day) && fs.existsSync(path.join(archiveRoot, year, month, day, taskId, 'local', 'task.md'))) return [year, month, day, taskId];
83
+ }
84
+ }
85
+ }
86
+ return [];
59
87
  }
60
88
 
61
89
  function resolveBranchFromTaskContent(content: string, taskId: string): string {
@@ -102,6 +130,27 @@ export function resolveTaskWorkspace(taskId: string, repoRoot: string): TaskWork
102
130
 
103
131
  export function listTaskWorkspaces(repoRoot: string, state: TaskWorkspaceState): TaskWorkspace[] {
104
132
  const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
133
+ if (state === 'archive') {
134
+ if (!fs.existsSync(stateRoot)) return [];
135
+ const rows: TaskWorkspace[] = [];
136
+ for (const year of fs.readdirSync(stateRoot).sort()) {
137
+ if (!/^\d{4}$/.test(year)) continue;
138
+ for (const month of fs.readdirSync(path.join(stateRoot, year)).sort()) {
139
+ if (!/^\d{2}$/.test(month)) continue;
140
+ for (const day of fs.readdirSync(path.join(stateRoot, year, month)).sort()) {
141
+ if (!/^\d{2}$/.test(day)) continue;
142
+ for (const taskId of fs.readdirSync(path.join(stateRoot, year, month, day)).sort()) {
143
+ if (!TASK_ID_RE.test(taskId)) continue;
144
+ const taskMd = path.join(stateRoot, year, month, day, taskId, 'local', 'task.md');
145
+ if (!fs.existsSync(taskMd)) continue;
146
+ const content = fs.readFileSync(taskMd, 'utf8');
147
+ rows.push({ taskId, branch: resolveBranchFromTaskContent(content, taskId), state, taskMd });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return rows;
153
+ }
105
154
  if (!fs.existsSync(stateRoot)) return [];
106
155
  return fs.readdirSync(stateRoot, { withFileTypes: true })
107
156
  .filter((entry) => entry.isDirectory() && TASK_ID_RE.test(entry.name))
@@ -81,7 +81,7 @@ function listSortedNumeric(dir: string, width: number): string[] {
81
81
  }
82
82
 
83
83
  function findInArchive(repoRoot: string, taskId: string): string | null {
84
- // archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
84
+ // archive-tasks stores local material at .agents/workspace/archive/YYYY/MM/DD/{taskId}/local/task.md
85
85
  // where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
86
86
  // the task id's creation date. So we cannot derive the path from taskId alone;
87
87
  // walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
@@ -91,7 +91,7 @@ function findInArchive(repoRoot: string, taskId: string): string | null {
91
91
  for (const month of listSortedNumeric(yearDir, 2)) {
92
92
  const monthDir = path.join(yearDir, month);
93
93
  for (const day of listSortedNumeric(monthDir, 2)) {
94
- const candidate = path.join(monthDir, day, taskId, 'task.md');
94
+ const candidate = path.join(monthDir, day, taskId, 'local', 'task.md');
95
95
  if (fs.existsSync(candidate)) return candidate;
96
96
  }
97
97
  }
@@ -162,7 +162,7 @@ function enumerateAllTaskDirs(repoRoot: string): { taskId: string; taskDir: stri
162
162
  const monthDir = path.join(yearDir, month);
163
163
  for (const day of listSortedNumeric(monthDir, 2).reverse()) {
164
164
  const dayDir = path.join(monthDir, day);
165
- for (const taskId of fs.readdirSync(dayDir).sort()) add(taskId, path.join(dayDir, taskId), 'archive');
165
+ for (const taskId of fs.readdirSync(dayDir).sort()) add(taskId, path.join(dayDir, taskId, 'local'), 'archive');
166
166
  }
167
167
  }
168
168
  }
package/lib/task/write.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import crypto from 'node:crypto';
3
4
 
4
5
  import { isValidAgentInfraVersion, VERSION } from '../version.ts';
5
6
  import {
@@ -223,7 +224,7 @@ function errorDetails(error: unknown, fallback: TaskWriteErrorCode): TaskWriteEr
223
224
  };
224
225
  }
225
226
 
226
- function writeTask(request: TaskWriteRequest, options: TaskWriteOptions = {}): TaskWriteResult {
227
+ function writeTaskCore(request: TaskWriteRequest, options: TaskWriteOptions = {}): TaskWriteResult {
227
228
  const resolved = options.taskLocation
228
229
  ? {
229
230
  ok: true as const,
@@ -479,6 +480,119 @@ function writeTask(request: TaskWriteRequest, options: TaskWriteOptions = {}): T
479
480
  return { ...successBase, status: 'applied', changed: true };
480
481
  }
481
482
 
483
+ function archiveContentsHash(localDir: string): string {
484
+ const files: string[] = [];
485
+ const walk = (dir: string) => {
486
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
487
+ if (entry.name.includes('\n') || entry.name.includes('\r') || entry.name === 'manifest.md') {
488
+ throw new Error(`Archive source path is not valid: ${path.join(dir, entry.name)}`);
489
+ }
490
+ const target = path.join(dir, entry.name);
491
+ const stat = fs.lstatSync(target);
492
+ if (stat.isSymbolicLink()) throw new Error(`Archive source cannot contain symbolic links: ${target}`);
493
+ if (stat.isDirectory()) walk(target);
494
+ else if (stat.isFile()) {
495
+ if (entry.name !== 'contents.sha256') files.push(target);
496
+ }
497
+ else throw new Error(`Archive source cannot contain special files: ${target}`);
498
+ }
499
+ };
500
+ walk(localDir);
501
+ const rows = files
502
+ .map((file) => ({ file, relative: path.relative(localDir, file).split(path.sep).join('/') }))
503
+ .sort((left, right) => Buffer.compare(Buffer.from(left.relative), Buffer.from(right.relative)))
504
+ .map(({ file, relative }) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${relative}`);
505
+ return `${rows.join('\n')}${rows.length ? '\n' : ''}`;
506
+ }
507
+
508
+ function writeArchiveContentsHash(localDir: string): void {
509
+ const contents = archiveContentsHash(localDir);
510
+ const hashPath = path.join(localDir, 'contents.sha256');
511
+ const tempPath = path.join(localDir, `.contents.sha256.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
512
+ try {
513
+ fs.writeFileSync(tempPath, contents, { flag: 'wx' });
514
+ fs.renameSync(tempPath, hashPath);
515
+ } catch (error) {
516
+ try {
517
+ fs.unlinkSync(tempPath);
518
+ } catch (cleanupError) {
519
+ if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') {
520
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
521
+ }
522
+ }
523
+ throw error;
524
+ }
525
+ }
526
+
527
+ function restoreArchivedTask(taskMdPath: string, contents: Buffer, mode: number): void {
528
+ const tempPath = `${taskMdPath}.${process.pid}.${Math.random().toString(36).slice(2)}.rollback`;
529
+ fs.writeFileSync(tempPath, contents, { flag: 'wx', mode });
530
+ try {
531
+ fs.renameSync(tempPath, taskMdPath);
532
+ } catch (error) {
533
+ try {
534
+ fs.unlinkSync(tempPath);
535
+ } catch (cleanupError) {
536
+ if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') {
537
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
538
+ }
539
+ }
540
+ throw error;
541
+ }
542
+ }
543
+
544
+ function writeTask(request: TaskWriteRequest, options: TaskWriteOptions = {}): TaskWriteResult {
545
+ const resolved = options.taskLocation
546
+ ? {
547
+ ok: true as const,
548
+ repoRoot: options.taskLocation.repoRoot,
549
+ taskId: options.taskLocation.taskId,
550
+ taskDir: path.dirname(options.taskLocation.taskMdPath),
551
+ taskMdPath: options.taskLocation.taskMdPath,
552
+ state: options.taskLocation.state
553
+ }
554
+ : resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
555
+ if (!resolved.ok) {
556
+ return failure(request, { taskId: resolved.taskId, taskMdPath: null, actualState: null }, resolved.code, resolved.message);
557
+ }
558
+ if (resolved.state !== 'archive' || request.expectedState !== 'archive' || request.dryRun) {
559
+ return writeTaskCore(request, { ...options, taskLocation: {
560
+ repoRoot: resolved.repoRoot,
561
+ taskId: resolved.taskId,
562
+ taskMdPath: resolved.taskMdPath,
563
+ state: resolved.state
564
+ } });
565
+ }
566
+
567
+ const identity = { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, actualState: resolved.state };
568
+ try {
569
+ const originalTask = fs.readFileSync(resolved.taskMdPath);
570
+ const originalMode = fs.statSync(resolved.taskMdPath).mode;
571
+ archiveContentsHash(resolved.taskDir);
572
+ const result = writeTaskCore(request, { ...options, taskLocation: {
573
+ repoRoot: resolved.repoRoot,
574
+ taskId: resolved.taskId,
575
+ taskMdPath: resolved.taskMdPath,
576
+ state: resolved.state
577
+ } });
578
+ if (result.status === 'applied') {
579
+ try {
580
+ writeArchiveContentsHash(resolved.taskDir);
581
+ } catch (error) {
582
+ try {
583
+ restoreArchivedTask(resolved.taskMdPath, originalTask, originalMode);
584
+ } catch (rollbackError) {
585
+ throw new Error(`Archive checksum update failed: ${error instanceof Error ? error.message : String(error)}; task rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
586
+ }
587
+ throw error;
588
+ }
589
+ }
590
+ return result;
591
+ } catch (error) {
592
+ return failure(request, identity, 'TASK_READ_FAILED', error instanceof Error ? error.message : String(error));
593
+ }
594
+ }
595
+
482
596
  export { writeTask, captureTaskWriteMetadata, canonicalTimestamp };
483
597
  export type {
484
598
  FrontmatterMutation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Antigravity CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,8 +21,7 @@ Record a reproducible command or entry point, the exact file/line/object/version
21
21
  - Preserve artifact SHA-256, semantic digest, review head, receipt, request-id, snapshot tree, and platform marker exactly.
22
22
  - The Issue continues to mirror the complete report; the report must not depend on logs accessible only on the local machine.
23
23
  - Manual validation keeps its stricter basename-only and sanitized-result rules; this shared rule does not expand public fields.
24
- - A `$ ` command line satisfies the structural gate but does not require complete successful output.
25
24
 
26
25
  ## Evidence Pairing
27
26
 
28
- Pair every verification claim with a `$ ` command and a proportionate result summary. Include a decisive raw excerpt only for failure, blocking, or dispute conclusions. When a command contains sensitive arguments, use its allowed public name or a redacted command.
27
+ Pair every verification claim with an executable command and a proportionate result summary. Include a decisive raw excerpt only for failure, blocking, or dispute conclusions. When a command contains sensitive arguments, use its allowed public name or a redacted command.
@@ -21,8 +21,7 @@
21
21
  - artifact SHA-256、semantic digest、review head、receipt、request-id、快照树和平台 marker 必须精确保留。
22
22
  - Issue 继续镜像报告全文;报告不得依赖只有本机可访问的日志。
23
23
  - 人工验证继续使用 basename-only、sanitized result 等更严格规则;共享规则不扩大可公开字段。
24
- - `$ ` 命令行标记满足结构门禁,但不要求附带完整成功输出。
25
24
 
26
25
  ## 证据配对
27
26
 
28
- 报告中的每个验证断言都应配一个 `$ ` 命令和相称结果摘要;只有失败、阻塞或争议结论才附决定性原文摘录。命令含敏感参数时使用允许公开的命令名或脱敏命令。
27
+ 报告中的每个验证断言都应配一个可执行命令和相称结果摘要;只有失败、阻塞或争议结论才附决定性原文摘录。命令含敏感参数时使用允许公开的命令名或脱敏命令。
@@ -7,7 +7,7 @@ description: >
7
7
 
8
8
  # Archive Completed Tasks
9
9
 
10
- Move completed tasks from `.agents/workspace/completed/` into `.agents/workspace/archive/YYYY/MM/DD/TASK-xxx/` and rebuild a three-level archive index:
10
+ Archive completed tasks from `.agents/workspace/completed/` into `.agents/workspace/archive/YYYY/MM/DD/TASK-xxx/local/` and rebuild a three-level archive index. Local task materials live in `local/` with a path-sorted `contents.sha256`; the TASK root contains no ordinary files.
11
11
  - root manifest: `.agents/workspace/archive/manifest.md`
12
12
  - yearly manifest: `.agents/workspace/archive/YYYY/manifest.md`
13
13
  - monthly manifest: `.agents/workspace/archive/YYYY/MM/manifest.md`
@@ -32,7 +32,7 @@ bash .agents/skills/archive-tasks/scripts/archive-tasks.sh [--days N | --before
32
32
 
33
33
  The script is responsible for:
34
34
  - reading `completed_at` from `task.md` frontmatter and falling back to `updated_at`
35
- - moving task directories directly into `YYYY/MM/DD/TASK-xxx/` without compression
35
+ - storing local task materials under `YYYY/MM/DD/TASK-xxx/local/` without compression
36
36
  - skipping already archived, missing, or malformed tasks
37
37
  - rebuilding root, yearly, and monthly manifests from all archived tasks
38
38
  - printing an archive and skip summary
@@ -7,7 +7,7 @@ description: >
7
7
 
8
8
  # 归档已完成任务
9
9
 
10
- 将 `.agents/workspace/completed/` 中的已完成任务移动到 `.agents/workspace/archive/YYYY/MM/DD/TASK-xxx/`,并重建三级归档索引:
10
+ 将 `.agents/workspace/completed/` 中的已完成任务归档到 `.agents/workspace/archive/YYYY/MM/DD/TASK-xxx/local/`,并重建三级归档索引。TASK 本地材料位于 `local/`,含按路径排序的 `contents.sha256`;TASK 根目录不存放普通文件。
11
11
  - 根 manifest:`.agents/workspace/archive/manifest.md`
12
12
  - 年 manifest:`.agents/workspace/archive/YYYY/manifest.md`
13
13
  - 月 manifest:`.agents/workspace/archive/YYYY/MM/manifest.md`
@@ -32,7 +32,7 @@ bash .agents/skills/archive-tasks/scripts/archive-tasks.sh [--days N | --before
32
32
 
33
33
  脚本负责:
34
34
  - 解析 `task.md` frontmatter 中的 `completed_at`(缺失时回退到 `updated_at`)
35
- - 按 `YYYY/MM/DD/TASK-xxx/` 目录直接移动任务,不压缩
35
+ - 按 `YYYY/MM/DD/TASK-xxx/local/` 保存任务本地材料,不压缩
36
36
  - 跳过已归档、缺少元数据或不存在的任务
37
37
  - 全量重建根 / 年 / 月三级 manifest
38
38
  - 输出归档与跳过摘要
@@ -8,9 +8,14 @@ WORKSPACE_ROOT="$REPO_ROOT/.agents/workspace"
8
8
  COMPLETED_DIR="$WORKSPACE_ROOT/completed"
9
9
  ARCHIVE_DIR="$WORKSPACE_ROOT/archive"
10
10
  MANIFEST_PATH="$ARCHIVE_DIR/manifest.md"
11
+ tmpdir=""
12
+ cleanup() {
13
+ [ -z "$tmpdir" ] || rm -rf "$tmpdir"
14
+ }
15
+ trap 'cleanup' 0
16
+ trap 'cleanup; exit 1' HUP INT TERM
11
17
 
12
- tmpdir="$(mktemp -d)"
13
- trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
18
+ tmpdir="$(mktemp -d "$WORKSPACE_ROOT/.archive-tmp.XXXXXX")"
14
19
 
15
20
  IDS_FILE="$tmpdir/task-ids.txt"
16
21
  : > "$IDS_FILE"
@@ -160,6 +165,17 @@ archive_task_dir() {
160
165
  return 0
161
166
  fi
162
167
 
168
+ find "$task_dir" -name manifest.md -print > "$tmpdir/task-manifests.txt"
169
+ if [ -s "$tmpdir/task-manifests.txt" ]; then
170
+ echo "TASK content cannot contain manifest.md: $task_id" >&2
171
+ return 1
172
+ fi
173
+ find "$task_dir" ! -type f ! -type d -print > "$tmpdir/task-special-files.txt"
174
+ if [ -s "$tmpdir/task-special-files.txt" ]; then
175
+ echo "TASK content cannot contain symbolic links or special files: $task_id" >&2
176
+ return 1
177
+ fi
178
+
163
179
  completed_at=$(extract_completed_at "$task_file")
164
180
  task_date=$(printf '%s' "$completed_at" | cut -c1-10)
165
181
 
@@ -179,12 +195,89 @@ archive_task_dir() {
179
195
  return 0
180
196
  fi
181
197
 
182
- mkdir -p "$ARCHIVE_DIR/$year/$month/$day"
183
- mv "$task_dir" "$destination_dir"
198
+ mkdir -p "$(dirname "$destination_dir")"
199
+ staged_dir="$tmpdir/$task_id"
200
+ mkdir -p "$staged_dir/local"
201
+ cp -R "$task_dir"/. "$staged_dir/local/"
202
+ write_contents_hash "$staged_dir/local"
203
+ mv "$staged_dir" "$destination_dir"
204
+ if ! rm -rf "$task_dir"; then
205
+ if ! rm -rf "$destination_dir"; then
206
+ echo "Failed to remove completed task and roll back archive copy: $task_id" >&2
207
+ else
208
+ echo "Failed to remove completed task; rolled back archive copy: $task_id" >&2
209
+ fi
210
+ return 1
211
+ fi
184
212
  archived_count=$((archived_count + 1))
185
213
  printf 'Archived %s -> %s\n' "$task_id" "$relative_path"
186
214
  }
187
215
 
216
+ write_contents_hash() {
217
+ local_dir="$1"
218
+ hash_path=${2:-$local_dir/contents.sha256}
219
+ hash_tmp="$tmpdir/contents.sha256"
220
+ newline='
221
+ '
222
+ : > "$hash_tmp"
223
+ find "$local_dir" -type f ! -name contents.sha256 | LC_ALL=C sort | while IFS= read -r source_file; do
224
+ relative_file=${source_file#"$local_dir"/}
225
+ case "$relative_file" in *"$newline"*|*"$(printf '\r')"*) echo "Newline path is not supported" >&2; return 1 ;; esac
226
+ if command -v sha256sum >/dev/null 2>&1; then
227
+ digest=$(sha256sum "$source_file" | awk '{print $1}')
228
+ else
229
+ digest=$(shasum -a 256 "$source_file" | awk '{print $1}')
230
+ fi
231
+ printf '%s %s\n' "$digest" "$relative_file" >> "$hash_tmp"
232
+ done
233
+ mv "$hash_tmp" "$hash_path"
234
+ if [ "$hash_path" = "$local_dir/contents.sha256" ] && command -v sha256sum >/dev/null 2>&1; then
235
+ (cd "$local_dir" && sha256sum -c contents.sha256 >/dev/null)
236
+ elif [ "$hash_path" = "$local_dir/contents.sha256" ]; then
237
+ (cd "$local_dir" && shasum -a 256 -c contents.sha256 >/dev/null)
238
+ fi
239
+ }
240
+
241
+ validate_source_hash() {
242
+ source_dir="$1"
243
+ [ -f "$source_dir/task.md" ] || { echo "Archive source lacks task.md: $source_dir" >&2; return 1; }
244
+ [ -f "$source_dir/contents.sha256" ] || { echo "Archive source lacks contents.sha256: $source_dir" >&2; return 1; }
245
+ write_contents_hash "$source_dir" "$tmpdir/contents.expected"
246
+ if ! cmp -s "$tmpdir/contents.expected" "$source_dir/contents.sha256"; then
247
+ echo "Archive source checksum mismatch: $source_dir" >&2
248
+ return 1
249
+ fi
250
+ }
251
+
252
+ validate_existing_archive() {
253
+ [ -d "$ARCHIVE_DIR" ] || return 0
254
+ for year_dir in "$ARCHIVE_DIR"/[0-9][0-9][0-9][0-9]; do
255
+ [ -d "$year_dir" ] || continue
256
+ for month_dir in "$year_dir"/[0-9][0-9]; do
257
+ [ -d "$month_dir" ] || continue
258
+ for day_dir in "$month_dir"/[0-9][0-9]; do
259
+ [ -d "$day_dir" ] || continue
260
+ for task_dir in "$day_dir"/TASK-*; do
261
+ [ -d "$task_dir" ] || continue
262
+ [ -d "$task_dir/local" ] || { echo "Unsupported legacy archive task layout: $task_dir" >&2; return 1; }
263
+ for task_entry in "$task_dir"/*; do
264
+ [ -e "$task_entry" ] || continue
265
+ case "$(basename "$task_entry")" in
266
+ local|github|derived) [ -d "$task_entry" ] || { echo "Invalid archive source directory: $task_entry" >&2; return 1; } ;;
267
+ *) echo "Unexpected TASK root entry: $task_entry" >&2; return 1 ;;
268
+ esac
269
+ done
270
+ find "$task_dir/local" -name manifest.md -print > "$tmpdir/task-manifests.txt"
271
+ [ ! -s "$tmpdir/task-manifests.txt" ] || { echo "TASK content cannot contain manifest.md: $task_dir" >&2; return 1; }
272
+ find "$task_dir/local" ! -type f ! -type d -print > "$tmpdir/task-special-files.txt"
273
+ [ ! -s "$tmpdir/task-special-files.txt" ] || { echo "TASK content cannot contain symbolic links or special files: $task_dir" >&2; return 1; }
274
+ validate_source_hash "$task_dir/local"
275
+ done
276
+ done
277
+ done
278
+ done
279
+ }
280
+
188
281
  should_archive_filtered_task() {
189
282
  task_dir="$1"
190
283
  task_file="$task_dir/task.md"
@@ -231,7 +324,7 @@ rebuild_manifest() {
231
324
  [ -d "$task_dir" ] || continue
232
325
 
233
326
  task_id=$(basename "$task_dir")
234
- task_file="$task_dir/task.md"
327
+ task_file="$task_dir/local/task.md"
235
328
  relative_path="$year/$month/$day/$task_id/"
236
329
  title="$task_id"
237
330
  task_type="unknown"
@@ -261,7 +354,15 @@ rebuild_manifest() {
261
354
  done
262
355
  done
263
356
 
264
- find "$ARCHIVE_DIR" -type f -name 'manifest.md' -exec rm -f {} \;
357
+ rm -f "$ARCHIVE_DIR/manifest.md"
358
+ for year_dir in "$ARCHIVE_DIR"/[0-9][0-9][0-9][0-9]; do
359
+ [ -d "$year_dir" ] || continue
360
+ rm -f "$year_dir/manifest.md"
361
+ for month_dir in "$year_dir"/[0-9][0-9]; do
362
+ [ -d "$month_dir" ] || continue
363
+ rm -f "$month_dir/manifest.md"
364
+ done
365
+ done
265
366
 
266
367
  awk -F'\t' '{print $1 "\t" $2}' "$entries_file" | LC_ALL=C sort -u > "$month_keys_file"
267
368
  awk -F'\t' '{print $1}' "$entries_file" | LC_ALL=C sort -u > "$year_keys_file"
@@ -436,6 +537,8 @@ if [ -s "$IDS_FILE" ]; then
436
537
  MODE="ids"
437
538
  fi
438
539
 
540
+ validate_existing_archive
541
+
439
542
  archived_count=0
440
543
  skipped_count=0
441
544