@tea-agent/loop-agent 0.44.0-next.4 → 0.44.0-next.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 (100) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/pi-sdk-executor.js +7 -7
  4. package/dist/executors/shell-write-guard.js +26 -3
  5. package/dist/infrastructure/console/operation-store.js +46 -14
  6. package/dist/shared/file-identity.js +23 -0
  7. package/dist/shared/git-workspace-status.js +480 -0
  8. package/dist/shared/path-safety.js +2 -2
  9. package/dist/shared/wizard-local-telemetry.js +2 -1
  10. package/dist/worker/console/chat/context-insights.js +723 -0
  11. package/dist/worker/console/chat/pi-runtime.js +217 -18
  12. package/dist/worker/console/chat/repo-browser.js +52 -4
  13. package/dist/worker/console/chat/routes.js +150 -58
  14. package/dist/worker/console/chat/shortcuts.js +7 -0
  15. package/dist/worker/console/console-update-runtime.js +2 -2
  16. package/dist/worker/console/operator-user-error.js +28 -1
  17. package/dist/worker/console/pi-readiness.js +4 -4
  18. package/dist/worker/console/recovery-cta.js +14 -14
  19. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B_lmJjOm.js → abnfDiagram-N423BO3Z-Brn2sPzL.js} +1 -1
  20. package/dist/worker/console/static/assets/{arc-pymM4Qeh.js → arc-BIMNTG0W.js} +1 -1
  21. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-Bw9EPAhi.js → architectureDiagram-T3A2C74G--WtcgR_A.js} +1 -1
  22. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C7_oXJp2.js → blockDiagram-VBNYF7ZC-BT_Ztog1.js} +1 -1
  23. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-LnLWDbh8.js → c4Diagram-5PPSVZJV-C5hbVr-l.js} +1 -1
  24. package/dist/worker/console/static/assets/channel-DNWB4hpR.js +1 -0
  25. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BBiu3sCW.js → chunk-2GRJ4B5K-BKKnyR9Z.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DiXqDQml.js → chunk-2Q5K7J3B-BHs6biqA.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-lmNq9nYk.js → chunk-5RXB4S5H-BdSFy_vP.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-boBu74dU.js → chunk-5VM5RSS4-BpJIdTKV.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CjxqoJLQ.js → chunk-6Q2QTUOP-C_iWJhkT.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DL0vQ9MG.js → chunk-GF5L2VYU-BTbmx3-k.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DGPqE-O0.js → chunk-JWPE2WC7-D4Ee6DmP.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-v4BbT_r2.js → chunk-KBJHAD2P-B487SORR.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-Ci0ynvyL.js → chunk-RYQCIY6F-BKYHjNrr.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CGsbJoTV.js → chunk-XXDRQBXY-DrTdoYZG.js} +1 -1
  35. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-COhFgJji.js +1 -0
  36. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-COhFgJji.js +1 -0
  37. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CzoRDEI7.js → cose-bilkent-JH36ORCC-CttUY_Jo.js} +1 -1
  38. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BOwFwbX4.js → cynefin-VYW2F7L2-B4Rm5gnR.js} +1 -1
  39. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-2Cfg451l.js → cynefinDiagram-MW4NZA55-ByxHBq_S.js} +1 -1
  40. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-DbT0D6Y5.js → dagre-VZM6K2ZE-DGrfNFA0.js} +1 -1
  41. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dfd_JoMs.js → diagram-7IWD3JNH-KjrUPquv.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DaGm82kh.js → diagram-B4RE2ZJO-VS4mlm75.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DMoI8Ibc.js → diagram-LBJQPF4R-BLXYmFTM.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BtIFA3aY.js → diagram-Q27KOJAE-C6xoFAqH.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-UB23O5K3-D_2X1DTZ.js → diagram-UB23O5K3-C478dCe7.js} +1 -1
  46. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-yFW5ZYlZ.js → ebnfDiagram-BXEA7PRR-DHEpW5zN.js} +1 -1
  47. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DBXh5Svj.js → erDiagram-JOGREHBK-CA5GKfo9.js} +1 -1
  48. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C4ycm8KM.js → flowDiagram-UKHOOZJN-zysi0Fl-.js} +1 -1
  49. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-qr-_Pekr.js → ganttDiagram-PKOTCBZU-DGzcKA8Y.js} +1 -1
  50. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DzOSLv_A.js → gitGraphDiagram-DS77QQ5N-CHsuIG1w.js} +1 -1
  51. package/dist/worker/console/static/assets/index-CJh6pVLm.js +482 -0
  52. package/dist/worker/console/static/assets/index-C_kvKmFM.css +1 -0
  53. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C7Hl8D7l.js → infoDiagram-6WML65LV-BexZHPCp.js} +1 -1
  54. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-fBxVlr6t.js → ishikawaDiagram-WSZJBQD7-Gkx8q-DB.js} +1 -1
  55. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CoiJEVCl.js → journeyDiagram-NVQOT4AX-XDW3qrJ7.js} +1 -1
  56. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-TJXpxNuV.js → kanban-definition-27J2QSJJ-DJ07eEaN.js} +1 -1
  57. package/dist/worker/console/static/assets/{linear-CjQWz1pG.js → linear-DL-zeqOn.js} +1 -1
  58. package/dist/worker/console/static/assets/{mermaid.core-DY9_apIE.js → mermaid.core-C0HdXtOf.js} +5 -5
  59. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-HB0Id8gM.js → mindmap-definition-FAOFIHXS-CqRX0lrt.js} +1 -1
  60. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-0OSWgr6v.js → pegDiagram-VL7TDLO6-BVM9JZ2H.js} +1 -1
  61. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Cei1GGC2.js → pieDiagram-7S7Q4E2Y-BA3ozdKk.js} +1 -1
  62. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CDy0Adfu.js → quadrantDiagram-CIZ2JOQS-B-0_p9_4.js} +1 -1
  63. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwqzUWwN.js → railroadDiagram-AXF67PYL-DHRzPWFc.js} +1 -1
  64. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-Bif2IBRL.js → requirementDiagram-LRYGKXZP-9BEC_PcW.js} +1 -1
  65. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CYIoNMLg.js → sankeyDiagram-W5VNT64P-DMxeW4rN.js} +1 -1
  66. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BDS7Y0aQ.js → sequenceDiagram-SI44F4Z6-DixP6LIU.js} +1 -1
  67. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-lZic-_Bp.js → sizeCapture-X5ZJPWSS-B_EifJBo.js} +1 -1
  68. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-WCVlCqSd.js → stateDiagram-OKZ733FA-BwikwWbX.js} +1 -1
  69. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-S2ZHMo1c.js +1 -0
  70. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-pGccxTw_.js → swimlanes-SLNWSIFB-C162l9o1.js} +2 -2
  71. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BZm4ByfV.js +8 -0
  72. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D29RlrQA.js → timeline-definition-Z64GVDOM-mpe_u1i4.js} +1 -1
  73. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-01fEM1NU.js → vennDiagram-T6HMQDX7-DyUk54HX.js} +1 -1
  74. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C4GeB6dq.js → wardleyDiagram-T6FBY63Y-HYyMkzfm.js} +1 -1
  75. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BXHrXHps.js → xychartDiagram-ELKLHX3M-DRoh7skR.js} +1 -1
  76. package/dist/worker/console/static/index.html +2 -2
  77. package/dist/worker/console/static-src/app/console-types.js +9 -1
  78. package/dist/worker/console/static-src/operator-chat/context-insights-store.js +64 -0
  79. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +98 -0
  80. package/dist/worker/console/static-src/operator-chat/file-palette-nav.js +81 -0
  81. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +4 -2
  82. package/dist/worker/console/static-src/operator-chat/useComposer.js +80 -6
  83. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +8 -1
  84. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +36 -17
  85. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +18 -0
  86. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  87. package/dist/worker/console/static-src/shell/console-update-reload.js +1 -1
  88. package/dist/worker/console/workspace-initialization.js +3 -3
  89. package/dist/worker/observability/read-model.js +23 -2
  90. package/dist/worker/observe/node-context-insights.js +479 -0
  91. package/docs/README.md +1 -1
  92. package/docs/templates/backend-test-dag.json +1 -0
  93. package/package.json +4 -4
  94. package/dist/worker/console/static/assets/channel-Ym13M3Lb.js +0 -1
  95. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeFR1wGo.js +0 -1
  96. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeFR1wGo.js +0 -1
  97. package/dist/worker/console/static/assets/index-PFjB6g6l.js +0 -482
  98. package/dist/worker/console/static/assets/index-ZtyB6Ol-.css +0 -1
  99. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-cj5UUB6m.js +0 -1
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-C_Qp6bnh.js +0 -8
@@ -0,0 +1,480 @@
1
+ import { access, lstat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { runResilientGitCommand } from "./resilient-git.js";
4
+ const MAX_SUBMODULE_DEPTH = 4;
5
+ export const DEFAULT_WORKSPACE_STATUS_DEPENDENCIES = {
6
+ runGit: (cwd, args) => runResilientGitCommand({ cwd, args, readOnly: true }).then((result) => ({
7
+ code: result.code,
8
+ stdout: result.stdout,
9
+ stderr: result.stderr,
10
+ })),
11
+ readSubmoduleStatus: async (cwd) => {
12
+ const result = await runResilientGitCommand({
13
+ cwd,
14
+ args: ["status", "--porcelain=v1", "--untracked-files=all"],
15
+ readOnly: true,
16
+ });
17
+ if (result.code !== 0) {
18
+ throw new Error(result.stderr.trim() ||
19
+ result.stdout.trim() ||
20
+ `git status exited ${result.code}`);
21
+ }
22
+ return result.stdout;
23
+ },
24
+ };
25
+ /** Parse porcelain v1 output into normalized path -> "XY" entries. */
26
+ export function parsePorcelainEntries(porcelain) {
27
+ const entries = new Map();
28
+ for (const line of porcelain.split("\n")) {
29
+ const trimmed = line.trimEnd();
30
+ if (!trimmed)
31
+ continue;
32
+ const x = trimmed[0] ?? " ";
33
+ const y = trimmed[1] ?? " ";
34
+ const rawPath = trimmed.slice(3);
35
+ if (!rawPath)
36
+ continue;
37
+ const normalized = normalizeWorkspacePath(unquoteGitPath(renameTargetPath(rawPath)));
38
+ if (!normalized)
39
+ continue;
40
+ entries.set(normalized, `${x}${y}`);
41
+ }
42
+ return entries;
43
+ }
44
+ /**
45
+ * Return the destination path of a porcelain path field. Rename entries
46
+ * render as `ORIG -> DEST` where either side may independently be C-quoted;
47
+ * splitting on a quoted ` -> ` (or unquoting before splitting) corrupts the
48
+ * key with residual quotes or half-decoded octal. Scan for the last
49
+ * outside-quote separator instead, then return the still-quoted DEST for
50
+ * the caller to unquote.
51
+ */
52
+ function renameTargetPath(rawPath) {
53
+ let inQuotes = false;
54
+ let separatorIndex = -1;
55
+ for (let i = 0; i < rawPath.length - 3; i += 1) {
56
+ const char = rawPath[i];
57
+ if (char === "\\") {
58
+ i += 1; // skip the escaped character; it cannot open/close quotes
59
+ continue;
60
+ }
61
+ if (char === '"')
62
+ inQuotes = !inQuotes;
63
+ if (!inQuotes && rawPath.startsWith(" -> ", i))
64
+ separatorIndex = i;
65
+ }
66
+ if (separatorIndex === -1)
67
+ return rawPath;
68
+ return rawPath.slice(separatorIndex + 4);
69
+ }
70
+ function normalizeWorkspacePath(value) {
71
+ const target = value.includes(" -> ")
72
+ ? (value.split(" -> ").at(-1) ?? value)
73
+ : value;
74
+ // Embedded-repo untracked entries render as `dir/`; strip the trailing
75
+ // slash so the key matches the registered submodule path form.
76
+ return target.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
77
+ }
78
+ /**
79
+ * Unquote a git C-quoted path (`"..."` with C-style escapes; non-ASCII bytes
80
+ * arrive as octal like \346\225\260). Returns the input unchanged when not
81
+ * quoted. This keeps Chinese/space paths (e.g. `source/需求.md`) comparable
82
+ * against writeSet patterns.
83
+ */
84
+ export function unquoteGitPath(value) {
85
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
86
+ return value;
87
+ }
88
+ const inner = value.slice(1, -1);
89
+ let out = "";
90
+ for (let i = 0; i < inner.length; i += 1) {
91
+ const char = inner[i];
92
+ if (char !== "\\") {
93
+ out += char;
94
+ continue;
95
+ }
96
+ const next = inner[i + 1];
97
+ if (next === undefined)
98
+ break;
99
+ if (next === "n")
100
+ out += "\n";
101
+ else if (next === "t")
102
+ out += "\t";
103
+ else if (next === "r")
104
+ out += "\r";
105
+ else if (next === "\\")
106
+ out += "\\";
107
+ else if (next === '"')
108
+ out += '"';
109
+ else if (/[0-7]{3}/.test(inner.slice(i + 1, i + 4))) {
110
+ // Octal byte escape: collect consecutive escapes into one string
111
+ // and decode as UTF-8. `continue` skips the shared `i += 1` below
112
+ // (i already points past the consumed run).
113
+ const bytes = [];
114
+ let j = i;
115
+ while (j < inner.length && inner[j] === "\\" && /[0-7]{3}/.test(inner.slice(j + 1, j + 4))) {
116
+ bytes.push(Number.parseInt(inner.slice(j + 1, j + 4), 8));
117
+ j += 4;
118
+ }
119
+ out += Buffer.from(bytes).toString("utf8");
120
+ i = j - 1;
121
+ continue;
122
+ }
123
+ else {
124
+ out += next;
125
+ }
126
+ i += 1;
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * Read registered submodule directories from `.gitmodules` via
132
+ * `git config -f .gitmodules --get-regexp` (structured output; no
133
+ * shell-quoting hazards). Returns [] when the file is absent or empty.
134
+ */
135
+ export async function readSubmoduleRegistrations(rootCwd, dependencies = DEFAULT_WORKSPACE_STATUS_DEPENDENCIES) {
136
+ const result = await dependencies.runGit(rootCwd, [
137
+ "config",
138
+ "-f",
139
+ ".gitmodules",
140
+ "--get-regexp",
141
+ "^submodule\\..*\\.path$",
142
+ ]);
143
+ // Exit 1 with no stderr = the file exists but has no matching keys.
144
+ // Any other non-zero exit (unreadable file, parse error) must fail closed:
145
+ // returning [] would silently drop every submodule from observation.
146
+ if (result.code === 1 && !result.stderr.trim())
147
+ return [];
148
+ if (result.code !== 0) {
149
+ throw new Error(`git config -f .gitmodules failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
150
+ }
151
+ if (!result.stdout.trim())
152
+ return [];
153
+ const paths = [];
154
+ for (const line of result.stdout.split("\n")) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed)
157
+ continue;
158
+ // Line format: `submodule.<name>.path <value>`. The value may contain
159
+ // spaces; split on the first space after the key only.
160
+ const separator = trimmed.indexOf(" ");
161
+ if (separator <= 0)
162
+ continue;
163
+ const submodulePath = trimmed.slice(separator + 1).trim();
164
+ if (!submodulePath)
165
+ continue;
166
+ paths.push(normalizeWorkspacePath(submodulePath));
167
+ }
168
+ return paths;
169
+ }
170
+ /**
171
+ * Probe the gitlink state of one submodule against its parent repository.
172
+ * Porcelain codes cannot distinguish "dirty content inside the submodule"
173
+ * (covered by the child's file-level entries) from "the child HEAD actually
174
+ * moved" (a git mutation the child's own clean status cannot express), so
175
+ * the relevant SHAs are compared directly.
176
+ *
177
+ * Three facts are captured so staged gitlinks and repeated moves cannot
178
+ * hide a mutation:
179
+ * - `indexSha`: what the parent index records for the gitlink now.
180
+ * - `headTreeSha`: what the parent HEAD tree records (detects `git add sub`
181
+ * staged after a commit — the working gitlink moved but index == HEAD is
182
+ * not the full story; see gitlinkMutated).
183
+ * - `headSha`: the child's checked-out HEAD.
184
+ *
185
+ * `readFailed` maps to the caller's fail-closed incomplete path.
186
+ */
187
+ async function readGitlinkState(parentRoot, submodulePath, dependencies) {
188
+ const ls = await dependencies.runGit(parentRoot, [
189
+ "ls-files",
190
+ "-s",
191
+ "--",
192
+ submodulePath,
193
+ ]);
194
+ const line = ls.code !== 0
195
+ ? undefined
196
+ : ls.stdout.split("\n").find((entry) => entry.trim());
197
+ // `160000 <sha> 0\t<path>` for a gitlink entry.
198
+ const indexSha = line?.split(/\s+/)[1];
199
+ const headTree = await dependencies.runGit(parentRoot, [
200
+ "rev-parse",
201
+ `HEAD:${submodulePath}`,
202
+ ]);
203
+ const headTreeSha = headTree.code === 0 ? headTree.stdout.trim() : undefined;
204
+ const rev = await dependencies.runGit(joinFsPath(parentRoot, submodulePath), ["rev-parse", "HEAD"]);
205
+ const headSha = rev.code === 0 ? rev.stdout.trim() : undefined;
206
+ if (ls.code !== 0 || rev.code !== 0) {
207
+ return {
208
+ indexSha,
209
+ headTreeSha,
210
+ headSha,
211
+ readFailed: true,
212
+ errorDetail: `ls-files exit ${ls.code}; rev-parse exit ${rev.code}: ${rev.stderr.trim() || ls.stderr.trim() || "no output"}`,
213
+ };
214
+ }
215
+ return { indexSha, headTreeSha, headSha, readFailed: false };
216
+ }
217
+ /**
218
+ * A gitlink mutation is any state where the working gitlink (child HEAD)
219
+ * disagrees with what the parent's committed or staged history expects, or
220
+ * where the parent index gitlink is itself dirty relative to HEAD:
221
+ * - child HEAD != index: writer moved the submodule HEAD (committed inside
222
+ * the submodule, or checked out another commit).
223
+ * - index != HEAD tree: the gitlink change was staged (`git add sub`) but
224
+ * not committed — still an unauthorized git mutation during a run.
225
+ * - child HEAD != HEAD tree (index clean): a moved HEAD not yet staged;
226
+ * the classic in-submodule commit.
227
+ * When the parent has never committed the submodule (fresh `submodule add`,
228
+ * no `HEAD:<path>`), only the index-vs-child comparison applies.
229
+ */
230
+ function gitlinkMutated(state) {
231
+ const { indexSha, headTreeSha, headSha } = state;
232
+ if (indexSha !== undefined && headSha !== undefined && indexSha !== headSha) {
233
+ return true;
234
+ }
235
+ if (headTreeSha !== undefined &&
236
+ indexSha !== undefined &&
237
+ headTreeSha !== indexSha) {
238
+ return true;
239
+ }
240
+ if (headTreeSha !== undefined &&
241
+ headSha !== undefined &&
242
+ headTreeSha !== headSha) {
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+ /**
248
+ * Sentinel key for a mutated gitlink. Embeds the observed SHA triple so the
249
+ * key changes when the gitlink moves again (a constant key would make a
250
+ * second move on a dirty baseline indistinguishable from no move).
251
+ */
252
+ function gitlinkSentinelKey(workspacePath, state) {
253
+ const fingerprint = [state.indexSha, state.headTreeSha, state.headSha]
254
+ .map((sha) => sha ?? "-")
255
+ .join("+");
256
+ return `${GITLINK_SENTINEL_PREFIX}${workspacePath}@${fingerprint}`;
257
+ }
258
+ async function submoduleWorkingTreeInitialized(rootCwd, submodulePath) {
259
+ try {
260
+ const info = await lstat(path.resolve(rootCwd, ...submodulePath.split("/"), ".git"));
261
+ return info.isFile() || info.isDirectory();
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
267
+ async function collectSubmoduleEntries(input) {
268
+ // undefined = collection failed for this submodule (recorded, fail-closed).
269
+ const repoRoot = joinFsPath(input.rootCwd, input.workspacePath);
270
+ try {
271
+ const porcelain = await input.dependencies.readSubmoduleStatus(repoRoot);
272
+ input.repos.push({
273
+ repoRelativePath: input.repoWorkspacePath,
274
+ repoId: input.repoWorkspacePath,
275
+ state: "clean-status-read",
276
+ porcelain,
277
+ });
278
+ const childEntries = parsePorcelainEntries(porcelain);
279
+ const expanded = new Map();
280
+ for (const [childPath, code] of childEntries) {
281
+ expanded.set(`${input.repoWorkspacePath}/${childPath}`, code);
282
+ }
283
+ if (input.depth < MAX_SUBMODULE_DEPTH) {
284
+ const nestedPaths = await readSubmoduleRegistrations(repoRoot, input.dependencies);
285
+ for (const nestedPath of nestedPaths) {
286
+ const nestedWorkspacePath = `${input.repoWorkspacePath}/${nestedPath}`;
287
+ const initialized = await submoduleWorkingTreeInitialized(repoRoot, nestedPath);
288
+ if (!initialized) {
289
+ input.repos.push({
290
+ repoRelativePath: nestedWorkspacePath,
291
+ repoId: nestedWorkspacePath,
292
+ state: "uninitialized",
293
+ porcelain: "",
294
+ });
295
+ continue;
296
+ }
297
+ // Gitlink probe at this nesting level: the parent of `nested` is the
298
+ // current repository (repoRoot). Same three-SHA semantics as the
299
+ // top level; a moved nested HEAD surfaces as a SHA-tagged sentinel.
300
+ const nestedGitlink = await readGitlinkState(repoRoot, nestedPath, input.dependencies);
301
+ if (nestedGitlink.readFailed) {
302
+ input.repos.push({
303
+ repoRelativePath: nestedWorkspacePath,
304
+ repoId: nestedWorkspacePath,
305
+ state: "status-read-failed",
306
+ porcelain: "",
307
+ errorDetail: `gitlink probe failed: ${nestedGitlink.errorDetail ?? "unknown"}`,
308
+ });
309
+ continue;
310
+ }
311
+ const nestedEntries = await collectSubmoduleEntries({
312
+ rootCwd: repoRoot,
313
+ workspacePath: nestedPath,
314
+ repoWorkspacePath: nestedWorkspacePath,
315
+ depth: input.depth + 1,
316
+ repos: input.repos,
317
+ dependencies: input.dependencies,
318
+ });
319
+ if (nestedEntries === undefined) {
320
+ // Nested collection failures are recorded in the shared
321
+ // repos array with state status-read-failed; completeness
322
+ // is derived from those states after the top-level loop.
323
+ continue;
324
+ }
325
+ for (const [nestedFile, code] of nestedEntries) {
326
+ // nestedEntries keys are already full workspace paths
327
+ // (prefixed with nestedWorkspacePath).
328
+ expanded.set(nestedFile, code);
329
+ }
330
+ if (gitlinkMutated(nestedGitlink)) {
331
+ expanded.set(gitlinkSentinelKey(nestedWorkspacePath, nestedGitlink), "M ");
332
+ }
333
+ // Drop the child's own directory-level entry for the nested
334
+ // submodule (gitlink ` M nested` or embedded `?? nested/`);
335
+ // its file facts now come from the nested repository.
336
+ expanded.delete(`${input.repoWorkspacePath}/${nestedPath}`);
337
+ }
338
+ }
339
+ return expanded;
340
+ }
341
+ catch (error) {
342
+ input.repos.push({
343
+ repoRelativePath: input.repoWorkspacePath,
344
+ repoId: input.repoWorkspacePath,
345
+ state: "status-read-failed",
346
+ porcelain: "",
347
+ errorDetail: error instanceof Error ? error.message : String(error),
348
+ });
349
+ return undefined;
350
+ }
351
+ }
352
+ function joinFsPath(root, workspacePath) {
353
+ return path.resolve(root, ...workspacePath.split("/"));
354
+ }
355
+ /**
356
+ * Expand a parent porcelain snapshot into workspace file-level facts.
357
+ * Callers pass the already-captured parent porcelain so the parent bytes are
358
+ * captured once and reused.
359
+ */
360
+ export async function expandWorkspaceStatus(input) {
361
+ const dependencies = input.dependencies ?? DEFAULT_WORKSPACE_STATUS_DEPENDENCIES;
362
+ const parentEntries = parsePorcelainEntries(input.parentPorcelain);
363
+ const repos = [
364
+ {
365
+ repoRelativePath: "",
366
+ repoId: "parent",
367
+ state: "clean-status-read",
368
+ porcelain: input.parentPorcelain,
369
+ },
370
+ ];
371
+ const entries = new Map();
372
+ let complete = true;
373
+ const registrations = input.hasGitmodulesFile
374
+ ? await readSubmoduleRegistrations(input.rootCwd, dependencies)
375
+ : [];
376
+ for (const registration of registrations) {
377
+ const initialized = await submoduleWorkingTreeInitialized(input.rootCwd, registration);
378
+ if (!initialized) {
379
+ repos.push({
380
+ repoRelativePath: registration,
381
+ repoId: registration,
382
+ state: "uninitialized",
383
+ porcelain: "",
384
+ });
385
+ continue;
386
+ }
387
+ // Gitlink probe: SHA-compare the parent index gitlink against the
388
+ // child's checked-out HEAD. Only a real HEAD move (e.g. the writer
389
+ // committed inside the submodule) becomes a sentinel; dirty child
390
+ // content stays covered by the child's file-level entries.
391
+ const gitlink = await readGitlinkState(input.rootCwd, registration, dependencies);
392
+ if (gitlink.readFailed) {
393
+ repos.push({
394
+ repoRelativePath: registration,
395
+ repoId: registration,
396
+ state: "status-read-failed",
397
+ porcelain: "",
398
+ errorDetail: `gitlink probe failed: ${gitlink.errorDetail ?? "unknown"}`,
399
+ });
400
+ complete = false;
401
+ continue;
402
+ }
403
+ const submoduleEntries = await collectSubmoduleEntries({
404
+ rootCwd: input.rootCwd,
405
+ workspacePath: registration,
406
+ repoWorkspacePath: registration,
407
+ depth: 1,
408
+ repos,
409
+ dependencies,
410
+ });
411
+ if (submoduleEntries === undefined) {
412
+ complete = false;
413
+ continue;
414
+ }
415
+ for (const [workspacePath, code] of submoduleEntries) {
416
+ entries.set(workspacePath, code);
417
+ }
418
+ if (gitlinkMutated(gitlink)) {
419
+ entries.set(gitlinkSentinelKey(registration, gitlink), "M ");
420
+ }
421
+ }
422
+ // Completeness is derived from the recorded repo states: any
423
+ // status-read-failed repository at any depth (top-level or nested)
424
+ // makes the observation incomplete, so nested failures bubble up.
425
+ // (.gitmodules read failures throw and fail the whole observation.)
426
+ const completeNow = complete &&
427
+ !repos.some((repo) => repo.state === "status-read-failed");
428
+ // Parent entries: keep everything except directory-level gitlink entries
429
+ // of submodules whose own status was read successfully AND whose gitlink
430
+ // is unchanged. When the parent still reports the submodule as modified
431
+ // (gitlink differs from the index — the writer committed inside the
432
+ // submodule, or checked out another commit), keep a sentinel entry so the
433
+ // change stays visible in the expanded delta instead of silently
434
+ // vanishing behind the child's clean status. The child's clean file-level
435
+ // status cannot distinguish "gitlink untouched" from "gitlink moved";
436
+ // only the parent entry can, so it is preserved whenever non-clean.
437
+ const expandedRegistrations = new Set(repos
438
+ .filter((repo) => repo.repoRelativePath !== "" &&
439
+ repo.state === "clean-status-read")
440
+ .map((repo) => repo.repoRelativePath));
441
+ for (const [parentPath, code] of parentEntries) {
442
+ if (expandedRegistrations.has(parentPath)) {
443
+ // Directory-level submodule entries are dropped: file-level facts
444
+ // come from the child repos, and a moved gitlink is surfaced by the
445
+ // SHA probe as a sentinel during the registration loop. Porcelain
446
+ // codes cannot distinguish dirty content from a moved HEAD, so no
447
+ // code-based sentinel may be derived here.
448
+ continue;
449
+ }
450
+ if (entries.has(parentPath))
451
+ continue; // child facts take precedence
452
+ entries.set(parentPath, code);
453
+ }
454
+ return {
455
+ parentPorcelain: input.parentPorcelain,
456
+ entries,
457
+ repos,
458
+ complete: completeNow,
459
+ };
460
+ }
461
+ /** Prefix marking a preserved parent gitlink fact (mutated submodule HEAD). */
462
+ export const GITLINK_SENTINEL_PREFIX = "\u0000gitlink\u0000/";
463
+ /** Serialize expanded entries back into sorted porcelain-v1-shaped lines. */
464
+ export function synthesizeExpandedPorcelain(entries) {
465
+ const lines = [];
466
+ for (const [filePath, code] of [...entries.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
467
+ lines.push(`${code} ${filePath}`);
468
+ }
469
+ return lines.join("\n") + (lines.length > 0 ? "\n" : "");
470
+ }
471
+ /** Check for a `.gitmodules` file at the repository root. */
472
+ export async function hasGitmodulesFile(rootCwd) {
473
+ try {
474
+ await access(path.resolve(rootCwd, ".gitmodules"));
475
+ return true;
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ }
@@ -1,6 +1,7 @@
1
1
  import { constants as fsConstants } from "node:fs";
2
2
  import { lstat, open, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
4
5
  function assertContained(root, target, label) {
5
6
  if (target === root || !target.startsWith(`${root}${path.sep}`)) {
6
7
  throw new Error(`${label}: path escapes boundary: ${target}`);
@@ -81,8 +82,7 @@ export async function readFileBoundarySafe(input) {
81
82
  if (current.isSymbolicLink() ||
82
83
  !current.isFile() ||
83
84
  current.nlink !== 1 ||
84
- current.dev !== opened.dev ||
85
- current.ino !== opened.ino) {
85
+ !sameFileIdentity(opened, current)) {
86
86
  throw new Error(`${input.label}: path changed while opening`);
87
87
  }
88
88
  return await handle.readFile();
@@ -12,7 +12,8 @@ function deleteHeaderCaseInsensitive(headers, name) {
12
12
  }
13
13
  function configuredHeader(headers, name) {
14
14
  const key = Object.keys(headers ?? {}).find((entry) => entry.toLowerCase() === name.toLowerCase());
15
- return key ? headers?.[key] : undefined;
15
+ const value = key ? headers?.[key] : undefined;
16
+ return typeof value === "string" ? value : undefined;
16
17
  }
17
18
  function setStaticHeader(headers, configured, name, defaultValue) {
18
19
  deleteHeaderCaseInsensitive(headers, name);