taskchef 8.4.2 → 8.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.js CHANGED
@@ -70,7 +70,7 @@ const FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION = 7;
70
70
  const INTERRUPTED_TURN_TASK_SCHEMA_VERSION = 8;
71
71
  const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
72
72
  const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
73
- const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard"]);
73
+ const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard", "projectIndex"]);
74
74
  const DASHBOARD_CONFIG_FIELDS = new Set(["autostart"]);
75
75
  const PROJECT_FIELDS = new Set([
76
76
  "name",
@@ -80,6 +80,17 @@ const PROJECT_FIELDS = new Set([
80
80
  "description",
81
81
  ]);
82
82
  const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
83
+ const PROJECT_INDEX_FIELDS = new Set(["bindings", "exclusions", "routingHints"]);
84
+ const PROJECT_BINDING_FIELDS = new Set(["hostId", "projectId", "path"]);
85
+ const ROUTING_HINT_FIELDS = new Set(["path", "aliases", "githubRepos", "responsibilities", "forgotten"]);
86
+ const ROUTING_FACT_FIELDS = new Set(["value", "provenance"]);
87
+ const FORGOTTEN_FACT_FIELDS = new Set(["kind", "value"]);
88
+ const ROUTING_PROVENANCE = new Set(["explicit_user", "verified_repository", "accepted_terminal_report"]);
89
+ const ROUTING_PROVENANCE_FIELDS = new Set([
90
+ "kind", "evidence", "taskId", "threadId", "turnRef", "repositoryPath",
91
+ ]);
92
+ const ROUTING_HINT_KINDS = new Set(["alias", "githubRepo", "responsibility"]);
93
+ const MAX_ROUTING_HINTS = 128;
83
94
  const STATEFUL_DISPATCH_FIELDS = new Set([
84
95
  "schemaVersion",
85
96
  "id",
@@ -521,6 +532,150 @@ async function normalizeProjects(
521
532
  return normalized;
522
533
  }
523
534
 
535
+ function normalizedAbsolutePath(value, name) {
536
+ const normalized = requireString(value, name).trim();
537
+ if (!path.isAbsolute(normalized) || path.normalize(normalized) !== normalized) {
538
+ throw new Error(`${name} must be a normalized absolute path`);
539
+ }
540
+ return normalized;
541
+ }
542
+
543
+ function normalizeRoutingFacts(value, name, { kind, maxCount, maxLength }) {
544
+ if (!Array.isArray(value) || value.length > maxCount) {
545
+ throw new Error(`${name} must be an array with at most ${maxCount} entries`);
546
+ }
547
+ const seen = new Set();
548
+ return value.map((fact, index) => {
549
+ requireExactFields(fact, ROUTING_FACT_FIELDS, `${name}[${index}]`);
550
+ let normalized = requireString(fact.value, `${name}[${index}].value`).trim();
551
+ if (normalized.length > maxLength) throw new Error(`${name}[${index}].value is too long`);
552
+ if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${name}[${index}].value`);
553
+ requireExactFields(fact.provenance, ROUTING_PROVENANCE_FIELDS, `${name}[${index}].provenance`);
554
+ const provenanceKind = requireEnum(
555
+ fact.provenance.kind,
556
+ ROUTING_PROVENANCE,
557
+ `${name}[${index}].provenance.kind`,
558
+ );
559
+ const provenance = {
560
+ kind: provenanceKind,
561
+ evidence: requireString(fact.provenance.evidence, `${name}[${index}].provenance.evidence`).trim(),
562
+ taskId: fact.provenance.taskId === null
563
+ ? null : requireSafeId(fact.provenance.taskId, `${name}[${index}].provenance.taskId`),
564
+ threadId: fact.provenance.threadId === null
565
+ ? null : normalizeDurableThreadId(fact.provenance.threadId, `${name}[${index}].provenance.threadId`),
566
+ turnRef: fact.provenance.turnRef === null
567
+ ? null : normalizeTurnRef(fact.provenance.turnRef, `${name}[${index}].provenance.turnRef`),
568
+ repositoryPath: fact.provenance.repositoryPath === null
569
+ ? null : normalizedAbsolutePath(
570
+ fact.provenance.repositoryPath,
571
+ `${name}[${index}].provenance.repositoryPath`,
572
+ ),
573
+ };
574
+ if (provenance.evidence.length > 240) throw new Error(`${name}[${index}].provenance.evidence is too long`);
575
+ if (provenanceKind === "accepted_terminal_report"
576
+ && [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part === null)) {
577
+ throw new Error(`${name}[${index}].provenance requires taskId, threadId, and turnRef`);
578
+ }
579
+ if (provenanceKind !== "accepted_terminal_report"
580
+ && [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part !== null)) {
581
+ throw new Error(`${name}[${index}].provenance task identity is only valid for accepted reports`);
582
+ }
583
+ const item = {
584
+ value: normalized,
585
+ provenance,
586
+ };
587
+ const key = item.value.toLowerCase();
588
+ if (seen.has(key)) throw new Error(`${name} must not contain duplicate values`);
589
+ seen.add(key);
590
+ return item;
591
+ });
592
+ }
593
+
594
+ function normalizeProjectIndex(value) {
595
+ requireExactFields(value, PROJECT_INDEX_FIELDS, "taskchef.json.projectIndex");
596
+ if (!Array.isArray(value.bindings) || !Array.isArray(value.exclusions)
597
+ || !Array.isArray(value.routingHints)) {
598
+ throw new Error("taskchef.json.projectIndex lists must be arrays");
599
+ }
600
+ if (value.routingHints.length > MAX_ROUTING_HINTS) {
601
+ throw new Error(`taskchef.json.projectIndex.routingHints must have at most ${MAX_ROUTING_HINTS} entries`);
602
+ }
603
+ const bindings = value.bindings.map((binding, index) => {
604
+ const name = `taskchef.json.projectIndex.bindings[${index}]`;
605
+ requireExactFields(binding, PROJECT_BINDING_FIELDS, name);
606
+ return {
607
+ hostId: requireEnum(binding.hostId, new Set(["local"]), `${name}.hostId`),
608
+ projectId: requireString(binding.projectId, `${name}.projectId`).trim(),
609
+ path: normalizedAbsolutePath(binding.path, `${name}.path`),
610
+ };
611
+ });
612
+ const exclusions = value.exclusions.map((entry, index) => {
613
+ const name = `taskchef.json.projectIndex.exclusions[${index}]`;
614
+ requireExactFields(entry, PROJECT_BINDING_FIELDS, name);
615
+ return {
616
+ hostId: requireEnum(entry.hostId, new Set(["local"]), `${name}.hostId`),
617
+ projectId: entry.projectId === null
618
+ ? null : requireString(entry.projectId, `${name}.projectId`).trim(),
619
+ path: normalizedAbsolutePath(entry.path, `${name}.path`),
620
+ };
621
+ });
622
+ const routingHints = value.routingHints.map((hint, index) => {
623
+ const name = `taskchef.json.projectIndex.routingHints[${index}]`;
624
+ requireExactFields(hint, ROUTING_HINT_FIELDS, name);
625
+ if (!Array.isArray(hint.forgotten) || hint.forgotten.length > 32) {
626
+ throw new Error(`${name}.forgotten must be an array with at most 32 entries`);
627
+ }
628
+ const forgotten = hint.forgotten.map((fact, factIndex) => {
629
+ const factName = `${name}.forgotten[${factIndex}]`;
630
+ requireExactFields(fact, FORGOTTEN_FACT_FIELDS, factName);
631
+ const kind = requireEnum(fact.kind, ROUTING_HINT_KINDS, `${factName}.kind`);
632
+ let normalized = requireString(fact.value, `${factName}.value`).trim();
633
+ if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${factName}.value`);
634
+ return { kind, value: normalized };
635
+ });
636
+ return {
637
+ path: normalizedAbsolutePath(hint.path, `${name}.path`),
638
+ aliases: normalizeRoutingFacts(hint.aliases, `${name}.aliases`, {
639
+ kind: "alias", maxCount: 16, maxLength: 80,
640
+ }),
641
+ githubRepos: normalizeRoutingFacts(hint.githubRepos, `${name}.githubRepos`, {
642
+ kind: "githubRepo", maxCount: 16, maxLength: 256,
643
+ }),
644
+ responsibilities: normalizeRoutingFacts(hint.responsibilities, `${name}.responsibilities`, {
645
+ kind: "responsibility", maxCount: 8, maxLength: 240,
646
+ }),
647
+ forgotten,
648
+ };
649
+ });
650
+ for (const [name, entries] of [["bindings", bindings], ["exclusions", exclusions]]) {
651
+ const ids = entries.map((entry) => `${entry.hostId}\0${entry.projectId ?? `path:${entry.path}`}`);
652
+ if (new Set(ids).size !== ids.length) throw new Error(`projectIndex ${name} must not contain duplicate native IDs`);
653
+ }
654
+ if (new Set(bindings.map((entry) => entry.path)).size !== bindings.length) {
655
+ throw new Error("projectIndex bindings must not contain duplicate paths");
656
+ }
657
+ if (new Set(exclusions.map((entry) => entry.path)).size !== exclusions.length) {
658
+ throw new Error("projectIndex exclusions must not contain duplicate paths");
659
+ }
660
+ if (new Set(routingHints.map((entry) => entry.path)).size !== routingHints.length) {
661
+ throw new Error("projectIndex routingHints must not contain duplicate paths");
662
+ }
663
+ return { bindings, exclusions, routingHints };
664
+ }
665
+
666
+ function emptyProjectIndex() {
667
+ return { bindings: [], exclusions: [], routingHints: [] };
668
+ }
669
+
670
+ function projectIndexForProjects(projectIndex, projects) {
671
+ const paths = new Set(projects.map((project) => project.path));
672
+ return {
673
+ bindings: projectIndex.bindings.filter((entry) => paths.has(entry.path)),
674
+ exclusions: projectIndex.exclusions,
675
+ routingHints: projectIndex.routingHints.filter((entry) => paths.has(entry.path)),
676
+ };
677
+ }
678
+
524
679
  function normalizeGithubRemote(remote) {
525
680
  if (typeof remote !== "string" || remote.trim().length === 0) return null;
526
681
  try {
@@ -615,10 +770,24 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
615
770
  }
616
771
  dashboard = { autostart: config.dashboard.autostart };
617
772
  }
773
+ const projectIndex = "projectIndex" in config
774
+ ? normalizeProjectIndex(config.projectIndex)
775
+ : null;
776
+ const projects = await normalizeProjects(config.projects, { checkPaths });
777
+ if (projectIndex) {
778
+ const projectPaths = new Set(projects.map((project) => project.path));
779
+ if (projectIndex.bindings.some((entry) => !projectPaths.has(entry.path))) {
780
+ throw new Error("projectIndex binding path must identify a configured project");
781
+ }
782
+ if (projectIndex.routingHints.some((entry) => !projectPaths.has(entry.path))) {
783
+ throw new Error("projectIndex routing hint path must identify a configured project");
784
+ }
785
+ }
618
786
  return {
619
787
  schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
620
- projects: await normalizeProjects(config.projects, { checkPaths }),
788
+ projects,
621
789
  ...(dashboard ? { dashboard } : {}),
790
+ ...(projectIndex ? { projectIndex } : {}),
622
791
  };
623
792
  }
