@zq-silk/yui 0.6.13 → 0.6.14

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 (90) hide show
  1. package/README.md +34 -5
  2. package/dist/cli/commandCatalog.js +173 -57
  3. package/dist/cli/helpRenderer.js +3 -1
  4. package/dist/cli.js +99 -11
  5. package/dist/commands/configCommands.js +521 -171
  6. package/dist/commands/deliveryGuardPreflight.js +2 -2
  7. package/dist/commands/executionAuditCommands.js +56 -3
  8. package/dist/commands/projectCommands.js +504 -2
  9. package/dist/commands/releaseCommands.js +0 -1
  10. package/dist/commands/taskActor.js +17 -0
  11. package/dist/commands/taskBaseCommands.js +29 -0
  12. package/dist/commands/taskCommands.js +577 -126
  13. package/dist/commands/taskContextCommand.js +36 -2
  14. package/dist/commands/taskNextActionCommand.js +48 -3
  15. package/dist/commands/taskPublicationCommands.js +319 -0
  16. package/dist/commands/taskRoleRuntimeStatus.js +83 -59
  17. package/dist/commands/telemetryCommands.js +14 -13
  18. package/dist/config/yuiConfig.js +161 -8
  19. package/dist/context/sessionContextBudget.js +68 -0
  20. package/dist/context/wakeNotification.js +65 -0
  21. package/dist/controller/clientRuntime.js +2 -1
  22. package/dist/controller/ephemeralResourceReaper.js +2 -1
  23. package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
  24. package/dist/controller/jobSupervisor.js +5 -4
  25. package/dist/controller/resourceCleanupLinux.js +6 -6
  26. package/dist/controller/resourceInventoryLinux.js +3 -3
  27. package/dist/controller/runtime.js +88 -10
  28. package/dist/controller/updateReconciliation.js +4 -3
  29. package/dist/doctor/doctor.js +26 -7
  30. package/dist/executor/agentConfigurationCatalog.js +18 -0
  31. package/dist/executor/fileRoleLaunchPlanner.js +3 -3
  32. package/dist/lifecycle/contextBudgetRollover.js +81 -0
  33. package/dist/lifecycle/exactRunTerminalization.js +11 -1
  34. package/dist/lifecycle/providerErrorClass.js +33 -12
  35. package/dist/observability/executionAudit.js +214 -6
  36. package/dist/output/table.js +18 -0
  37. package/dist/repository/gitWorkspace.js +92 -0
  38. package/dist/repository/project.js +218 -4
  39. package/dist/repository/taskBaseFreshness.js +318 -0
  40. package/dist/repository/taskWorkspacePreparer.js +16 -2
  41. package/dist/review/deltaRecheck.js +232 -0
  42. package/dist/review/reviewConfig.js +31 -0
  43. package/dist/review/reviewFindingLedger.js +5 -1
  44. package/dist/review/reviewRound.js +156 -1
  45. package/dist/run/providerRetry.js +21 -3
  46. package/dist/run/providerRetryConfig.js +13 -60
  47. package/dist/run/recoveryProjection.js +199 -0
  48. package/dist/runtime/builtinAgentDrivers.js +3 -0
  49. package/dist/runtime/builtinTranscriptUsage.js +76 -32
  50. package/dist/runtime/continuationManager.js +17 -0
  51. package/dist/runtime/index.js +2 -0
  52. package/dist/runtime/launchDiagnostics.js +154 -0
  53. package/dist/runtime/lifecycleReservation.js +13 -0
  54. package/dist/runtime/providerContinuation.js +38 -0
  55. package/dist/runtime/providerContinuationReconciliationService.js +1 -0
  56. package/dist/runtime/providerErrorCodes.js +278 -0
  57. package/dist/runtime/runtimeHealthPolicy.js +20 -0
  58. package/dist/runtime/runtimeObservation.js +1 -0
  59. package/dist/runtime/runtimeProjection.js +115 -23
  60. package/dist/runtime/tmuxAdapters.js +242 -48
  61. package/dist/scheduler/activeRoleRunDelivery.js +139 -3
  62. package/dist/scheduler/activeTaskProgress.js +4 -3
  63. package/dist/scheduler/leaderWakeupProcessor.js +105 -15
  64. package/dist/scheduler/roleRunLiveness.js +2 -1
  65. package/dist/scheduler/roleRunStall.js +3 -2
  66. package/dist/scheduler/taskWake.js +72 -0
  67. package/dist/scheduler/wakeReason.js +64 -0
  68. package/dist/scheduler/wakeupQueue.js +2 -1
  69. package/dist/setup/setupCommand.js +1 -1
  70. package/dist/storage/migration/productionRegistry.js +325 -1
  71. package/dist/storage/sqliteSchema.js +61 -2
  72. package/dist/storage/sqliteStore.js +129 -2
  73. package/dist/storage/storeRpc.js +1 -0
  74. package/dist/storage/taskStore.js +262 -5
  75. package/dist/storage/upgrade/recordVersions.js +6 -1
  76. package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
  77. package/dist/task/completionReadiness.js +282 -0
  78. package/dist/task/publicationReference.js +123 -0
  79. package/dist/task/taskRecordReference.js +3 -1
  80. package/dist/telemetry/telemetryConfig.js +23 -18
  81. package/dist/telemetry/telemetryWiring.js +8 -8
  82. package/dist/tmux/tmuxManager.js +50 -9
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/view.js +18 -0
  85. package/dist/web/webSnapshot.js +100 -10
  86. package/i18n/README.zh-CN.md +5 -5
  87. package/package.json +1 -1
  88. package/skills/yui-leader/SKILL.md +49 -10
  89. package/skills/yui-operator/SKILL.md +13 -3
  90. package/skills/yui-worker/SKILL.md +8 -0
