@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 @@ import { createSandboxCapabilityPlan, runSandboxHooks, runBoundedSandboxHookComm
18
18
  import { resolveTools, tmpfsSeedStagingPath, tmpfsSeedTargetPath, toolConfigDirCandidates } from '../tools.js';
19
19
  import { assertFreshSandboxReady, hydrateTmpfsSeedEntries, prepareTmpfsMounts, startSandboxControlBroker } from '../recovery.js';
20
20
  import { acquireSandboxControlReplacement, assertSandboxControlCutoverSnapshot, beginSandboxControlReplacement, captureSandboxControlCutoverSnapshot, commitSandboxControlReplacement, quiesceSandboxControlRoot, recoverSandboxControlReplacement, readSandboxControlManifest } from '../control/lifecycle.js';
21
+ import { atomicWriteJson } from '../control/state.js';
21
22
  import { inspectSandboxControlContainer } from '../control/container-identity.js';
22
23
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
23
24
  import { sandboxCoreBindMounts } from '../mounts.js';
@@ -652,7 +653,24 @@ function readImageLabels(config, engine) {
652
653
  config.imageName
653
654
  ]));
654
655
  }
655
- export async function create(args) {
656
+ function readProjectInitCommandDigest(controlRoot) {
657
+ const markerPath = path.join(controlRoot, 'project-init.json');
658
+ if (!fs.existsSync(markerPath))
659
+ return null;
660
+ try {
661
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
662
+ return marker.version === 1 && typeof marker.commandSha256 === 'string' && /^[a-f0-9]{64}$/.test(marker.commandSha256)
663
+ ? marker.commandSha256
664
+ : null;
665
+ }
666
+ catch {
667
+ return null;
668
+ }
669
+ }
670
+ function writeProjectInitCommandDigest(controlRoot, commandSha256) {
671
+ atomicWriteJson(path.join(controlRoot, 'project-init.json'), { version: 1, commandSha256 });
672
+ }
673
+ export async function create(args, { runProjectInitCommand = true } = {}) {
656
674
  const { values, positionals } = parseArgs({
657
675
  args,
658
676
  allowPositionals: true,
@@ -720,6 +738,7 @@ export async function create(args) {
720
738
  identity: target.workspace
721
739
  });
722
740
  const worktree = worktreeCandidates.find((candidate) => fs.existsSync(candidate)) ?? worktreeCandidates[0] ?? '';
741
+ let worktreeCreated = false;
723
742
  const shareCommon = shareCommonDir(effectiveConfig);
724
743
  const shareBranch = shareBranchDir(effectiveConfig, branch);
725
744
  const preparedDockerfile = prepareDockerfile(effectiveConfig, capabilityPlan.image);
@@ -821,6 +840,7 @@ export async function create(args) {
821
840
  toEnginePath(engine, worktree),
822
841
  branch
823
842
  ]);
843
+ worktreeCreated = true;
824
844
  }
825
845
  else {
826
846
  message(`Creating branch '${branch}' from '${baseBranch}'...`);
@@ -834,6 +854,7 @@ export async function create(args) {
834
854
  toEnginePath(engine, worktree),
835
855
  baseBranch
836
856
  ]);
857
+ worktreeCreated = true;
837
858
  }
838
859
  return `Worktree ready at ${worktree}`;
839
860
  }