624
793
 
@@ -724,11 +893,17 @@ export async function readProjectIndex(workspaceRoot, { checkPaths = true } = {}
724
893
  configHash: configHash(config),
725
894
  projectSetHash: projectSetHash(config.projects),
726
895
  projects,
896
+ ...(config.projectIndex ? { projectIndex: config.projectIndex } : {}),
727
897
  };
728
898
  }
729
899
 
730
900
  function mutationPreview(root, operation, current, updated) {
731
901
  const diff = projectDiff(current.projects, updated.projects);
902
+ const beforeIndex = current.projectIndex ?? emptyProjectIndex();
903
+ const afterIndex = updated.projectIndex ?? emptyProjectIndex();
904
+ const projectIndexDiff = JSON.stringify(beforeIndex) === JSON.stringify(afterIndex)
905
+ ? null
906
+ : { before: beforeIndex, after: afterIndex };
732
907
  const preview = {
733
908
  schemaVersion: 1,
734
909
  operation,
@@ -741,6 +916,7 @@ function mutationPreview(root, operation, current, updated) {
741
916
  beforeProjects: current.projects,
742
917
  afterProjects: updated.projects,
743
918
  diff,
919
+ projectIndexDiff,
744
920
  };
745
921
  return {
746
922
  ...preview,
@@ -883,12 +1059,475 @@ async function commitConfigMutation(root, operation, current, updated, preview,
883
1059
  return { transactionId, backupId: backup.id, backupDeduplicated: backup.deduplicated };
884
1060
  }
885
1061
 
1062
+ function validateNativeProjectSnapshot(snapshot) {
1063
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
1064
+ || snapshot.schemaVersion !== 2 || !Array.isArray(snapshot.projects)) {
1065
+ throw new Error("native project snapshot must be a schemaVersion 2 object with a projects array");
1066
+ }
1067
+ return snapshot.projects.map((project, index) => {
1068
+ const name = `native projects[${index}]`;
1069
+ if (!project || typeof project !== "object" || Array.isArray(project)) {
1070
+ throw new Error(`${name} must be an object`);
1071
+ }
1072
+ for (const field of ["projectId", "projectKind", "label"]) {
1073
+ if (!(field in project)) throw new Error(`${name} is missing field: ${field}`);
1074
+ }
1075
+ const normalized = {
1076
+ projectId: requireString(project.projectId, `${name}.projectId`).trim(),
1077
+ projectKind: requireString(project.projectKind, `${name}.projectKind`).trim(),
1078
+ label: requireString(project.label, `${name}.label`).trim(),
1079
+ path: typeof project.path === "string" ? project.path.trim() : null,
1080
+ hostId: typeof project.hostId === "string" ? project.hostId.trim() : null,
1081
+ hostDisplayName: typeof project.hostDisplayName === "string"
1082
+ ? project.hostDisplayName.trim() : null,
1083
+ isGitRepository: typeof project.isGitRepository === "boolean"
1084
+ ? project.isGitRepository : null,
1085
+ };
1086
+ if (normalized.projectKind === "local"
1087
+ && (normalized.path === null || normalized.path.length === 0
1088
+ || normalized.hostId === null || normalized.hostId.length === 0
1089
+ || normalized.isGitRepository === null)) {
1090
+ throw new Error(`${name} local project requires path, hostId, and isGitRepository`);
1091
+ }
1092
+ return normalized;
1093
+ });
1094
+ }
1095
+
1096
+ function uniqueProjectName(preferred, projects) {
1097
+ const base = preferred.trim();
1098
+ const names = new Set(projects.map((project) => project.name.toLowerCase()));
1099
+ if (!names.has(base.toLowerCase())) return base;
1100
+ for (let suffix = 2; suffix < 10_000; suffix += 1) {
1101
+ const candidate = `${base} (${suffix})`;
1102
+ if (!names.has(candidate.toLowerCase())) return candidate;
1103
+ }
1104
+ throw new Error(`could not allocate a unique project name for ${base}`);
1105
+ }
1106
+
1107
+ function routingView(config) {
1108
+ return (config.projectIndex?.routingHints ?? []).map((hint) => ({
1109
+ path: hint.path,
1110
+ aliases: hint.aliases.map((fact) => fact.value),
1111
+ githubRepos: hint.githubRepos.map((fact) => fact.value),
1112
+ responsibilities: hint.responsibilities.map((fact) => fact.value),
1113
+ }));
1114
+ }
1115
+
1116
+ export async function reconcileProjects(workspaceRoot, nativeSnapshot, options = {}) {
1117
+ const nativeProjects = validateNativeProjectSnapshot(nativeSnapshot);
1118
+ const root = await realpath(path.resolve(workspaceRoot));
1119
+ return withWorkspaceLock(root, async () => {
1120
+ const current = await readConfig(root, { checkPaths: false });
1121
+ const storedIndex = current.projectIndex ?? emptyProjectIndex();
1122
+ const diagnostics = [];
1123
+ const candidates = [];
1124
+ for (const native of nativeProjects) {
1125
+ if (native.projectKind !== "local" || native.hostId !== "local") {
1126
+ diagnostics.push({
1127
+ projectId: native.projectId,
1128
+ code: "ineligible-host",
1129
+ message: "Only local projects on the active local execution host are eligible.",
1130
+ });
1131
+ continue;
1132
+ }
1133
+ try {
1134
+ const canonicalPath = await canonicalDirectory(native.path);
1135
+ assertWorkspaceOutsideProject(root, canonicalPath);
1136
+ candidates.push({ ...native, path: canonicalPath });
1137
+ } catch (error) {
1138
+ diagnostics.push({
1139
+ projectId: native.projectId,
1140
+ code: /own delegation project|inside a delegation project/.test(error.message)
1141
+ ? "workspace-excluded" : "unavailable-path",
1142
+ message: error.message,
1143
+ });
1144
+ }
1145
+ }
1146
+ const storedPaths = new Set([
1147
+ ...current.projects.map((project) => project.path),
1148
+ ...storedIndex.bindings.map((entry) => entry.path),
1149
+ ...storedIndex.exclusions.map((entry) => entry.path),
1150
+ ...storedIndex.routingHints.map((entry) => entry.path),
1151
+ ]);
1152
+ const resolvedPaths = new Map();
1153
+ for (const storedPath of storedPaths) {
1154
+ resolvedPaths.set(storedPath, await canonicalDirectory(storedPath).catch(() => null));
1155
+ }
1156
+ const configuredCanonicalCounts = new Map();
1157
+ const candidatePaths = new Set(candidates.map((candidate) => candidate.path));
1158
+ for (const project of current.projects) {
1159
+ const canonicalPath = resolvedPaths.get(project.path);
1160
+ if (canonicalPath) {
1161
+ configuredCanonicalCounts.set(
1162
+ canonicalPath,
1163
+ (configuredCanonicalCounts.get(canonicalPath) ?? 0) + 1,
1164
+ );
1165
+ }
1166
+ }
1167
+ const canCanonicalize = (storedPath, canonicalPath) => (
1168
+ storedPath === canonicalPath
1169
+ || [storedIndex.bindings, storedIndex.exclusions, storedIndex.routingHints].every((entries) => (
1170
+ !entries.some((entry) => entry.path === canonicalPath && entry.path !== storedPath)
1171
+ ))
1172
+ );
1173
+ const canonicalizedPaths = new Map();
1174
+ for (const project of current.projects) {
1175
+ const canonicalPath = resolvedPaths.get(project.path);
1176
+ if (canonicalPath && candidatePaths.has(canonicalPath)
1177
+ && configuredCanonicalCounts.get(canonicalPath) === 1
1178
+ && canCanonicalize(project.path, canonicalPath)) {
1179
+ canonicalizedPaths.set(project.path, canonicalPath);
1180
+ }
1181
+ }
1182
+ const canonicalizeIndexedPath = (entry) => ({
1183
+ ...entry,
1184
+ path: canonicalizedPaths.get(entry.path) ?? entry.path,
1185
+ });
1186
+ const projects = current.projects.map((project) => ({
1187
+ ...project,
1188
+ path: canonicalizedPaths.get(project.path) ?? project.path,
1189
+ }));
1190
+ const currentIndex = {
1191
+ bindings: storedIndex.bindings.map(canonicalizeIndexedPath),
1192
+ exclusions: storedIndex.exclusions.map(canonicalizeIndexedPath),
1193
+ routingHints: storedIndex.routingHints.map(canonicalizeIndexedPath),
1194
+ };
1195
+ const resolvedStoredPath = (storedPath) => resolvedPaths.get(storedPath) ?? storedPath;
1196
+ const idCounts = new Map();
1197
+ const pathCounts = new Map();
1198
+ for (const candidate of candidates) {
1199
+ const id = `${candidate.hostId}\0${candidate.projectId}`;
1200
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
1201
+ pathCounts.set(candidate.path, (pathCounts.get(candidate.path) ?? 0) + 1);
1202
+ }
1203
+ const bindings = [...currentIndex.bindings];
1204
+ const added = [];
1205
+ const bound = [];
1206
+ const available = [];
1207
+ for (const candidate of candidates) {
1208
+ const id = `${candidate.hostId}\0${candidate.projectId}`;
1209
+ if (idCounts.get(id) > 1 || pathCounts.get(candidate.path) > 1) {
1210
+ diagnostics.push({
1211
+ projectId: candidate.projectId,
1212
+ code: idCounts.get(id) > 1 ? "duplicate-native-id" : "duplicate-canonical-path",
1213
+ message: "Ambiguous native project identity was ignored.",
1214
+ });
1215
+ continue;
1216
+ }
1217
+ const excluded = currentIndex.exclusions.some((entry) => (
1218
+ entry.hostId === candidate.hostId
1219
+ && (entry.projectId === candidate.projectId
1220
+ || resolvedStoredPath(entry.path) === candidate.path)
1221
+ ));
1222
+ if (excluded) {
1223
+ diagnostics.push({
1224
+ projectId: candidate.projectId,
1225
+ code: "explicitly-excluded",
1226
+ message: "Project remains excluded until explicitly included.",
1227
+ });
1228
+ continue;
1229
+ }
1230
+ const idBinding = bindings.find((entry) => (
1231
+ entry.hostId === candidate.hostId && entry.projectId === candidate.projectId
1232
+ ));
1233
+ if (idBinding && resolvedStoredPath(idBinding.path) !== candidate.path) {
1234
+ diagnostics.push({
1235
+ projectId: candidate.projectId,
1236
+ code: "identity-path-conflict",
1237
+ message: `Native identity is still bound to ${idBinding.path}; review the move explicitly.`,
1238
+ });
1239
+ continue;
1240
+ }
1241
+ const pathBindings = bindings.filter(
1242
+ (entry) => resolvedStoredPath(entry.path) === candidate.path,
1243
+ );
1244
+ if (pathBindings.length > 1) {
1245
+ diagnostics.push({
1246
+ projectId: candidate.projectId,
1247
+ code: "ambiguous-configured-path",
1248
+ message: "Multiple configured index paths resolve to this native project.",
1249
+ });
1250
+ continue;
1251
+ }
1252
+ const [pathBinding] = pathBindings;
1253
+ if (pathBinding && (pathBinding.hostId !== candidate.hostId
1254
+ || pathBinding.projectId !== candidate.projectId)) {
1255
+ diagnostics.push({
1256
+ projectId: candidate.projectId,
1257
+ code: "path-identity-conflict",
1258
+ message: "Canonical path is already bound to another native identity.",
1259
+ });
1260
+ continue;
1261
+ }
1262
+ const configuredMatches = projects.filter(
1263
+ (project) => resolvedStoredPath(project.path) === candidate.path,
1264
+ );
1265
+ if (configuredMatches.length > 1) {
1266
+ diagnostics.push({
1267
+ projectId: candidate.projectId,
1268
+ code: "ambiguous-configured-path",
1269
+ message: "Multiple configured project paths resolve to this native project.",
1270
+ });
1271
+ continue;
1272
+ }
1273
+ let [configured] = configuredMatches;
1274
+ if (!configured) {
1275
+ try {
1276
+ configured = await inspectProject({
1277
+ path: candidate.path,
1278
+ name: uniqueProjectName(candidate.label || path.basename(candidate.path), projects),
1279
+ });
1280
+ if (configured.isGitRepository !== candidate.isGitRepository) {
1281
+ throw new Error("native Git repository metadata does not match the canonical path");
1282
+ }
1283
+ projects.push(configured);
1284
+ added.push(configured);
1285
+ } catch (error) {
1286
+ diagnostics.push({
1287
+ projectId: candidate.projectId,
1288
+ code: "inspection-failed",
1289
+ message: error.message,
1290
+ });
1291
+ continue;
1292
+ }
1293
+ }
1294
+ if (!idBinding) {
1295
+ const binding = {
1296
+ hostId: candidate.hostId,
1297
+ projectId: candidate.projectId,
1298
+ path: configured.path,
1299
+ };
1300
+ bindings.push(binding);
1301
+ bound.push(binding);
1302
+ }
1303
+ available.push({
1304
+ hostId: candidate.hostId,
1305
+ projectId: candidate.projectId,
1306
+ path: candidate.path,
1307
+ });
1308
+ }
1309
+ const indexChanged = JSON.stringify(bindings) !== JSON.stringify(currentIndex.bindings);
1310
+ const updated = await validateConfig({
1311
+ ...current,
1312
+ projects,
1313
+ ...(current.projectIndex || indexChanged
1314
+ ? { projectIndex: { ...currentIndex, bindings } }
1315
+ : {}),
1316
+ }, { checkPaths: false });
1317
+ const preview = mutationPreview(root, "project-reconcile", current, updated);
1318
+ const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1319
+ const committed = changed
1320
+ ? await commitConfigMutation(root, "project-reconcile", current, updated, preview, options)
1321
+ : { transactionId: null, backupId: null, backupDeduplicated: false };
1322
+ return {
1323
+ schemaVersion: 1,
1324
+ changed,
1325
+ added,
1326
+ bound,
1327
+ available,
1328
+ diagnostics,
1329
+ projectCount: updated.projects.length,
1330
+ ...preview,
1331
+ ...committed,
1332
+ };
1333
+ });
1334
+ }
1335
+
1336
+ export async function includeProject(workspaceRoot, selector, options = {}) {
1337
+ const root = await realpath(path.resolve(workspaceRoot));
1338
+ return withWorkspaceLock(root, async () => {
1339
+ const current = await readConfig(root, { checkPaths: false });
1340
+ const index = current.projectIndex ?? emptyProjectIndex();
1341
+ const selectedPaths = new Set();
1342
+ if (selector.path) {
1343
+ const requestedPath = path.resolve(requireString(selector.path, "project path").trim());
1344
+ selectedPaths.add(requestedPath);
1345
+ const canonicalPath = await canonicalDirectory(requestedPath).catch(() => null);
1346
+ if (canonicalPath) selectedPaths.add(canonicalPath);
1347
+ }
1348
+ const exclusions = index.exclusions.filter((entry) => !(
1349
+ selectedPaths.has(entry.path)
1350
+ || (selector.projectId && entry.hostId === (selector.hostId ?? "local")
1351
+ && entry.projectId === selector.projectId)
1352
+ ));
1353
+ if (exclusions.length === index.exclusions.length) {
1354
+ return { changed: false, exclusions, configHash: configHash(current) };
1355
+ }
1356
+ const updated = await validateConfig({
1357
+ ...current,
1358
+ projectIndex: { ...index, exclusions },
1359
+ }, { checkPaths: false });
1360
+ const preview = mutationPreview(root, "project-include", current, updated);
1361
+ const committed = await commitConfigMutation(root, "project-include", current, updated, preview, options);
1362
+ return { changed: true, exclusions, ...preview, ...committed };
1363
+ });
1364
+ }
1365
+
1366
+ function routingHintField(kind) {
1367
+ if (kind === "alias") return "aliases";
1368
+ if (kind === "githubRepo") return "githubRepos";
1369
+ return "responsibilities";
1370
+ }
1371
+
1372
+ function emptyRoutingHint(projectPath) {
1373
+ return {
1374
+ path: projectPath,
1375
+ aliases: [],
1376
+ githubRepos: [],
1377
+ responsibilities: [],
1378
+ forgotten: [],
1379
+ };
1380
+ }
1381
+
1382
+ export async function updateProjectHint(workspaceRoot, input, options = {}) {
1383
+ requireExactFields(
1384
+ input,
1385
+ new Set(["action", "project", "kind", "value", "provenance"]),
1386
+ "project hint input",
1387
+ );
1388
+ const action = requireEnum(
1389
+ input.action,
1390
+ new Set(["remember", "correct", "forget"]),
1391
+ "project hint action",
1392
+ );
1393
+ const kind = requireEnum(input.kind, ROUTING_HINT_KINDS, "project hint kind");
1394
+ let value = requireString(input.value, "project hint value").trim();
1395
+ if (kind === "alias" && value.length > 80) throw new Error("project alias must be at most 80 characters");
1396
+ if (kind === "responsibility" && value.length > 240) {
1397
+ throw new Error("project responsibility must be at most 240 characters");
1398
+ }
1399
+ if (kind === "githubRepo") value = canonicalGithubRepository(value, "project hint value");
1400
+ const root = await realpath(path.resolve(workspaceRoot));
1401
+ return withWorkspaceLock(root, async () => {
1402
+ const current = await readConfig(root, { checkPaths: false });
1403
+ const configuredPath = path.resolve(requireString(input.project, "project hint project").trim());
1404
+ const project = current.projects.find((candidate) => candidate.path === configuredPath);
1405
+ if (!project) throw new Error(`configured project not found: ${configuredPath}`);
1406
+ const normalizedFact = normalizeRoutingFacts([{
1407
+ value,
1408
+ provenance: input.provenance,
1409
+ }], "project hint", {
1410
+ kind,
1411
+ maxCount: 1,
1412
+ maxLength: kind === "alias" ? 80 : kind === "responsibility" ? 240 : 256,
1413
+ })[0];
1414
+ const provenance = normalizedFact.provenance;
1415
+ if (action === "correct" && kind !== "alias") {
1416
+ throw new Error("correct is only valid for an explicit alias correction");
1417
+ }
1418
+ if (action === "forget" && provenance.kind !== "explicit_user") {
1419
+ throw new Error("forget requires explicit_user provenance");
1420
+ }
1421
+ if (action !== "forget" && kind === "alias" && provenance.kind !== "explicit_user") {
1422
+ throw new Error("aliases require explicit_user provenance");
1423
+ }
1424
+ if (action !== "forget" && kind === "githubRepo"
1425
+ && !new Set(["verified_repository", "accepted_terminal_report"]).has(provenance.kind)) {
1426
+ throw new Error("repository ownership requires verified repository evidence");
1427
+ }
1428
+ if (action !== "forget" && kind === "responsibility"
1429
+ && provenance.kind !== "accepted_terminal_report") {
1430
+ throw new Error("responsibilities require accepted_terminal_report provenance");
1431
+ }
1432
+ if (action !== "forget" && kind === "githubRepo") {
1433
+ if (provenance.repositoryPath === null) {
1434
+ throw new Error("repository ownership requires an inspected repositoryPath");
1435
+ }
1436
+ const repositoryPath = await canonicalGitRoot(provenance.repositoryPath);
1437
+ const relative = path.relative(project.path, repositoryPath);
1438
+ if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
1439
+ throw new Error("repositoryPath must be the selected project or a contained Git root");
1440
+ }
1441
+ const inspected = await inspectProject({ path: repositoryPath });
1442
+ if (!inspected.githubRepos.some((repository) => repository.toLowerCase() === value.toLowerCase())) {
1443
+ throw new Error("repository hint is not the inspected project's canonical origin");
1444
+ }
1445
+ }
1446
+ if (provenance.kind === "accepted_terminal_report") {
1447
+ const tasks = await readDispatchesUnlocked(root);
1448
+ const task = tasks.find((candidate) => candidate.id === provenance.taskId);
1449
+ const turn = task?.latestTurn;
1450
+ if (!task || task.project.path !== project.path
1451
+ || task.threadId !== provenance.threadId
1452
+ || turn?.turnRef !== provenance.turnRef
1453
+ || !RESULT_STATUSES.has(turn.result?.status)) {
1454
+ throw new Error("project hint provenance is not the accepted current terminal report");
1455
+ }
1456
+ }
1457
+ const projectIndex = current.projectIndex ?? emptyProjectIndex();
1458
+ let hints = projectIndex.routingHints.map((hint) => ({
1459
+ ...hint,
1460
+ aliases: [...hint.aliases],
1461
+ githubRepos: [...hint.githubRepos],
1462
+ responsibilities: [...hint.responsibilities],
1463
+ forgotten: [...hint.forgotten],
1464
+ }));
1465
+ let hint = hints.find((candidate) => candidate.path === project.path);
1466
+ if (!hint) {
1467
+ if (hints.length >= MAX_ROUTING_HINTS) throw new Error("project routing hint capacity is full");
1468
+ hint = emptyRoutingHint(project.path);
1469
+ hints.push(hint);
1470
+ }
1471
+ const field = routingHintField(kind);
1472
+ const key = value.toLowerCase();
1473
+ const forgottenIndex = hint.forgotten.findIndex((fact) => (
1474
+ fact.kind === kind && fact.value.toLowerCase() === key
1475
+ ));
1476
+ let suppressed = false;
1477
+ if (action !== "forget") {
1478
+ if (forgottenIndex !== -1 && provenance.kind !== "explicit_user") {
1479
+ suppressed = true;
1480
+ } else {
1481
+ if (forgottenIndex !== -1) hint.forgotten.splice(forgottenIndex, 1);
1482
+ if (action === "correct" && kind === "alias") {
1483
+ for (const other of hints.filter((candidate) => candidate.path !== project.path)) {
1484
+ const removed = other.aliases.some((fact) => fact.value.toLowerCase() === key);
1485
+ other.aliases = other.aliases.filter((fact) => fact.value.toLowerCase() !== key);
1486
+ if (removed && !other.forgotten.some((fact) => (
1487
+ fact.kind === "alias" && fact.value.toLowerCase() === key
1488
+ ))) {
1489
+ if (other.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
1490
+ other.forgotten.push({ kind: "alias", value });
1491
+ }
1492
+ }
1493
+ }
1494
+ if (!hint[field].some((fact) => fact.value.toLowerCase() === key)) {
1495
+ const limit = kind === "alias" ? 16 : kind === "githubRepo" ? 16 : 8;
1496
+ if (hint[field].length >= limit) throw new Error(`project ${field} capacity is full`);
1497
+ hint[field].push(normalizedFact);
1498
+ }
1499
+ }
1500
+ } else {
1501
+ hint[field] = hint[field].filter((fact) => fact.value.toLowerCase() !== key);
1502
+ if (forgottenIndex === -1) {
1503
+ if (hint.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
1504
+ hint.forgotten.push({ kind, value });
1505
+ }
1506
+ }
1507
+ hints = hints.filter((candidate) => (
1508
+ candidate.aliases.length || candidate.githubRepos.length
1509
+ || candidate.responsibilities.length || candidate.forgotten.length
1510
+ ));
1511
+ const updated = await validateConfig({
1512
+ ...current,
1513
+ projectIndex: { ...projectIndex, routingHints: hints },
1514
+ }, { checkPaths: false });
1515
+ const preview = mutationPreview(root, `project-hint-${action}`, current, updated);
1516
+ const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1517
+ const committed = changed
1518
+ ? await commitConfigMutation(root, `project-hint-${action}`, current, updated, preview, options)
1519
+ : { transactionId: null, backupId: null, backupDeduplicated: false };
1520
+ return { changed, suppressed, project: project.path, kind, value, ...preview, ...committed };
1521
+ });
1522
+ }
1523
+
886
1524
  export async function prepareDispatch(workspaceRoot, {
887
1525
  taskId = randomUUID(),
888
1526
  now = () => new Date().toISOString(),
889
1527
  } = {}) {
890
1528
  const workspace = await realpath(path.resolve(workspaceRoot));
891
- const projects = await listProjects(workspace);
1529
+ const config = await readConfig(workspace, { checkPaths: false });
1530
+ const projects = [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
892
1531
  const marker = taskChefMarker(taskId);
893
1532
  const preparedAt = requireTimestamp(now(), "preparedAt");
894
1533
  return {
@@ -899,6 +1538,7 @@ export async function prepareDispatch(workspaceRoot, {
899
1538
  marker,
900
1539
  projectCount: projects.length,
901
1540
  projects,
1541
+ routingHints: routingView(config),
902
1542
  modelRoles: await resolveModelRoles(),
903
1543
  };
904
1544
  }
@@ -909,9 +1549,14 @@ export async function addProject(workspaceRoot, input, options = {}) {
909
1549
  const config = await readConfig(root, { checkPaths: false });
910
1550
  const project = await inspectProject(input);
911
1551
  assertWorkspaceOutsideProject(root, project.path);
1552
+ const currentIndex = config.projectIndex ?? emptyProjectIndex();
1553
+ const exclusions = currentIndex.exclusions.filter((entry) => entry.path !== project.path);
912
1554
  const updated = await validateConfig({
913
1555
  ...config,
914
1556
  projects: [...config.projects, project],
1557
+ ...(config.projectIndex || exclusions.length !== currentIndex.exclusions.length
1558
+ ? { projectIndex: { ...currentIndex, exclusions } }
1559
+ : {}),
915
1560
  }, { checkPaths: false });
916
1561
  const preview = mutationPreview(root, "project-add", config, updated);
917
1562
  if (options.dryRun) return { changed: true, project, ...preview };
@@ -952,9 +1597,36 @@ export async function importProjects(workspaceRoot, inputs, { replace = false, d
952
1597
  if (index === -1) projects.push(project);
953
1598
  else projects[index] = project;
954
1599
  }
1600
+ const currentIndex = current.projectIndex ?? emptyProjectIndex();
1601
+ const retainedPaths = new Set(projects.map((project) => project.path));
1602
+ const removedProjects = replace
1603
+ ? current.projects.filter((project) => !retainedPaths.has(project.path))
1604
+ : [];
1605
+ const exclusions = [...currentIndex.exclusions];
1606
+ for (const project of removedProjects) {
1607
+ const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
1608
+ const alreadyExcluded = exclusions.some((entry) => (
1609
+ entry.path === project.path
1610
+ || (binding && entry.hostId === binding.hostId && entry.projectId === binding.projectId)
1611
+ ));
1612
+ if (!alreadyExcluded) {
1613
+ exclusions.push({
1614
+ hostId: binding?.hostId ?? "local",
1615
+ projectId: binding?.projectId ?? null,
1616
+ path: project.path,
1617
+ });
1618
+ }
1619
+ }
1620
+ const nextProjectIndex = {
1621
+ ...projectIndexForProjects(currentIndex, projects),
1622
+ exclusions,
1623
+ };
955
1624
  const config = await validateConfig({
956
1625
  ...current,
957
1626
  projects,
1627
+ ...(current.projectIndex || removedProjects.length > 0
1628
+ ? { projectIndex: nextProjectIndex }
1629
+ : {}),
958
1630
  }, { checkPaths: false });
959
1631
  const preview = mutationPreview(root, replace ? "project-replace" : "project-import", current, config);
960
1632
  if (dryRun) {
@@ -994,9 +1666,24 @@ export async function removeProject(workspaceRoot, name, { dryRun = false, ...op
994
1666
  if (index === -1) throw new Error(`configured project not found: ${name}`);
995
1667
  const [project] = config.projects.slice(index, index + 1);
996
1668
  const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
1669
+ const currentIndex = config.projectIndex ?? emptyProjectIndex();
1670
+ const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
1671
+ const exclusion = {
1672
+ hostId: binding?.hostId ?? "local",
1673
+ projectId: binding?.projectId ?? null,
1674
+ path: project.path,
1675
+ };
997
1676
  const updated = await validateConfig({
998
1677
  ...config,
999
1678
  projects,
1679
+ projectIndex: {
1680
+ bindings: currentIndex.bindings.filter((entry) => entry.path !== project.path),
1681
+ exclusions: [
1682
+ ...currentIndex.exclusions.filter((entry) => entry.path !== project.path),
1683
+ exclusion,
1684
+ ],
1685
+ routingHints: currentIndex.routingHints.filter((entry) => entry.path !== project.path),
1686
+ },
1000
1687
  }, { checkPaths: false });
1001
1688
  const preview = mutationPreview(root, "project-remove", config, updated);
1002
1689
  if (dryRun) return { project, changed: true, ...preview };
@@ -1163,7 +1850,15 @@ export async function restoreWorkspaceBackup(workspaceRoot, id, {
1163
1850
  "projects-only restore requires readable current configuration; preview --scope config or state instead",
1164
1851
  );
1165
1852
  }
1166
- const updated = scope === "projects" ? { ...current, projects: stored.projects } : stored;
1853
+ let updated = stored;
1854
+ if (scope === "projects") {
1855
+ const { projectIndex: _currentIndex, ...currentWithoutIndex } = current;
1856
+ updated = await validateConfig({
1857
+ ...currentWithoutIndex,
1858
+ projects: stored.projects,
1859
+ ...(stored.projectIndex ? { projectIndex: stored.projectIndex } : {}),
1860
+ }, { checkPaths: false });
1861
+ }
1167
1862
  const beforeStateHash = current
1168
1863
  ? configHash(current)
1169
1864
  : currentPresent ? `unreadable:${sha256(currentBytes)}` : "missing";
@@ -2114,10 +2809,25 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
2114
2809
  requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
2115
2810
  const root = await realpath(path.resolve(workspaceRoot));
2116
2811
  return withWorkspaceLock(root, async () => {
2117
- const config = await readConfig(root);
2118
- const projectPath = await canonicalDirectory(input.project);
2119
- const project = config.projects.find((candidate) => candidate.path === projectPath);
2120
- if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
2812
+ const config = await readConfig(root, { checkPaths: false });
2813
+ const requested = requireString(input.project, "task input.project").trim();
2814
+ const requestedPath = path.resolve(requested);
2815
+ let configured = config.projects.find((candidate) => candidate.path === requestedPath);
2816
+ if (!configured) {
2817
+ const canonicalRequested = await canonicalDirectory(requested);
2818
+ for (const candidate of config.projects) {
2819
+ const canonicalCandidate = await realpath(candidate.path).catch(() => null);
2820
+ if (canonicalCandidate === canonicalRequested) {
2821
+ configured = candidate;
2822
+ break;
2823
+ }
2824
+ }
2825
+ }
2826
+ if (!configured) throw new Error(`project is not configured in taskchef.json: ${requestedPath}`);
2827
+ const projectPath = configured.isGitRepository
2828
+ ? await canonicalGitRoot(requested)
2829
+ : await canonicalDirectory(requested);
2830
+ const project = { ...configured, path: projectPath };
2121
2831
  const createdAt = now ?? new Date().toISOString();
2122
2832
  const dispatch = await validateDispatchShape({
2123
2833
  schemaVersion: CURRENT_TASK_SCHEMA_VERSION,