@@ -1,4 +1,5 @@
1
1
  import { join, resolve } from "node:path";
2
+ import { createHash } from "node:crypto";
2
3
  /** The Yui-owned directory holding a managed Project's canonical repository. */
3
4
  export function managedProjectPath(home, projectId) {
4
5
  const id = requireIdentity(projectId, "Project id");
@@ -7,7 +8,7 @@ export function managedProjectPath(home, projectId) {
7
8
  export function createProject(id, name, path, branches, now, metadata = {}) {
8
9
  const timestamp = now.toISOString();
9
10
  return validateProject({
10
- schemaVersion: 3,
11
+ schemaVersion: 4,
11
12
  id: requireIdentity(id, "Project id"),
12
13
  name: validateProjectName(name),
13
14
  aliases: normalizeAliases(metadata.aliases ?? [], name),
@@ -19,6 +20,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
19
20
  stableBranch: requireGitRef(branches.stable, "Project stable branch"),
20
21
  developmentBranch: requireGitRef(branches.development, "Project development branch"),
21
22
  knowledge: [],
23
+ knowledgeProposals: [],
22
24
  createdAt: timestamp,
23
25
  updatedAt: timestamp
24
26
  });
@@ -26,7 +28,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
26
28
  export function validateProjectName(value) {
27
29
  return requireIdentity(value, "Project name");
28
30
  }
29
- export function addProjectKnowledge(project, id, title, body, now) {
31
+ export function addProjectKnowledge(project, id, title, body, now, provenance) {
30
32
  const timestamp = now.toISOString();
31
33
  const knowledge = {
32
34
  schemaVersion: 1,
@@ -34,6 +36,7 @@ export function addProjectKnowledge(project, id, title, body, now) {
34
36
  title: requireText(title, "Project knowledge title"),
35
37
  body: requireText(body, "Project knowledge body"),
36
38
  status: "active",
39
+ ...(provenance === undefined ? {} : { provenance: validateKnowledgeProvenance(provenance) }),
37
40
  createdAt: timestamp,
38
41
  updatedAt: timestamp
39
42
  };
@@ -108,6 +111,108 @@ export function retireProjectKnowledge(project, id, now) {
108
111
  throw new Error(`Project knowledge not found: ${knowledgeId}.`);
109
112
  return validateProject({ ...project, knowledge, updatedAt: now.toISOString() });
110
113
  }
114
+ /**
115
+ * Stable dedup fingerprint for a promotion candidate. Two proposals with the
116
+ * same source identity, title, and body are the same conclusion regardless of
117
+ * who proposed them or when.
118
+ */
119
+ export function knowledgeProposalFingerprint(input) {
120
+ const sourceKey = [
121
+ input.projectId,
122
+ input.source.taskId,
123
+ input.source.decisionId ?? "",
124
+ input.source.milestoneId ?? "",
125
+ input.source.commitSha ?? ""
126
+ ].join("|");
127
+ return createHash("sha256")
128
+ .update(`${sourceKey}\u0000${input.title}\u0000${input.body}`)
129
+ .digest("hex");
130
+ }
131
+ /** Digest of the cited source evidence, so a later Agent can verify it. */
132
+ export function knowledgeEvidenceDigest(evidence) {
133
+ return createHash("sha256").update(requireText(evidence, "Knowledge evidence")).digest("hex");
134
+ }
135
+ export function findKnowledgeProposal(project, proposalId) {
136
+ return project.knowledgeProposals.find(({ id }) => id === proposalId) ?? null;
137
+ }
138
+ /**
139
+ * Append a validated proposal. The caller is responsible for deduplication:
140
+ * use {@link findKnowledgeProposalByFingerprint} first and reuse the existing
141
+ * pending candidate instead of storing a second copy.
142
+ */
143
+ export function addKnowledgeProposal(project, proposal) {
144
+ const stored = validateKnowledgeProposal(proposal, project.id);
145
+ if (project.knowledgeProposals.some((entry) => entry.id === stored.id)) {
146
+ throw new Error(`Knowledge proposal already exists: ${stored.id}.`);
147
+ }
148
+ return validateProject({
149
+ ...project,
150
+ knowledgeProposals: [...project.knowledgeProposals, stored],
151
+ updatedAt: stored.proposedAt
152
+ });
153
+ }
154
+ export function findKnowledgeProposalByFingerprint(project, fingerprint, status) {
155
+ return project.knowledgeProposals.find((entry) => (entry.fingerprint === fingerprint
156
+ && (status === undefined || entry.status === status))) ?? null;
157
+ }
158
+ /** Record the Operator decision on a pending proposal. */
159
+ export function decideKnowledgeProposal(project, proposalId, decision, now) {
160
+ const id = requireIdentity(proposalId, "Knowledge proposal id");
161
+ const timestamp = now.toISOString();
162
+ let found = false;
163
+ const knowledgeProposals = project.knowledgeProposals.map((entry) => {
164
+ if (entry.id !== id)
165
+ return entry;
166
+ found = true;
167
+ if (entry.status !== "pending") {
168
+ throw new Error(`Knowledge proposal is already ${entry.status}: ${id}.`);
169
+ }
170
+ return {
171
+ ...entry,
172
+ status: decision.knowledgeId === undefined ? "rejected" : "accepted",
173
+ decidedBy: decision.by,
174
+ decidedAt: timestamp,
175
+ ...(decision.reason === undefined ? {} : { decisionReason: requireText(decision.reason, "Knowledge proposal decision reason") }),
176
+ ...(decision.knowledgeId === undefined ? {} : { knowledgeId: requireIdentity(decision.knowledgeId, "Knowledge id") }),
177
+ updatedAt: timestamp
178
+ };
179
+ });
180
+ if (!found)
181
+ throw new Error(`Knowledge proposal not found: ${id}.`);
182
+ return validateProject({ ...project, knowledgeProposals, updatedAt: timestamp });
183
+ }
184
+ /**
185
+ * Pure acceptance policy. A proposal is a duplicate when an active Knowledge
186
+ * entry already carries its fingerprint. It conflicts when an active entry has
187
+ * the same title but a different conclusion; the Operator must then choose an
188
+ * explicit update, supersede, or reject instead of a silent overwrite.
189
+ */
190
+ export function planKnowledgeAcceptance(project, proposal) {
191
+ const active = project.knowledge.filter((entry) => entry.status === "active");
192
+ const duplicate = active.find((entry) => entry.provenance?.fingerprint === proposal.fingerprint);
193
+ if (duplicate !== undefined)
194
+ return { kind: "duplicate", knowledgeId: duplicate.id };
195
+ const conflicting = active.find((entry) => entry.title === proposal.title);
196
+ if (conflicting !== undefined) {
197
+ throw new Error(`Knowledge ${conflicting.id} has the same title but a different conclusion.`
198
+ + " Accept with --update to replace its body, or reject the proposal.");
199
+ }
200
+ if (proposal.supersedesKnowledgeId !== undefined) {
201
+ const target = project.knowledge.find((entry) => entry.id === proposal.supersedesKnowledgeId);
202
+ if (target === undefined) {
203
+ throw new Error(`Knowledge to supersede not found: ${proposal.supersedesKnowledgeId}.`);
204
+ }
205
+ if (target.status !== "active") {
206
+ throw new Error(`Knowledge to supersede is not active: ${target.id}.`);
207
+ }
208
+ }
209
+ return {
210
+ kind: "create",
211
+ ...(proposal.supersedesKnowledgeId === undefined
212
+ ? {}
213
+ : { supersedesKnowledgeId: proposal.supersedesKnowledgeId })
214
+ };
215
+ }
111
216
  export function resolveProject(projects, reference) {
112
217
  const normalized = requireText(reference, "Project reference").toLocaleLowerCase();
113
218
  const matches = projects.filter((project) => (project.id.toLocaleLowerCase() === normalized
@@ -138,8 +243,8 @@ export function assertProjectCatalog(projects) {
138
243
  }
139
244
  }
140
245
  export function validateProject(project) {
141
- if (project.schemaVersion !== 3) {
142
- throw new Error("Project must use schemaVersion 3.");
246
+ if (project.schemaVersion !== 4) {
247
+ throw new Error("Project must use schemaVersion 4.");
143
248
  }
144
249
  requireIdentity(project.id, "Project id");
145
250
  requireIdentity(project.name, "Project name");
@@ -172,16 +277,125 @@ export function validateProject(project) {
172
277
  if (!["active", "retired"].includes(entry.status)) {
173
278
  throw new Error("Project knowledge status is invalid.");
174
279
  }
280
+ if (entry.provenance !== undefined) {
281
+ validateKnowledgeProvenance(entry.provenance);
282
+ }
175
283
  requireTimestamp(entry.createdAt, "Project knowledge createdAt");
176
284
  requireTimestamp(entry.updatedAt, "Project knowledge updatedAt");
177
285
  if (knowledgeIds.has(entry.id))
178
286
  throw new Error(`Duplicate Project knowledge id: ${entry.id}.`);
179
287
  knowledgeIds.add(entry.id);
180
288
  }
289
+ const proposalIds = new Set();
290
+ for (const entry of project.knowledgeProposals) {
291
+ validateKnowledgeProposal(entry, project.id);
292
+ if (proposalIds.has(entry.id)) {
293
+ throw new Error(`Duplicate Knowledge proposal id: ${entry.id}.`);
294
+ }
295
+ proposalIds.add(entry.id);
296
+ }
181
297
  requireTimestamp(project.createdAt, "Project createdAt");
182
298
  requireTimestamp(project.updatedAt, "Project updatedAt");
183
299
  return project;
184
300
  }
301
+ function validateKnowledgeProvenance(provenance) {
302
+ requireIdentity(provenance.taskId, "Knowledge provenance task id");
303
+ if (provenance.decisionId !== undefined) {
304
+ requireIdentity(provenance.decisionId, "Knowledge provenance decision id");
305
+ }
306
+ if (provenance.milestoneId !== undefined) {
307
+ requireIdentity(provenance.milestoneId, "Knowledge provenance milestone id");
308
+ }
309
+ if (provenance.commitSha !== undefined) {
310
+ requireText(provenance.commitSha, "Knowledge provenance commit");
311
+ }
312
+ if (provenance.evidenceDigest !== undefined) {
313
+ requireText(provenance.evidenceDigest, "Knowledge provenance evidence digest");
314
+ }
315
+ if (provenance.fingerprint !== undefined) {
316
+ requireText(provenance.fingerprint, "Knowledge provenance fingerprint");
317
+ }
318
+ if (provenance.proposalId !== undefined) {
319
+ requireIdentity(provenance.proposalId, "Knowledge provenance proposal id");
320
+ }
321
+ if (provenance.promotedBy !== undefined
322
+ && provenance.promotedBy !== "user"
323
+ && provenance.promotedBy !== "operator") {
324
+ throw new Error("Knowledge provenance promotedBy is invalid.");
325
+ }
326
+ if (provenance.promotedAt !== undefined) {
327
+ requireTimestamp(provenance.promotedAt, "Knowledge provenance promotedAt");
328
+ }
329
+ return provenance;
330
+ }
331
+ function validateKnowledgeProposal(proposal, projectId) {
332
+ if (proposal.schemaVersion !== 1) {
333
+ throw new Error("Knowledge proposal must use schemaVersion 1.");
334
+ }
335
+ requireIdentity(proposal.id, "Knowledge proposal id");
336
+ if (proposal.projectId !== projectId) {
337
+ throw new Error(`Knowledge proposal belongs to another Project: ${proposal.projectId}.`);
338
+ }
339
+ requireText(proposal.title, "Knowledge proposal title");
340
+ requireText(proposal.body, "Knowledge proposal body");
341
+ if (!["pending", "accepted", "rejected"].includes(proposal.status)) {
342
+ throw new Error("Knowledge proposal status is invalid.");
343
+ }
344
+ requireIdentity(proposal.source.taskId, "Knowledge proposal source task id");
345
+ if (proposal.source.decisionId !== undefined) {
346
+ requireIdentity(proposal.source.decisionId, "Knowledge proposal source decision id");
347
+ }
348
+ if (proposal.source.milestoneId !== undefined) {
349
+ requireIdentity(proposal.source.milestoneId, "Knowledge proposal source milestone id");
350
+ }
351
+ if (proposal.source.commitSha !== undefined) {
352
+ requireText(proposal.source.commitSha, "Knowledge proposal source commit");
353
+ }
354
+ if (proposal.scope !== undefined)
355
+ requireText(proposal.scope, "Knowledge proposal scope");
356
+ if (proposal.expiresWhen !== undefined) {
357
+ requireText(proposal.expiresWhen, "Knowledge proposal expiry condition");
358
+ }
359
+ if (proposal.supersedesKnowledgeId !== undefined) {
360
+ requireIdentity(proposal.supersedesKnowledgeId, "Knowledge proposal supersedes id");
361
+ }
362
+ requireText(proposal.fingerprint, "Knowledge proposal fingerprint");
363
+ if (proposal.evidenceDigest !== undefined) {
364
+ requireText(proposal.evidenceDigest, "Knowledge proposal evidence digest");
365
+ }
366
+ if (!["leader", "reviewer", "user", "operator"].includes(proposal.proposedBy)) {
367
+ throw new Error("Knowledge proposal proposedBy is invalid.");
368
+ }
369
+ requireTimestamp(proposal.proposedAt, "Knowledge proposal proposedAt");
370
+ if (proposal.decidedBy !== undefined
371
+ && proposal.decidedBy !== "user"
372
+ && proposal.decidedBy !== "operator") {
373
+ throw new Error("Knowledge proposal decidedBy is invalid.");
374
+ }
375
+ if (proposal.decidedAt !== undefined) {
376
+ requireTimestamp(proposal.decidedAt, "Knowledge proposal decidedAt");
377
+ }
378
+ if (proposal.decisionReason !== undefined) {
379
+ requireText(proposal.decisionReason, "Knowledge proposal decision reason");
380
+ }
381
+ if (proposal.knowledgeId !== undefined) {
382
+ requireIdentity(proposal.knowledgeId, "Knowledge proposal knowledge id");
383
+ }
384
+ requireTimestamp(proposal.updatedAt, "Knowledge proposal updatedAt");
385
+ if (proposal.status === "pending" && proposal.decidedAt !== undefined) {
386
+ throw new Error("Pending Knowledge proposal must not carry a decision timestamp.");
387
+ }
388
+ if (proposal.status !== "pending" && proposal.decidedAt === undefined) {
389
+ throw new Error("Decided Knowledge proposal must carry a decision timestamp.");
390
+ }
391
+ if (proposal.status === "accepted" && proposal.knowledgeId === undefined) {
392
+ throw new Error("Accepted Knowledge proposal must reference its Knowledge entry.");
393
+ }
394
+ if (proposal.status !== "accepted" && proposal.knowledgeId !== undefined) {
395
+ throw new Error("Only an accepted Knowledge proposal may reference a Knowledge entry.");
396
+ }
397
+ return proposal;
398
+ }
185
399
  function normalizeAliases(values, name) {
186
400
  const aliases = values.map((value) => requireIdentity(value, "Project alias"));
187
401
  const folded = new Set();
@@ -0,0 +1,318 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ import { createTaskEvent } from "../event/taskEvent.js";
3
+ import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
4
+ import { NodeGitWorkspace } from "./gitWorkspace.js";
5
+ const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u;
6
+ const RISK_FILE_PREVIEW = 20;
7
+ export async function captureTaskBaseProvenance(input) {
8
+ const common = {
9
+ projectId: input.project.id,
10
+ directory: input.binding.directory,
11
+ baseRef: input.baseRef,
12
+ baseCommit: input.baseCommit,
13
+ ...(input.project.remoteUrl === undefined
14
+ ? {}
15
+ : { remoteUrl: redactRemoteUrl(input.project.remoteUrl) }),
16
+ remoteConfigured: input.project.remoteUrl !== undefined
17
+ };
18
+ if (input.project.remoteUrl === undefined) {
19
+ return { ...common, source: "not-applicable" };
20
+ }
21
+ if (input.remoteBaseline !== undefined) {
22
+ return {
23
+ ...common,
24
+ source: "remote-fetch",
25
+ trackingRef: `refs/heads/${input.remoteBaseline.branch}`,
26
+ trackingCommit: input.remoteBaseline.commit
27
+ };
28
+ }
29
+ const tracking = await input.git.inspectRemoteTracking({
30
+ repositoryPath: input.project.path,
31
+ remoteUrl: input.project.remoteUrl,
32
+ branch: input.project.developmentBranch
33
+ });
34
+ if (tracking === null) {
35
+ return { ...common, source: "unknown" };
36
+ }
37
+ return {
38
+ ...common,
39
+ source: "local-tracking",
40
+ trackingRef: tracking.ref,
41
+ trackingCommit: tracking.commit
42
+ };
43
+ }
44
+ export function recordTaskBaseProvenanceEvents(tx, taskId, provenance, now) {
45
+ for (const entry of provenance) {
46
+ const payload = {
47
+ projectId: entry.projectId,
48
+ directory: entry.directory,
49
+ baseRef: entry.baseRef,
50
+ baseCommit: entry.baseCommit,
51
+ source: entry.source,
52
+ remoteConfigured: entry.remoteConfigured ? "true" : "false",
53
+ ...(entry.remoteUrl === undefined ? {} : { remoteUrl: entry.remoteUrl }),
54
+ ...(entry.trackingRef === undefined ? {} : { trackingRef: entry.trackingRef }),
55
+ ...(entry.trackingCommit === undefined ? {} : { trackingCommit: entry.trackingCommit })
56
+ };
57
+ tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "task.base-provenance", payload, now));
58
+ }
59
+ }
60
+ export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
61
+ const task = requireTask(store, taskId);
62
+ const git = options.git ?? newGitWorkspace();
63
+ const workspace = store.getTaskWorkspace(taskId);
64
+ const entries = await Promise.all(task.projectBindings.map(async (binding) => {
65
+ const project = requireProject(store, binding.projectId);
66
+ const entry = workspace === null ? undefined : workspaceProjectEntry(workspace, project.id);
67
+ const baseCommit = await resolveBaseCommit(git, project, binding, entry);
68
+ const workspacePath = entry?.path ?? project.path;
69
+ const provenance = latestBaseProvenance(store.listEvents(taskId), project.id);
70
+ const tracked = await resolveTracked(git, project, workspacePath, options.refresh === true);
71
+ const status = await classifyStatus(git, workspacePath, project, baseCommit, tracked);
72
+ const remoteOnlyChangedFiles = await remoteOnlyChanges(git, workspacePath, status, baseCommit, tracked?.commit);
73
+ const workspaceClean = await inspectWorkspaceClean(git, workspacePath);
74
+ return {
75
+ projectId: project.id,
76
+ directory: binding.directory,
77
+ baseRef: binding.baseRef,
78
+ baseCommit,
79
+ status,
80
+ source: tracked?.source ?? (project.remoteUrl === undefined ? "not-applicable" : "compatibility-projection"),
81
+ workspacePath,
82
+ workspaceClean,
83
+ ...(tracked === undefined ? {} : {
84
+ trackedRef: tracked.ref,
85
+ trackedCommit: tracked.commit,
86
+ ...(tracked.error === undefined ? {} : { error: redactText(tracked.error) })
87
+ }),
88
+ remoteOnlyChangedFiles: remoteOnlyChangedFiles.files.slice(0, RISK_FILE_PREVIEW),
89
+ remoteOnlyChangedFileCount: remoteOnlyChangedFiles.files.length,
90
+ ...(risk(status, workspaceClean, remoteOnlyChangedFiles.files, tracked?.error) === undefined
91
+ ? {}
92
+ : { risk: risk(status, workspaceClean, remoteOnlyChangedFiles.files, tracked?.error) }),
93
+ ...(provenance === undefined ? {} : {
94
+ observedAt: provenance.event.createdAt,
95
+ observedSource: provenance.provenance.source,
96
+ ...(provenance.provenance.remoteUrl === undefined
97
+ ? {}
98
+ : { observedRemoteUrl: provenance.provenance.remoteUrl }),
99
+ ...(provenance.provenance.trackingRef === undefined
100
+ ? {}
101
+ : { observedTrackingRef: provenance.provenance.trackingRef }),
102
+ ...(provenance.provenance.trackingCommit === undefined
103
+ ? {}
104
+ : { observedTrackingCommit: provenance.provenance.trackingCommit })
105
+ })
106
+ };
107
+ }));
108
+ return { taskId, refreshed: options.refresh === true, entries };
109
+ }
110
+ export function assertTaskBaseFreshnessForCompletion(report) {
111
+ const warnings = [];
112
+ for (const entry of report.entries) {
113
+ if (entry.status === "behind" || entry.status === "diverged") {
114
+ throw usageError(`Task ${report.taskId} Project ${entry.projectId} base is ${entry.status}; `
115
+ + `run 'yui task base status ${report.taskId} --refresh' and choose an explicit delivery base. `
116
+ + "Safe resolutions are to rebase or merge the Task workspace onto the refreshed remote base, "
117
+ + "create a clean delivery branch from that base and cherry-pick the Task changes, "
118
+ + "or ask the Leader to resolve it. Continuing without rebasing is allowed only after explicitly "
119
+ + "recording the unrelated-diff risk.");
120
+ }
121
+ if (entry.workspaceClean === false) {
122
+ throw usageError(`Task ${report.taskId} Project ${entry.projectId} workspace is dirty; `
123
+ + "commit, integrate, or clean it before completion.");
124
+ }
125
+ if (entry.status === "unknown") {
126
+ if (report.refreshed) {
127
+ throw usageError(`Task ${report.taskId} Project ${entry.projectId} remote base could not be refreshed: `
128
+ + `${entry.error ?? "unknown error"}`);
129
+ }
130
+ warnings.push(`Project ${entry.projectId} remote base is unknown; local completion continues without a remote claim.`);
131
+ }
132
+ }
133
+ return warnings;
134
+ }
135
+ export function renderTaskBaseFreshnessReport(report) {
136
+ if (report.entries.length === 0) {
137
+ return `Task ${report.taskId} has no Projects.\n`;
138
+ }
139
+ const lines = [
140
+ `Task base freshness: ${report.taskId} (source: ${report.refreshed ? "remote refresh" : "local tracking"})`
141
+ ];
142
+ for (const entry of report.entries) {
143
+ lines.push(`- ${entry.directory}: ${entry.projectId} @ ${entry.baseCommit}`, ` status: ${entry.status}; workspace: ${entry.workspaceClean === null ? "unknown" : entry.workspaceClean ? "clean" : "dirty"}`, ` observed remote: ${entry.observedRemoteUrl ?? "-"}`, ` tracked: ${entry.trackedCommit ?? "-"}${entry.trackedRef === undefined ? "" : ` (${entry.trackedRef})`}`, ` observed: ${entry.observedTrackingCommit ?? "-"}${entry.observedAt === undefined ? "" : ` at ${entry.observedAt}`}`);
144
+ if (entry.risk !== undefined)
145
+ lines.push(` risk: ${entry.risk}`);
146
+ if (entry.error !== undefined)
147
+ lines.push(` error: ${entry.error}`);
148
+ }
149
+ return lines.join("\n").concat("\n");
150
+ }
151
+ function newGitWorkspace() {
152
+ return new NodeGitWorkspace();
153
+ }
154
+ function requireTask(store, taskId) {
155
+ const task = store.getTask(taskId);
156
+ if (task === null)
157
+ throw usageError(`Task not found: ${taskId}.`);
158
+ return task;
159
+ }
160
+ function requireProject(store, projectId) {
161
+ const project = store.getProject(projectId);
162
+ if (project === null)
163
+ throw usageError(`Project not found: ${projectId}.`);
164
+ return project;
165
+ }
166
+ async function resolveBaseCommit(git, project, binding, entry) {
167
+ if (entry?.baseCommit !== undefined)
168
+ return entry.baseCommit;
169
+ if (COMMIT_PATTERN.test(binding.baseRef))
170
+ return binding.baseRef.toLowerCase();
171
+ return (await git.inspect(project.path, binding.baseRef)).baseCommit;
172
+ }
173
+ async function resolveTracked(git, project, workspacePath, refresh) {
174
+ if (project.remoteUrl === undefined)
175
+ return undefined;
176
+ if (refresh) {
177
+ if (git.fetchRemoteHeadIntoWorktree === undefined) {
178
+ return {
179
+ ref: `refs/heads/${project.developmentBranch}`,
180
+ commit: "",
181
+ source: "remote-refresh",
182
+ error: "managed Git workspace does not support remote refresh"
183
+ };
184
+ }
185
+ try {
186
+ const remote = await git.fetchRemoteHeadIntoWorktree({
187
+ repositoryPath: workspacePath,
188
+ remoteUrl: project.remoteUrl,
189
+ branch: project.developmentBranch
190
+ });
191
+ return {
192
+ ref: `refs/heads/${remote.branch}`,
193
+ commit: remote.commit,
194
+ source: "remote-refresh"
195
+ };
196
+ }
197
+ catch (error) {
198
+ return {
199
+ ref: `refs/heads/${project.developmentBranch}`,
200
+ commit: "",
201
+ source: "remote-refresh",
202
+ error: error instanceof Error ? error.message : String(error)
203
+ };
204
+ }
205
+ }
206
+ const tracking = await git.inspectRemoteTracking({
207
+ repositoryPath: project.path,
208
+ remoteUrl: project.remoteUrl,
209
+ branch: project.developmentBranch
210
+ });
211
+ if (tracking === null)
212
+ return undefined;
213
+ return { ref: tracking.ref, commit: tracking.commit, source: "local-tracking" };
214
+ }
215
+ async function classifyStatus(git, repositoryPath, project, baseCommit, tracked) {
216
+ if (project.remoteUrl === undefined)
217
+ return "not-applicable";
218
+ if (tracked === undefined || tracked.commit === "")
219
+ return "unknown";
220
+ if (tracked.commit === baseCommit)
221
+ return "up-to-date";
222
+ if (await git.isAncestor(repositoryPath, baseCommit, tracked.commit)) {
223
+ return "behind";
224
+ }
225
+ if (await git.isAncestor(repositoryPath, tracked.commit, baseCommit)) {
226
+ return "ahead";
227
+ }
228
+ return "diverged";
229
+ }
230
+ async function remoteOnlyChanges(git, repositoryPath, status, baseCommit, trackedCommit) {
231
+ if (trackedCommit === undefined
232
+ || trackedCommit === ""
233
+ || (status !== "behind" && status !== "diverged")) {
234
+ return { files: [] };
235
+ }
236
+ try {
237
+ const fromCommit = status === "behind"
238
+ ? baseCommit
239
+ : await git.mergeBase({ repositoryPath, leftCommit: baseCommit, rightCommit: trackedCommit });
240
+ return {
241
+ files: await git.changedFilesBetween({ repositoryPath, fromCommit, toCommit: trackedCommit })
242
+ };
243
+ }
244
+ catch {
245
+ return { files: [] };
246
+ }
247
+ }
248
+ async function inspectWorkspaceClean(git, workspacePath) {
249
+ try {
250
+ return await git.isClean(workspacePath);
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ }
256
+ function risk(status, workspaceClean, files, error) {
257
+ if (status === "behind" || status === "diverged") {
258
+ const suffix = files.length === 0
259
+ ? "no remote-only file delta is locally provable"
260
+ : `${files.length} remote-only file${files.length === 1 ? "" : "s"} (${files.slice(0, RISK_FILE_PREVIEW).join(", ")}${files.length > RISK_FILE_PREVIEW ? ", ..." : ""}) may appear as unrelated delivery diff`;
261
+ return `base is ${status}; ${suffix}.`;
262
+ }
263
+ if (workspaceClean === false)
264
+ return "worktree has uncommitted changes.";
265
+ if (status === "unknown")
266
+ return error === undefined ? "remote identity is not locally observable." : redactText(error);
267
+ return undefined;
268
+ }
269
+ function latestBaseProvenance(events, projectId) {
270
+ const event = [...events].reverse().find((candidate) => (candidate.type === "task.base-provenance" && candidate.payload.projectId === projectId));
271
+ if (event === undefined)
272
+ return undefined;
273
+ const source = event.payload.source;
274
+ if (source !== "local-tracking"
275
+ && source !== "remote-fetch"
276
+ && source !== "unknown"
277
+ && source !== "not-applicable") {
278
+ return undefined;
279
+ }
280
+ return {
281
+ event,
282
+ provenance: {
283
+ projectId,
284
+ directory: event.payload.directory ?? "",
285
+ baseRef: event.payload.baseRef ?? "",
286
+ baseCommit: event.payload.baseCommit ?? "",
287
+ source,
288
+ remoteConfigured: event.payload.remoteConfigured === "true",
289
+ ...(event.payload.remoteUrl === undefined ? {} : { remoteUrl: event.payload.remoteUrl }),
290
+ ...(event.payload.trackingRef === undefined ? {} : { trackingRef: event.payload.trackingRef }),
291
+ ...(event.payload.trackingCommit === undefined ? {} : { trackingCommit: event.payload.trackingCommit })
292
+ }
293
+ };
294
+ }
295
+ function redactText(value) {
296
+ return redactRemoteUrl(value);
297
+ }
298
+ export function redactRemoteUrl(value) {
299
+ return value
300
+ .replace(ABSOLUTE_REMOTE_URL_PATTERN, redactAbsoluteRemoteUrl)
301
+ .replace(SCP_REMOTE_URL_PATTERN, (_match, prefix, _user, host, path) => `${prefix}redacted@${host}:${path}`);
302
+ }
303
+ const ABSOLUTE_REMOTE_URL_PATTERN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;
304
+ const SCP_REMOTE_URL_PATTERN = /(^|[\s"'(])([A-Za-z0-9._-]+)@([A-Za-z0-9.-]+):([^\s"'<>]+)/gu;
305
+ function redactAbsoluteRemoteUrl(value) {
306
+ try {
307
+ const url = new URL(value);
308
+ if (url.username !== "" || url.password !== "") {
309
+ url.username = "redacted";
310
+ url.password = "";
311
+ return url.toString();
312
+ }
313
+ return value;
314
+ }
315
+ catch {
316
+ return value.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/@\s]+@/u, "$1redacted@");
317
+ }
318
+ }
@@ -14,6 +14,7 @@ import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
14
14
  import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
15
15
  import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity, TASK_WORKSPACE_TOKEN_PATTERN, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
16
16
  import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
17
+ import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
17
18
  const MAIN_WORKTREE = "main";
18
19
  const LEADER_ROLE = "leader";
19
20
  /**
@@ -240,7 +241,8 @@ export class FileTaskWorkspacePreparer {
240
241
  baseRef: remote.commit,
241
242
  recordedBaseRef: remote.commit,
242
243
  expectedCommit: remote.commit,
243
- pinTask: true
244
+ pinTask: true,
245
+ remoteBaseline: remote
244
246
  });
245
247
  continue;
246
248
  }
@@ -270,6 +272,14 @@ export class FileTaskWorkspacePreparer {
270
272
  && physical.baseCommit !== baseline.expectedCommit) {
271
273
  throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
272
274
  }
275
+ const provenance = await captureTaskBaseProvenance({
276
+ git: this.git,
277
+ project,
278
+ binding,
279
+ baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
280
+ baseCommit: physical.baseCommit,
281
+ ...(baseline.remoteBaseline === undefined ? {} : { remoteBaseline: baseline.remoteBaseline })
282
+ });
273
283
  prepared.push({
274
284
  project,
275
285
  entry: {
@@ -280,7 +290,8 @@ export class FileTaskWorkspacePreparer {
280
290
  branch: physical.branch,
281
291
  baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
282
292
  baseCommit: physical.baseCommit
283
- }
293
+ },
294
+ provenance
284
295
  });
285
296
  }
286
297
  await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
@@ -351,6 +362,9 @@ export class FileTaskWorkspacePreparer {
351
362
  tx.saveTask(persistedTask);
352
363
  }
353
364
  tx.saveManagedWorkspace(preserveWorkspaceCreatedAt(workspace, current));
365
+ if (current === null) {
366
+ recordTaskBaseProvenanceEvents(tx, task.id, prepared.map(({ provenance }) => provenance), timestamp);
367
+ }
354
368
  for (const role of tx.listRoles(task.id)) {
355
369
  // The Role field is only a cwd/snapshot hint. Preserve the hint for
356
370
  // an active WorkItem assignment; the durable owner is the WorkItem,