@@ -886,6 +907,7 @@ export async function create(args) {
886
907
  let replacementCutover = null;
887
908
  try {
888
909
  const recoveryResult = await recoverSandboxControlReplacement(controlPaths.root, replacementLease);
910
+ const previousProjectInitCommandDigest = readProjectInitCommandDigest(controlPaths.root);
889
911
  const hadExistingControlRoot = hadControlRootBeforeAcquire || recoveryResult === 'restored';
890
912
  const previousManifest = fs.existsSync(controlPaths.manifestPath)
891
913
  ? readSandboxControlManifest(controlPaths.manifestPath)
@@ -1295,6 +1317,29 @@ export async function create(args) {
1295
1317
  throw new Error(afterStartFailure.message
1296
1318
  ?? `Sandbox hook '${afterStartFailure.hookId}' failed.`);
1297
1319
  }
1320
+ const initCommand = effectiveConfig.initCommand;
1321
+ const initCommandDigest = initCommand === null ? null : createHash('sha256').update(initCommand).digest('hex');
1322
+ const shouldRunProjectInitCommand = runProjectInitCommand
1323
+ && initCommand !== null
1324
+ && initCommandDigest !== null
1325
+ && (worktreeCreated || previousProjectInitCommandDigest !== initCommandDigest);
1326
+ let projectInitCompleted = !worktreeCreated
1327
+ && initCommandDigest !== null
1328
+ && previousProjectInitCommandDigest === initCommandDigest;
1329
+ if (shouldRunProjectInitCommand && initCommand !== null) {
1330
+ try {
1331
+ runVerboseEngine(engine, 'docker', [
1332
+ 'exec', '--workdir', '/workspace', container, 'bash', '-lc', initCommand
1333
+ ]);
1334
+ projectInitCompleted = true;
1335
+ }
1336
+ catch (error) {
1337
+ throw new Error(`Project initialization failed for '${branch}': ${error instanceof Error ? error.message : String(error)}`);
1338
+ }
1339
+ }
1340
+ if (projectInitCompleted && initCommandDigest !== null) {
1341
+ writeProjectInitCommandDigest(controlPaths.root, initCommandDigest);
1342
+ }
1298
1343
  replacementLease.release();
1299
1344
  replacementLeaseHeld = false;
1300
1345
  return 'Container started';
@@ -109,7 +109,7 @@ export async function enter(args) {
109
109
  allowRecreate: parsed.recreate,
110
110
  recreate: async () => {
111
111
  const { create } = await import('./create.js');
112
- await create([target.requestedRef, '--no-refresh']);
112
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
113
113
  }
114
114
  });
115
115
  const container = ready.container;
@@ -65,7 +65,7 @@ export async function start(args) {
65
65
  forceRecreate: parsed.recreate,
66
66
  recreate: async () => {
67
67
  const { create } = await import('./create.js');
68
- await create([target.requestedRef, '--no-refresh']);
68
+ await create([target.requestedRef, '--no-refresh'], { runProjectInitCommand: false });
69
69
  }
70
70
  });
71
71
  const message = ready.path === 'healthy'
@@ -14,6 +14,7 @@ const DEFAULTS = Object.freeze({
14
14
  tools: ['agent-infra'],
15
15
  refreshIntervalDays: 7,
16
16
  dockerfile: null,
17
+ initCommand: null,
17
18
  vm: {
18
19
  cpu: null,
19
20
  memory: null,
@@ -44,6 +45,7 @@ function cloneDefaults() {
44
45
  tools: [...DEFAULTS.tools],
45
46
  refreshIntervalDays: DEFAULTS.refreshIntervalDays,
46
47
  dockerfile: DEFAULTS.dockerfile,
48
+ initCommand: DEFAULTS.initCommand,
47
49
  vm: { ...DEFAULTS.vm }
48
50
  };
49
51
  }
@@ -77,6 +79,13 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
77
79
  ? [...sandbox.runtimes]
78
80
  : defaults.runtimes;
79
81
  const dockerfile = typeof sandbox.dockerfile === 'string' ? sandbox.dockerfile : defaults.dockerfile ?? null;
82
+ if (sandbox.initCommand !== undefined &&
83
+ (typeof sandbox.initCommand !== 'string' || sandbox.initCommand.trim().length === 0)) {
84
+ throw new Error('sandbox.initCommand must be a non-empty string when configured');
85
+ }
86
+ const initCommand = typeof sandbox.initCommand === 'string'
87
+ ? sandbox.initCommand
88
+ : null;
80
89
  if (!dockerfile) {
81
90
  let enginesNode;
82
91
  try {
@@ -118,6 +127,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
118
127
  },
119
128
  refreshIntervalDays: asNonNegativeIntegerOrDefault(sandbox.refreshIntervalDays, defaults.refreshIntervalDays),
120
129
  dockerfile,
130
+ initCommand,
121
131
  vm: {
122
132
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
123
133
  memory: asPositiveNumberOrNull(sandbox.vm?.memory) ?? defaults.vm.memory,
@@ -1152,17 +1152,25 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1152
1152
  if (!manifest && brokerLive)
1153
1153
  throw new Error('SANDBOX_CONTROL_OWNER_MISMATCH');
1154
1154
  const platform = options.platform ?? process.platform;
1155
+ const processControl = options.executionProcessControl ?? {
1156
+ isAlive: (execution) => executionAlive(execution, platform, identityProbe),
1157
+ terminate: (execution, phase) => phase === 'graceful'
1158
+ ? terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe })
1159
+ : terminateSandboxControlExecution(execution, {
1160
+ platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1161
+ })
1162
+ };
1155
1163
  let executions = manifest ? readExecutions(manifest) : [];
1156
1164
  const owner = brokerLive ? broker : statusLive ? statusOwner : null;
1157
1165
  if (!owner) {
1158
1166
  if (manifest && !broker && !status)
1159
1167
  throw new Error('SANDBOX_CONTROL_OWNER_EVIDENCE_MISSING');
1160
1168
  for (const execution of executions) {
1161
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1169
+ processControl.terminate(execution, 'graceful');
1162
1170
  }
1163
1171
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1164
1172
  while (Date.now() < softDeadlineAt) {
1165
- if (!executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1173
+ if (!executions.some((execution) => processControl.isAlive(execution)))
1166
1174
  break;
1167
1175
  await new Promise((resolve) => setTimeout(resolve, 25));
1168
1176
  }
@@ -1171,29 +1179,26 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1171
1179
  if (Date.now() >= deadlineAt)
1172
1180
  throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1173
1181
  for (const execution of executions) {
1174
- if (!terminateSandboxControlExecution(execution, {
1175
- platform, timeoutMs: Math.max(0, deadlineAt - Date.now()), deadlineAt, forceAt: Date.now(), identityProbe
1176
- })) {
1182
+ if (!processControl.terminate(execution, 'force')) {
1177
1183
  throw new Error(`SANDBOX_CONTROL_EXECUTION_STILL_RUNNING: ${execution.requestId}`);
1178
1184
  }
1179
1185
  }
1180
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1186
+ if (executions.some((execution) => processControl.isAlive(execution))) {
1181
1187
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1182
1188
  }
1183
1189
  return broker || status ? 'stale' : 'missing';
1184
1190
  }
1185
- const remaining = () => Math.max(0, deadlineAt - Date.now());
1186
1191
  signalOwner(owner, platform, false);
1187
1192
  for (const execution of executions) {
1188
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, allowForce: false, identityProbe });
1193
+ processControl.terminate(execution, 'graceful');
1189
1194
  }
1190
1195
  const softDeadlineAt = Math.min(forceAt, Date.now() + Math.floor(Math.max(0, forceAt - Date.now()) / 2));
1191
1196
  while (Date.now() < softDeadlineAt) {
1192
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1197
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
1193
1198
  break;
1194
1199
  await new Promise((resolve) => setTimeout(resolve, 25));
1195
1200
  }
1196
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe))) {
1201
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution))) {
1197
1202
  return 'stopped';
1198
1203
  }
1199
1204
  if (manifest)
@@ -1201,17 +1206,17 @@ export async function quiesceSandboxControlRoot(root, options = {}) {
1201
1206
  if (Date.now() >= deadlineAt)
1202
1207
  throw new Error('SANDBOX_CONTROL_QUIESCE_DEADLINE_EXCEEDED');
1203
1208
  for (const execution of executions) {
1204
- terminateSandboxControlExecution(execution, { platform, timeoutMs: 0, deadlineAt, forceAt: Date.now(), identityProbe });
1209
+ processControl.terminate(execution, 'force');
1205
1210
  }
1206
1211
  signalOwner(owner, platform, true);
1207
1212
  while (Date.now() < deadlineAt) {
1208
- if (!ownerLive(owner, identityProbe) && !executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1213
+ if (!ownerLive(owner, identityProbe) && !executions.some((execution) => processControl.isAlive(execution)))
1209
1214
  return 'stopped';
1210
1215
  await new Promise((resolve) => setTimeout(resolve, 25));
1211
1216
  }
1212
1217
  if (ownerLive(owner, identityProbe))
1213
1218
  throw new Error('SANDBOX_CONTROL_BROKER_STILL_RUNNING');
1214
- if (executions.some((execution) => executionAlive(execution, platform, identityProbe)))
1219
+ if (executions.some((execution) => processControl.isAlive(execution)))
1215
1220
  throw new Error('SANDBOX_CONTROL_EXECUTION_STILL_RUNNING');
1216
1221
  return 'stopped';
1217
1222
  }
@@ -21,7 +21,9 @@ function stripQuotes(value) {
21
21
  }
22
22
  function readTaskContent(repoRoot, taskId) {
23
23
  for (const state of TASK_WORKSPACE_STATES) {
24
- const taskPath = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
24
+ const taskPath = state === 'archive'
25
+ ? path.join(repoRoot, '.agents', 'workspace', state, ...findArchiveTaskParts(repoRoot, taskId), 'local', 'task.md')
26
+ : path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
25
27
  if (fs.existsSync(taskPath)) {
26
28
  return { content: fs.readFileSync(taskPath, 'utf8'), state, taskMd: taskPath };
27
29
  }
@@ -29,7 +31,7 @@ function readTaskContent(repoRoot, taskId) {
29
31
  throw new Error(`Task not found: ${taskId}`);
30
32
  }
31
33
  function taskWorkspaceCandidates(repoRoot, taskId) {
32
- return TASK_WORKSPACE_STATES.flatMap((state) => {
34
+ const hot = TASK_WORKSPACE_STATES.filter((state) => state !== 'archive').flatMap((state) => {
33
35
  const taskMd = path.join(repoRoot, '.agents', 'workspace', state, taskId, 'task.md');
34
36
  if (!fs.existsSync(taskMd))
35
37
  return [];
@@ -41,6 +43,37 @@ function taskWorkspaceCandidates(repoRoot, taskId) {
41
43
  taskMd
42
44
  }];
43
45
  });
46
+ const parts = findArchiveTaskParts(repoRoot, taskId);
47
+ if (parts.length === 0)
48
+ return hot;
49
+ const taskMd = path.join(repoRoot, '.agents', 'workspace', 'archive', ...parts, 'local', 'task.md');
50
+ if (!fs.existsSync(taskMd))
51
+ return hot;
52
+ const content = fs.readFileSync(taskMd, 'utf8');
53
+ return [...hot, {
54
+ taskId,
55
+ branch: resolveBranchFromTaskContent(content, taskId),
56
+ state: 'archive',
57
+ taskMd
58
+ }];
59
+ }
60
+ function findArchiveTaskParts(repoRoot, taskId) {
61
+ const archiveRoot = path.join(repoRoot, '.agents', 'workspace', 'archive');
62
+ if (!fs.existsSync(archiveRoot))
63
+ return [];
64
+ for (const year of fs.readdirSync(archiveRoot).sort().reverse()) {
65
+ if (!/^\d{4}$/.test(year))
66
+ continue;
67
+ for (const month of fs.readdirSync(path.join(archiveRoot, year)).sort().reverse()) {
68
+ if (!/^\d{2}$/.test(month))
69
+ continue;
70
+ for (const day of fs.readdirSync(path.join(archiveRoot, year, month)).sort().reverse()) {
71
+ if (/^\d{2}$/.test(day) && fs.existsSync(path.join(archiveRoot, year, month, day, taskId, 'local', 'task.md')))
72
+ return [year, month, day, taskId];
73
+ }
74
+ }
75
+ }
76
+ return [];
44
77
  }
45
78
  function resolveBranchFromTaskContent(content, taskId) {
46
79
  const frontmatterBranch = content.match(/^branch:\s*(.+)$/m);
@@ -81,6 +114,33 @@ export function resolveTaskWorkspace(taskId, repoRoot) {
81
114
  }
82
115
  export function listTaskWorkspaces(repoRoot, state) {
83
116
  const stateRoot = path.join(repoRoot, '.agents', 'workspace', state);
117
+ if (state === 'archive') {
118
+ if (!fs.existsSync(stateRoot))
119
+ return [];
120
+ const rows = [];
121
+ for (const year of fs.readdirSync(stateRoot).sort()) {
122
+ if (!/^\d{4}$/.test(year))
123
+ continue;
124
+ for (const month of fs.readdirSync(path.join(stateRoot, year)).sort()) {
125
+ if (!/^\d{2}$/.test(month))
126
+ continue;
127
+ for (const day of fs.readdirSync(path.join(stateRoot, year, month)).sort()) {
128
+ if (!/^\d{2}$/.test(day))
129
+ continue;
130
+ for (const taskId of fs.readdirSync(path.join(stateRoot, year, month, day)).sort()) {
131
+ if (!TASK_ID_RE.test(taskId))
132
+ continue;
133
+ const taskMd = path.join(stateRoot, year, month, day, taskId, 'local', 'task.md');
134
+ if (!fs.existsSync(taskMd))
135
+ continue;
136
+ const content = fs.readFileSync(taskMd, 'utf8');
137
+ rows.push({ taskId, branch: resolveBranchFromTaskContent(content, taskId), state, taskMd });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ return rows;
143
+ }
84
144
  if (!fs.existsSync(stateRoot))
85
145
  return [];
86
146
  return fs.readdirSync(stateRoot, { withFileTypes: true })
@@ -41,7 +41,7 @@ function listSortedNumeric(dir, width) {
41
41
  .reverse();
42
42
  }
43
43
  function findInArchive(repoRoot, taskId) {
44
- // archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
44
+ // archive-tasks stores local material at .agents/workspace/archive/YYYY/MM/DD/{taskId}/local/task.md
45
45
  // where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
46
46
  // the task id's creation date. So we cannot derive the path from taskId alone;
47
47
  // walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
@@ -51,7 +51,7 @@ function findInArchive(repoRoot, taskId) {
51
51
  for (const month of listSortedNumeric(yearDir, 2)) {
52
52
  const monthDir = path.join(yearDir, month);
53
53
  for (const day of listSortedNumeric(monthDir, 2)) {
54
- const candidate = path.join(monthDir, day, taskId, 'task.md');
54
+ const candidate = path.join(monthDir, day, taskId, 'local', 'task.md');
55
55
  if (fs.existsSync(candidate))
56
56
  return candidate;
57
57
  }
@@ -125,7 +125,7 @@ function enumerateAllTaskDirs(repoRoot) {
125
125
  for (const day of listSortedNumeric(monthDir, 2).reverse()) {
126
126
  const dayDir = path.join(monthDir, day);
127
127
  for (const taskId of fs.readdirSync(dayDir).sort())
128
- add(taskId, path.join(dayDir, taskId), 'archive');
128
+ add(taskId, path.join(dayDir, taskId, 'local'), 'archive');
129
129
  }
130
130
  }
131
131
  }
@@ -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
  import { isValidAgentInfraVersion, VERSION } from '../version.js';
4
5
  import { parseTypedTaskFrontmatter, updateTaskFrontmatter } from './frontmatter.js';
5
6
  import { resolveTaskRef } from './resolve-ref.js';
@@ -53,7 +54,7 @@ function errorDetails(error, fallback) {
53
54
  message: error instanceof Error ? error.message : String(error)
54
55
  };
55
56
  }
56
- function writeTask(request, options = {}) {
57
+ function writeTaskCore(request, options = {}) {
57
58
  const resolved = options.taskLocation
58
59
  ? {
59
60
  ok: true,
@@ -258,5 +259,124 @@ function writeTask(request, options = {}) {
258
259
  }
259
260
  return { ...successBase, status: 'applied', changed: true };
260
261
  }
262
+ function archiveContentsHash(localDir) {
263
+ const files = [];
264
+ const walk = (dir) => {
265
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
266
+ if (entry.name.includes('\n') || entry.name.includes('\r') || entry.name === 'manifest.md') {
267
+ throw new Error(`Archive source path is not valid: ${path.join(dir, entry.name)}`);
268
+ }
269
+ const target = path.join(dir, entry.name);
270
+ const stat = fs.lstatSync(target);
271
+ if (stat.isSymbolicLink())
272
+ throw new Error(`Archive source cannot contain symbolic links: ${target}`);
273
+ if (stat.isDirectory())
274
+ walk(target);
275
+ else if (stat.isFile()) {
276
+ if (entry.name !== 'contents.sha256')
277
+ files.push(target);
278
+ }
279
+ else
280
+ throw new Error(`Archive source cannot contain special files: ${target}`);
281
+ }
282
+ };
283
+ walk(localDir);
284
+ const rows = files
285
+ .map((file) => ({ file, relative: path.relative(localDir, file).split(path.sep).join('/') }))
286
+ .sort((left, right) => Buffer.compare(Buffer.from(left.relative), Buffer.from(right.relative)))
287
+ .map(({ file, relative }) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${relative}`);
288
+ return `${rows.join('\n')}${rows.length ? '\n' : ''}`;
289
+ }
290
+ function writeArchiveContentsHash(localDir) {
291
+ const contents = archiveContentsHash(localDir);
292
+ const hashPath = path.join(localDir, 'contents.sha256');
293
+ const tempPath = path.join(localDir, `.contents.sha256.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
294
+ try {
295
+ fs.writeFileSync(tempPath, contents, { flag: 'wx' });
296
+ fs.renameSync(tempPath, hashPath);
297
+ }
298
+ catch (error) {
299
+ try {
300
+ fs.unlinkSync(tempPath);
301
+ }
302
+ catch (cleanupError) {
303
+ if (cleanupError.code !== 'ENOENT') {
304
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
305
+ }
306
+ }
307
+ throw error;
308
+ }
309
+ }
310
+ function restoreArchivedTask(taskMdPath, contents, mode) {
311
+ const tempPath = `${taskMdPath}.${process.pid}.${Math.random().toString(36).slice(2)}.rollback`;
312
+ fs.writeFileSync(tempPath, contents, { flag: 'wx', mode });
313
+ try {
314
+ fs.renameSync(tempPath, taskMdPath);
315
+ }
316
+ catch (error) {
317
+ try {
318
+ fs.unlinkSync(tempPath);
319
+ }
320
+ catch (cleanupError) {
321
+ if (cleanupError.code !== 'ENOENT') {
322
+ throw new Error(`${error instanceof Error ? error.message : String(error)}; failed to remove ${tempPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
323
+ }
324
+ }
325
+ throw error;
326
+ }
327
+ }
328
+ function writeTask(request, options = {}) {
329
+ const resolved = options.taskLocation
330
+ ? {
331
+ ok: true,
332
+ repoRoot: options.taskLocation.repoRoot,
333
+ taskId: options.taskLocation.taskId,
334
+ taskDir: path.dirname(options.taskLocation.taskMdPath),
335
+ taskMdPath: options.taskLocation.taskMdPath,
336
+ state: options.taskLocation.state
337
+ }
338
+ : resolveTaskRef(request.taskRef, { repoRoot: options.repoRoot });
339
+ if (!resolved.ok) {
340
+ return failure(request, { taskId: resolved.taskId, taskMdPath: null, actualState: null }, resolved.code, resolved.message);
341
+ }
342
+ if (resolved.state !== 'archive' || request.expectedState !== 'archive' || request.dryRun) {
343
+ return writeTaskCore(request, { ...options, taskLocation: {
344
+ repoRoot: resolved.repoRoot,
345
+ taskId: resolved.taskId,
346
+ taskMdPath: resolved.taskMdPath,
347
+ state: resolved.state
348
+ } });
349
+ }
350
+ const identity = { taskId: resolved.taskId, taskMdPath: resolved.taskMdPath, actualState: resolved.state };
351
+ try {
352
+ const originalTask = fs.readFileSync(resolved.taskMdPath);
353
+ const originalMode = fs.statSync(resolved.taskMdPath).mode;
354
+ archiveContentsHash(resolved.taskDir);
355
+ const result = writeTaskCore(request, { ...options, taskLocation: {
356
+ repoRoot: resolved.repoRoot,
357
+ taskId: resolved.taskId,
358
+ taskMdPath: resolved.taskMdPath,
359
+ state: resolved.state
360
+ } });
361
+ if (result.status === 'applied') {
362
+ try {
363
+ writeArchiveContentsHash(resolved.taskDir);
364
+ }
365
+ catch (error) {
366
+ try {
367
+ restoreArchivedTask(resolved.taskMdPath, originalTask, originalMode);
368
+ }
369
+ catch (rollbackError) {
370
+ throw new Error(`Archive checksum update failed: ${error instanceof Error ? error.message : String(error)}; task rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
371
+ }
372
+ throw error;
373
+ }
374
+ }
375
+ return result;
376
+ }
377
+ catch (error) {
378
+ return failure(request, identity, 'TASK_READ_FAILED', error instanceof Error ? error.message : String(error));
379
+ }
380
+ }
261
381
  export { writeTask, captureTaskWriteMetadata, canonicalTimestamp };
262
382
  //# sourceMappingURL=write.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "protocolVersion": 3,
3
- "packageVersion": "0.11.5",
4
- "sourceInputHash": "2f29b7f1f134733c3bc1bb7c92ea6c11e57462ed51a5a96b0198b8824a3f455d",
5
- "internalExecutableBuildHash": "cb9d00aa3fa974cf936a8aad310e1fa5f28842351f52a53fed43345fe9c46439"
3
+ "packageVersion": "0.11.6",
4
+ "sourceInputHash": "402048e73650c5ac4edfb1bc4b48ca3fceb7b9068790b1321281c4994121e80c",
5
+ "internalExecutableBuildHash": "47ecd04ac30183443b2b86f83d1668b8bdf9e59162cc9e2cedd333d3011843e9"
6
6
  }
package/lib/merge.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
  import { removeDirRecursive } from './remove-dir.ts';
4
5
  import { validateCurrentTaskContract } from './task/current-contract.ts';
5
6
 
@@ -107,7 +108,7 @@ function extractTitle(content: string): string | null {
107
108
  }
108
109
 
109
110
  function normalizeTaskRecord(taskDir: string, taskFile: string, dateParts: DateParts): TaskRecord {
110
- const taskId = path.basename(taskDir);
111
+ const taskId = path.basename(taskDir) === 'local' ? path.basename(path.dirname(taskDir)) : path.basename(taskDir);
111
112
  const content = fs.readFileSync(taskFile, 'utf8');
112
113
  const completedAt = extractField(content, 'completed_at');
113
114
  const updatedAt = extractField(content, 'updated_at');
@@ -155,7 +156,7 @@ function scanSourceTasks(sourceDir: string): TaskRecord[] {
155
156
  continue;
156
157
  }
157
158
 
158
- const taskDir = path.join(dayDir, taskEntry.name);
159
+ const taskDir = path.join(dayDir, taskEntry.name, 'local');
159
160
  const taskFile = path.join(taskDir, 'task.md');
160
161
  if (!fs.existsSync(taskFile)) {
161
162
  continue;
@@ -251,7 +252,7 @@ function collectArchiveEntries(archiveDir: string): ArchiveManifestEntry[] {
251
252
  }
252
253
 
253
254
  const taskDir = path.join(dayDir, taskEntry.name);
254
- const taskFile = path.join(taskDir, 'task.md');
255
+ const taskFile = path.join(taskDir, 'local', 'task.md');
255
256
  const relativePath = `${yearEntry.name}/${monthEntry.name}/${dayEntry.name}/${taskEntry.name}/`;
256
257
  let title = taskEntry.name;
257
258
  let type = 'unknown';
@@ -624,10 +625,12 @@ function validateSourceRoot(sourceRoot: string): void {
624
625
  function validateTaskDirectory(
625
626
  sourceRoot: string,
626
627
  taskDir: string,
627
- seenTaskIds: Map<string, string>
628
+ seenTaskIds: Map<string, string>,
629
+ archived = false
628
630
  ): void {
629
631
  const taskId = path.basename(taskDir);
630
- const taskFile = path.join(taskDir, 'task.md');
632
+ const contentDir = archived ? path.join(taskDir, 'local') : taskDir;
633
+ const taskFile = path.join(contentDir, 'task.md');
631
634
  const taskDirStat = sourcePathStat(sourceRoot, taskDir);
632
635
  if (!taskDirStat || !taskDirStat.isDirectory()) {
633
636
  invalidSource(`${sourceRelativePath(sourceRoot, taskDir)} must be a directory`);
@@ -646,6 +649,33 @@ function validateTaskDirectory(
646
649
  invalidSource(`${sourceRelativePath(sourceRoot, taskFile)} id '${String(contract.metadata.id ?? 'missing')}' does not match directory '${taskId}'`);
647
650
  }
648
651
  validateTaskTree(sourceRoot, taskDir);
652
+ if (archived) {
653
+ for (const entry of fs.readdirSync(taskDir, { withFileTypes: true })) {
654
+ if (!['local', 'github', 'derived'].includes(entry.name) || !entry.isDirectory()) {
655
+ invalidSource(`${sourceRelativePath(sourceRoot, path.join(taskDir, entry.name))} is not an allowed archive source directory`);
656
+ }
657
+ }
658
+ validateArchiveSource(sourceRoot, contentDir);
659
+ const githubDir = path.join(taskDir, 'github');
660
+ if (fs.existsSync(githubDir)) {
661
+ for (const capture of fs.readdirSync(githubDir, { withFileTypes: true })) {
662
+ const captureDir = path.join(githubDir, capture.name);
663
+ if (!capture.isDirectory() || !/^capture-[A-Za-z0-9._-]+$/.test(capture.name) || !fs.existsSync(path.join(captureDir, 'capture.md'))) {
664
+ invalidSource(`${sourceRelativePath(sourceRoot, captureDir)} is not a valid GitHub capture`);
665
+ }
666
+ validateArchiveSource(sourceRoot, captureDir);
667
+ }
668
+ }
669
+ const derivedDir = path.join(taskDir, 'derived');
670
+ if (fs.existsSync(derivedDir)) {
671
+ for (const view of fs.readdirSync(derivedDir, { withFileTypes: true })) {
672
+ const viewDir = path.join(derivedDir, view.name);
673
+ if (!view.isDirectory() || !fs.existsSync(path.join(viewDir, 'inputs.md'))) {
674
+ invalidSource(`${sourceRelativePath(sourceRoot, viewDir)} requires inputs.md`);
675
+ }
676
+ }
677
+ }
678
+ }
649
679
 
650
680
  const previousPath = seenTaskIds.get(taskId);
651
681
  if (previousPath) {
@@ -654,6 +684,26 @@ function validateTaskDirectory(
654
684
  seenTaskIds.set(taskId, sourceRelativePath(sourceRoot, taskDir));
655
685
  }
656
686
 
687
+ function validateArchiveSource(sourceRoot: string, sourceDir: string): void {
688
+ const hashFile = path.join(sourceDir, 'contents.sha256');
689
+ const hashStat = sourcePathStat(sourceRoot, hashFile);
690
+ if (!hashStat?.isFile()) invalidSource(`${sourceRelativePath(sourceRoot, sourceDir)} requires contents.sha256`);
691
+ const files: string[] = [];
692
+ const walk = (dir: string) => {
693
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
694
+ const target = path.join(dir, entry.name);
695
+ if (entry.name === 'manifest.md') invalidSource(`${sourceRelativePath(sourceRoot, target)} is forbidden inside a TASK`);
696
+ if (entry.isDirectory()) walk(target);
697
+ else if (entry.isFile() && target !== hashFile) files.push(target);
698
+ }
699
+ };
700
+ walk(sourceDir);
701
+ const expected = files.sort((a, b) => Buffer.compare(Buffer.from(path.relative(sourceDir, a).split(path.sep).join('/')), Buffer.from(path.relative(sourceDir, b).split(path.sep).join('/'))))
702
+ .map((file) => `${crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')} ${path.relative(sourceDir, file).split(path.sep).join('/')}`);
703
+ const actual = fs.readFileSync(hashFile, 'utf8').trimEnd().split(/\r?\n/).filter(Boolean);
704
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) invalidSource(`${sourceRelativePath(sourceRoot, hashFile)} does not match local files`);
705
+ }
706
+
657
707
  function validateMutableSection(
658
708
  sourceRoot: string,
659
709
  section: MutableSection,
@@ -730,9 +780,6 @@ function validateArchiveSection(
730
780
  const monthDir = monthPath;
731
781
  for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
732
782
  const dayPath = path.join(monthDir, dayEntry.name);
733
- if (allowManifest(monthDir, dayEntry)) {
734
- continue;
735
- }
736
783
  const dayStat = sourcePathStat(sourceRoot, dayPath);
737
784
  if (!dayStat?.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
738
785
  invalidSource(`${sourceRelativePath(sourceRoot, dayPath)} is not a DD directory or manifest.md`);
@@ -745,7 +792,7 @@ function validateArchiveSection(
745
792
  if (!taskStat?.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
746
793
  invalidSource(`${sourceRelativePath(sourceRoot, taskPath)} is not a TASK-YYYYMMDD-HHMMSS directory`);
747
794
  }
748
- validateTaskDirectory(sourceRoot, taskPath, seenTaskIds);
795
+ validateTaskDirectory(sourceRoot, taskPath, seenTaskIds, true);
749
796
  }
750
797
  }
751
798
  }
@@ -904,9 +951,10 @@ function mergeArchiveSection(sourceArchive: string, localArchive: string, report
904
951
  continue;
905
952
  }
906
953
 
954
+ const sourceTaskDir = path.dirname(task.taskDir);
907
955
  const destinationDir = path.join(localArchive, task.relativePath);
908
956
  fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
909
- fs.cpSync(task.taskDir, destinationDir, { recursive: true });
957
+ fs.cpSync(sourceTaskDir, destinationDir, { recursive: true });
910
958
  recordArchive(report, 'copied', {
911
959
  action: 'copied',
912
960
  symbol: '✓',
@@ -1045,6 +1093,7 @@ async function cmdMerge(args: string[]): Promise<void> {
1045
1093
  const report = createReport(resolvedSource, backupRootRelative);
1046
1094
  detectSourceMode(resolvedSource);
1047
1095
  validateSourceWorkspace(resolvedSource);
1096
+ validateArchiveSection(workspaceDir, new Map());
1048
1097
 
1049
1098
  for (const section of ALL_SECTIONS) {
1050
1099
  fs.mkdirSync(path.join(workspaceDir, section), { recursive: true });