aiki-cli 0.2.2 → 0.3.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +113 -6
  2. package/README.md +119 -30
  3. package/dist/bench/arms.js +10 -9
  4. package/dist/bench/harness.js +13 -10
  5. package/dist/bench/idea-lane-rotation.js +237 -0
  6. package/dist/bench/idea-v3-bench.js +506 -0
  7. package/dist/bench/idea-v3-rating.js +582 -0
  8. package/dist/bench/results.js +10 -3
  9. package/dist/bench/scoring/decision-insights.js +112 -0
  10. package/dist/bench/scoring/seeded-bugs.js +4 -0
  11. package/dist/cli/bench.js +180 -3
  12. package/dist/cli/doctor.js +56 -24
  13. package/dist/cli/index.js +31 -6
  14. package/dist/cli/resolve.js +7 -6
  15. package/dist/cli/resume.js +18 -0
  16. package/dist/cli/run.js +66 -8
  17. package/dist/council/view.js +446 -117
  18. package/dist/orchestration/calculations.js +97 -0
  19. package/dist/orchestration/context.js +37 -9
  20. package/dist/orchestration/decision-dossier.js +320 -0
  21. package/dist/orchestration/decision-graph.js +256 -0
  22. package/dist/orchestration/engine.js +5 -2
  23. package/dist/orchestration/evidence-pack.js +46 -0
  24. package/dist/orchestration/idea-lanes.js +20 -0
  25. package/dist/orchestration/jsonStage.js +72 -0
  26. package/dist/orchestration/legacy-idea-adapter.js +102 -0
  27. package/dist/orchestration/modes.js +39 -0
  28. package/dist/orchestration/preflight.js +203 -0
  29. package/dist/orchestration/quick-analysis.js +93 -0
  30. package/dist/orchestration/stages/cr-ladder.js +80 -0
  31. package/dist/orchestration/stages/s10-render.js +574 -150
  32. package/dist/orchestration/stages/s4-analyze.js +12 -7
  33. package/dist/orchestration/stages/s5-drift.js +9 -9
  34. package/dist/orchestration/stages/s6-positions.js +10 -0
  35. package/dist/orchestration/stages/s7-decision-graph.js +76 -0
  36. package/dist/orchestration/stages/s8-verify.js +153 -46
  37. package/dist/orchestration/stages/s8b-rebuttal.js +208 -0
  38. package/dist/orchestration/stages/s9-judge.js +329 -108
  39. package/dist/orchestration/stages/s9b-plan.js +118 -85
  40. package/dist/orchestration/url-sources.js +200 -0
  41. package/dist/providers/codex.js +2 -1
  42. package/dist/providers/spawn.js +5 -0
  43. package/dist/schemas/index.js +638 -15
  44. package/dist/skills/idea-refinement/analyst.md +18 -14
  45. package/dist/skills/idea-refinement/economics-delivery.md +7 -0
  46. package/dist/skills/idea-refinement/market-adoption.md +7 -0
  47. package/dist/storage/runs.js +12 -4
  48. package/dist/tui/app.js +53 -14
  49. package/dist/tui/format.js +4 -5
  50. package/dist/tui/smart-entry.js +17 -1
  51. package/dist/tui/timeline.js +2 -4
  52. package/dist/workflows/code-review.js +4 -2
  53. package/dist/workflows/idea-refinement.js +118 -46
  54. package/package.json +12 -4
  55. package/dist/orchestration/stages/s0-grill.js +0 -79
  56. package/dist/orchestration/stages/s1-intent.js +0 -25
  57. package/dist/orchestration/stages/s2-misread.js +0 -76
  58. package/dist/orchestration/stages/s3-prompts.js +0 -55
  59. package/dist/orchestration/stages/s6-claims.js +0 -56
  60. package/dist/orchestration/stages/s7-disagreement.js +0 -134
@@ -1,24 +1,46 @@
1
1
  // S9b — idea validation plan. This is the one report-v3 model call after the judge: it turns the
2
2
  // adjudicated risks, blind spots, and open questions into anchored validation actions. Rendering stays
3
- // deterministic; if the planner fails or produces unanchored actions, we write a flagged fallback plan.
4
- import { ActionPlan } from '../../schemas/index.js';
3
+ // deterministic; if the planner fails or produces unanchored actions, we write flagged unavailability.
4
+ import { ActionPlan, FeatureBacklog, ImplementationPlan } from '../../schemas/index.js';
5
+ import { z } from 'zod';
5
6
  import { BudgetExceeded, isFatal } from '../context.js';
6
7
  import { jsonCall } from '../jsonStage.js';
7
8
  import { loadSkill } from '../skills.js';
8
9
  import { mergeOpenQuestions } from './s10-render.js';
9
- const S9B_PROMPT = `ROLE: Validation planner. You write the next actions for a decision-maker after an
10
- idea council has already debated and judged the idea. Do not write a build roadmap. Write only validation
11
- actions that test unsettled risks, blind spots, or open questions. Cheapest decisive test first.{{SKILL}}
10
+ const PlannerOutput = z.object({
11
+ actions: z.array(z.object({
12
+ order: z.number().int().min(1).optional(),
13
+ action: z.string().min(1),
14
+ why: z.string().min(1),
15
+ validates: z.string().min(1),
16
+ effort: z.string().min(1).optional(),
17
+ kill_signal: z.string().min(1),
18
+ }).strict()).min(1).max(7),
19
+ sequencing_note: z.string().min(1),
20
+ feature_backlog: FeatureBacklog.optional(),
21
+ implementation_plan: ImplementationPlan.optional(),
22
+ }).strict();
23
+ const S9B_PROMPT = `ROLE: Validation planner. You write the requested practical outputs after an idea
24
+ council has debated and judged the idea. The actions list remains validation work: test unsettled risks,
25
+ blind spots, or open questions, with the cheapest decisive test first.{{SKILL}}
12
26
 
13
27
  Output ONLY JSON matching the ActionPlan schema:
14
28
  - actions: 1-7 ordered actions, each imperative and concrete.
15
29
  - validates MUST anchor to one of:
16
- - a dispute id from upheld_risks, e.g. "D3"
30
+ - a graph claim id from upheld_risks, e.g. "G3"
17
31
  - a blind spot label as "blind:<label>"
18
32
  - an open-question prefix as "Q:<question prefix>"
19
33
  - why ties the action to the risk, blind spot, or question.
20
34
  - kill_signal is the result that should stop or reshape the idea.
35
+ - Preserve the chair's numeric distinctions: operating break-even is not capital payback, and a target cap
36
+ is not a known cost. Do not introduce or reinterpret a number that is absent from decision_snapshot.
21
37
  - sequencing_note explains why this order is cheapest and decisive.
38
+ - Read requested_outputs in CONTEXT. When it includes FEATURE_BACKLOG, include feature_backlog with
39
+ must/should/later items {feature,user_value,rationale,effort:S|M|L} and wont items {feature,reason}.
40
+ Prioritize the smallest judge-impressing golden path; MUST is required, not a wishlist.
41
+ - When requested_outputs includes IMPLEMENTATION_PLAN, include implementation_plan.milestones with
42
+ {order,timebox,outcome,tasks,acceptance_test}. This is a concrete build sequence, not validation prose.
43
+ - Do not omit a requested output and do not invent an unrequested product surface.
22
44
 
23
45
  CONTEXT: {{CONTEXT_JSON}}`;
24
46
  function clip(s, n) {
@@ -31,29 +53,37 @@ function sevRank(s) {
31
53
  function norm(s) {
32
54
  return s.trim().toLowerCase();
33
55
  }
34
- function questionAnchor(q) {
35
- return `Q:${clip(q, 90)}`;
36
- }
37
- function blindAnchor(b) {
38
- return `blind:${b}`;
39
- }
40
- function upheldRisks(map, judgeReport) {
41
- const claimById = new Map([...map.consensus, ...map.unique].map((c) => [c.id, c.statement]));
56
+ function unresolvedRisks(graph, judgeReport) {
57
+ const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
42
58
  const rulingById = new Map(judgeReport.adjudications.map((a) => [a.id, a]));
43
- return map.contradictions
44
- .map((d) => {
45
- const adj = rulingById.get(d.id);
59
+ const risks = graph.claims
60
+ .map((claim) => {
61
+ const adj = rulingById.get(claim.id);
46
62
  if (adj?.ruling !== 'UPHOLD')
47
63
  return null;
48
64
  return {
49
- id: d.id,
50
- assumption: d.claim_ids.map((id) => claimById.get(id) ?? id).join(' / '),
51
- severity: [...d.attacks].sort((a, b) => sevRank(a.severity) - sevRank(b.severity))[0]?.severity ?? 'MED',
65
+ id: claim.id,
66
+ assumption: claim.proposition,
67
+ severity: claim.sensitivity === 'DECISIVE' ? 'HIGH' : claim.sensitivity === 'MATERIAL' ? 'MED' : 'LOW',
52
68
  reasoning: adj.reasoning,
53
69
  };
54
70
  })
55
- .filter((r) => r !== null)
56
- .sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
71
+ .filter((risk) => risk !== null);
72
+ const seen = new Set(risks.map((risk) => risk.id));
73
+ for (const hole of graph.holes.evidence) {
74
+ if (seen.has(hole.claim_id))
75
+ continue;
76
+ const claim = claimById.get(hole.claim_id);
77
+ if (!claim)
78
+ continue;
79
+ risks.push({
80
+ id: claim.id,
81
+ assumption: claim.proposition,
82
+ severity: claim.sensitivity === 'DECISIVE' ? 'HIGH' : claim.sensitivity === 'MATERIAL' ? 'MED' : 'LOW',
83
+ reasoning: hole.reason,
84
+ });
85
+ }
86
+ return risks.sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
57
87
  }
58
88
  export function buildActionPlannerPrompt(input, skill) {
59
89
  return S9B_PROMPT
@@ -62,7 +92,7 @@ export function buildActionPlannerPrompt(input, skill) {
62
92
  }
63
93
  export function validAnchor(anchor, anchors) {
64
94
  const a = anchor.trim();
65
- if (anchors.disputeIds.includes(a))
95
+ if (anchors.claimIds.includes(a))
66
96
  return true;
67
97
  if (a.toLowerCase().startsWith('blind:')) {
68
98
  const label = norm(a.slice('blind:'.length));
@@ -80,104 +110,107 @@ export function validAnchor(anchor, anchors) {
80
110
  }
81
111
  return false;
82
112
  }
83
- export function anchoredActionPlan(plan, anchors) {
113
+ export function anchoredActionPlan(plan, anchors, requestedOutputs = []) {
84
114
  const actions = plan.actions
85
115
  .filter((a) => validAnchor(a.validates, anchors))
86
116
  .sort((a, b) => a.order - b.order)
87
117
  .map((a, i) => ({ ...a, order: i + 1 }));
88
118
  if (actions.length === 0)
89
119
  return null;
90
- return { actions, sequencing_note: plan.sequencing_note };
120
+ if (requestedOutputs.includes('FEATURE_BACKLOG') && !plan.feature_backlog)
121
+ return null;
122
+ if (requestedOutputs.includes('IMPLEMENTATION_PLAN') && !plan.implementation_plan)
123
+ return null;
124
+ return {
125
+ actions,
126
+ sequencing_note: plan.sequencing_note,
127
+ ...(requestedOutputs.includes('FEATURE_BACKLOG') && plan.feature_backlog ? { feature_backlog: plan.feature_backlog } : {}),
128
+ ...(requestedOutputs.includes('IMPLEMENTATION_PLAN') && plan.implementation_plan ? { implementation_plan: plan.implementation_plan } : {}),
129
+ };
91
130
  }
92
- export function fallbackActionPlan(contract, map, judgeReport, openQuestions) {
93
- const risks = upheldRisks(map, judgeReport);
94
- const actions = [];
95
- for (const r of risks) {
96
- actions.push({
97
- order: actions.length + 1,
98
- action: `Test whether "${clip(r.assumption, 120)}" is true with the smallest realistic sample.`,
99
- why: `The chair upheld this as a load-bearing ${r.severity} risk.`,
100
- validates: r.id,
101
- effort: 'S',
102
- kill_signal: 'The assumption fails in the sample or only works outside the intended use case.',
103
- });
104
- }
105
- for (const b of map.blind_spots) {
106
- actions.push({
107
- order: actions.length + 1,
108
- action: `Answer the ${b} gap with one concrete evidence source.`,
109
- why: 'No analyst examined this dimension enough to rely on it.',
110
- validates: blindAnchor(b),
111
- effort: 'S',
112
- kill_signal: 'The answer exposes a hard blocker or makes the target user/use case incoherent.',
113
- });
114
- }
115
- for (const q of openQuestions) {
116
- actions.push({
117
- order: actions.length + 1,
118
- action: `Resolve this question: ${clip(q, 140)}`,
119
- why: 'The analysts identified it as an answer that could change the verdict.',
120
- validates: questionAnchor(q),
121
- effort: 'S',
122
- kill_signal: 'The answer contradicts the value proposition or removes the target user.',
123
- });
124
- }
125
- if (actions.length === 0) {
126
- const q = `What evidence would change the verdict for ${clip(contract.task, 80)}?`;
127
- actions.push({
128
- order: 1,
129
- action: 'Run one cheap test of the core user need before investing more.',
130
- why: 'The council produced no unsettled anchored item, so validate the core demand directly.',
131
- validates: questionAnchor(q),
132
- effort: 'S',
133
- kill_signal: 'Target users do not recognize the problem or would not switch from existing alternatives.',
134
- });
135
- }
131
+ export function normalizeEffort(raw) {
132
+ const value = raw?.trim().toLowerCase();
133
+ if (value === 's' || value === 'm' || value === 'l')
134
+ return value.toUpperCase();
135
+ const match = value?.match(/(\d+(?:\.\d+)?)(?:\s*-\s*(\d+(?:\.\d+)?))?\s*(minute|hour|day|week|month|year)s?/);
136
+ if (!match)
137
+ return 'M';
138
+ const amount = Number(match[2] ?? match[1]);
139
+ const unit = match[3];
140
+ const days = amount * (unit === 'minute' ? 1 / 1440 : unit === 'hour' ? 1 / 24 : unit === 'day' ? 1 : unit === 'week' ? 7 : unit === 'month' ? 30 : 365);
141
+ return days <= 2 ? 'S' : days <= 14 ? 'M' : 'L';
142
+ }
143
+ export function normalizePlannerOutput(plan) {
136
144
  return ActionPlan.parse({
137
- actions: actions.slice(0, 7).map((a, i) => ({ ...a, order: i + 1 })),
138
- sequencing_note: 'Start with upheld risks, then blind spots, then open questions; stop when a kill signal fires.',
145
+ actions: plan.actions.map((action, i) => ({
146
+ ...action,
147
+ order: i + 1,
148
+ effort: normalizeEffort(action.effort),
149
+ })),
150
+ sequencing_note: plan.sequencing_note,
151
+ ...(plan.feature_backlog ? { feature_backlog: plan.feature_backlog } : {}),
152
+ ...(plan.implementation_plan ? { implementation_plan: plan.implementation_plan } : {}),
139
153
  });
140
154
  }
141
- export async function s9bPlan(ctx, contract, seats, map, judgeReport) {
155
+ function unavailablePlan(reason, contract, risks, blindSpots, openQuestions) {
156
+ const unresolved = openQuestions.length ? openQuestions : [
157
+ ...risks.map((risk) => `What evidence would resolve whether ${risk.assumption}?`),
158
+ ...blindSpots.map((spot) => `What evidence resolves the ${spot} gap?`),
159
+ ];
160
+ return {
161
+ kind: 'PlannerUnavailable',
162
+ reason,
163
+ unresolved_questions: (unresolved.length ? unresolved : [`What evidence would change the verdict for ${clip(contract.task, 100)}?`]).slice(0, 10),
164
+ };
165
+ }
166
+ export async function s9bPlan(ctx, contract, seats, graph, judgeReport) {
142
167
  const openQuestions = mergeOpenQuestions(seats);
143
- const risks = upheldRisks(map, judgeReport);
168
+ const requestedOutputs = contract.requested_outputs
169
+ ?? ['DECISION'];
170
+ const risks = unresolvedRisks(graph, judgeReport);
171
+ const blindSpots = graph.holes.coverage.map((hole) => hole.label);
144
172
  const anchors = {
145
- disputeIds: risks.map((r) => r.id),
146
- blindSpots: map.blind_spots,
173
+ claimIds: risks.map((r) => r.id),
174
+ blindSpots,
147
175
  openQuestions,
148
176
  };
149
177
  const fallback = async (flag) => {
150
178
  ctx.addFlag(flag);
151
- const plan = fallbackActionPlan(contract, map, judgeReport, openQuestions);
179
+ const plan = unavailablePlan(flag === 'plan_skipped' ? 'budget_exhausted' : 'planner_failed', contract, risks, blindSpots, openQuestions);
152
180
  await ctx.writer.writeJson('action-plan', plan);
153
181
  return plan;
154
182
  };
155
- if (ctx.budget.limit - ctx.budget.used < 2)
183
+ if (ctx.budget.limit - ctx.budget.used < 1)
156
184
  return fallback('plan_skipped');
157
185
  const prompt = buildActionPlannerPrompt({
158
186
  task: contract.task,
159
187
  recommendation: judgeReport.recommendation,
160
188
  conditions: judgeReport.conditions,
189
+ decision_snapshot: judgeReport.decision_snapshot,
161
190
  upheld_risks: risks,
162
- blind_spots: map.blind_spots,
191
+ blind_spots: blindSpots,
163
192
  open_questions: openQuestions,
193
+ requested_outputs: requestedOutputs,
164
194
  }, loadSkill('idea-refinement', 'planner'));
165
195
  try {
166
- const first = await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-plan', prompt, ActionPlan);
167
- const anchored = anchoredActionPlan(first, anchors);
196
+ const first = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-plan', prompt, PlannerOutput, {
197
+ repair: ctx.budget.limit - ctx.budget.used >= 2,
198
+ }));
199
+ const anchored = anchoredActionPlan(first, anchors, requestedOutputs);
168
200
  if (anchored) {
169
201
  await ctx.writer.writeJson('action-plan', anchored);
170
202
  return anchored;
171
203
  }
172
204
  if (ctx.budget.limit - ctx.budget.used < 1)
173
205
  return fallback('plan_fallback');
174
- const repair = `${prompt}\n\n---\nYour previous plan had no actions with valid anchors.\n` +
175
- `Valid dispute ids: ${anchors.disputeIds.join(', ') || '(none)'}\n` +
206
+ const repair = `${prompt}\n\n---\nYour previous plan had no actions with valid anchors or omitted a requested deliverable.\n` +
207
+ `Valid graph claim ids: ${anchors.claimIds.join(', ') || '(none)'}\n` +
176
208
  `Valid blind spots: ${anchors.blindSpots.join(' | ') || '(none)'}\n` +
177
209
  `Valid open questions: ${anchors.openQuestions.join(' | ') || '(none)'}\n` +
178
- `Output ONLY corrected JSON with every action anchored to one of those values.`;
179
- const repaired = await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-anchor-repair', repair, ActionPlan);
180
- const repairedAnchored = anchoredActionPlan(repaired, anchors);
210
+ `Required outputs: ${requestedOutputs.join(', ')}\n` +
211
+ `Output ONLY corrected JSON with every action anchored and every requested deliverable present.`;
212
+ const repaired = normalizePlannerOutput(await jsonCall(ctx, ctx.handle(ctx.roles.judge), 'S9b-anchor-repair', repair, PlannerOutput, { repair: false }));
213
+ const repairedAnchored = anchoredActionPlan(repaired, anchors, requestedOutputs);
181
214
  if (repairedAnchored) {
182
215
  await ctx.writer.writeJson('action-plan', repairedAnchored);
183
216
  return repairedAnchored;
@@ -0,0 +1,200 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { isIP } from 'node:net';
4
+ import { UrlSourceSet, } from '../schemas/index.js';
5
+ const MAX_SOURCES = 5;
6
+ const MAX_RESPONSE_BYTES = 500_000;
7
+ const MAX_CONTENT_CHARS = 30_000;
8
+ const MAX_REDIRECTS = 3;
9
+ export function extractPublicUrls(input) {
10
+ const matches = input.match(/https?:\/\/[^\s<>"']+/gi) ?? [];
11
+ const seen = new Set();
12
+ const urls = [];
13
+ for (const match of matches) {
14
+ const cleaned = match.replace(/[.,;:!?\]\)}]+$/g, '');
15
+ if (seen.has(cleaned))
16
+ continue;
17
+ seen.add(cleaned);
18
+ urls.push(cleaned);
19
+ if (urls.length === MAX_SOURCES)
20
+ break;
21
+ }
22
+ return urls;
23
+ }
24
+ function isPrivateAddress(address) {
25
+ if (address === '::' || address === '::1')
26
+ return true;
27
+ if (address.toLowerCase().startsWith('fe80:') || address.toLowerCase().startsWith('fc') || address.toLowerCase().startsWith('fd'))
28
+ return true;
29
+ const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
30
+ const ipv4 = mapped ?? (isIP(address) === 4 ? address : undefined);
31
+ if (!ipv4)
32
+ return false;
33
+ const [a, b] = ipv4.split('.').map(Number);
34
+ return a === 0
35
+ || a === 10
36
+ || a === 127
37
+ || (a === 169 && b === 254)
38
+ || (a === 172 && b >= 16 && b <= 31)
39
+ || (a === 192 && b === 168)
40
+ || (a === 100 && b >= 64 && b <= 127)
41
+ || a >= 224;
42
+ }
43
+ async function assertPublicUrl(url, resolveHostname) {
44
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
45
+ throw new Error('only public http/https URLs are supported');
46
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
47
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname === '169.254.169.254') {
48
+ throw new Error('private or local URLs are not allowed');
49
+ }
50
+ if (isIP(hostname) && isPrivateAddress(hostname))
51
+ throw new Error('private or local URLs are not allowed');
52
+ if (!resolveHostname || isIP(hostname))
53
+ return;
54
+ const addresses = await lookup(hostname, { all: true });
55
+ if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) {
56
+ throw new Error('private or local URLs are not allowed');
57
+ }
58
+ }
59
+ function npmRegistryUrl(url) {
60
+ if (url.hostname !== 'npmjs.com' && url.hostname !== 'www.npmjs.com')
61
+ return undefined;
62
+ const match = url.pathname.match(/^\/package\/(.+?)\/?$/);
63
+ if (!match)
64
+ return undefined;
65
+ const name = decodeURIComponent(match[1]);
66
+ return `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
67
+ }
68
+ function decodeHtml(value) {
69
+ const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
70
+ return value.replace(/&(#x[\da-f]+|#\d+|\w+);/gi, (entity, code) => {
71
+ if (code[0] === '#') {
72
+ const radix = code[1]?.toLowerCase() === 'x' ? 16 : 10;
73
+ const value = Number.parseInt(code.slice(radix === 16 ? 2 : 1), radix);
74
+ return Number.isFinite(value) ? String.fromCodePoint(value) : entity;
75
+ }
76
+ return named[code.toLowerCase()] ?? entity;
77
+ });
78
+ }
79
+ function readableHtml(html) {
80
+ const titleMatch = html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
81
+ const title = titleMatch ? decodeHtml(titleMatch[1].replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim() : undefined;
82
+ const content = decodeHtml(html
83
+ .replace(/<(script|style|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
84
+ .replace(/<[^>]+>/g, ' '))
85
+ .replace(/\s+/g, ' ')
86
+ .trim();
87
+ return { title: title || undefined, content };
88
+ }
89
+ function sha256(content) {
90
+ return createHash('sha256').update(content).digest('hex');
91
+ }
92
+ async function readLimited(response) {
93
+ if (!response.body)
94
+ return '';
95
+ const reader = response.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let bytes = 0;
98
+ let body = '';
99
+ while (true) {
100
+ const { done, value } = await reader.read();
101
+ if (done)
102
+ return body + decoder.decode();
103
+ bytes += value.byteLength;
104
+ if (bytes > MAX_RESPONSE_BYTES) {
105
+ await reader.cancel();
106
+ throw new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes`);
107
+ }
108
+ body += decoder.decode(value, { stream: true });
109
+ }
110
+ }
111
+ function failure(id, url, accessedAt, status, error, finalUrl) {
112
+ return { id, url, final_url: finalUrl, status, accessed_at: accessedAt, error };
113
+ }
114
+ async function fetchWithRedirects(startUrl, fetchImpl, resolveHostname) {
115
+ let current = new URL(startUrl);
116
+ for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect++) {
117
+ await assertPublicUrl(current, resolveHostname);
118
+ const response = await fetchImpl(current.toString(), {
119
+ redirect: 'manual',
120
+ headers: { accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.1' },
121
+ });
122
+ if (response.status < 300 || response.status >= 400)
123
+ return { response, finalUrl: current.toString() };
124
+ const location = response.headers.get('location');
125
+ if (!location)
126
+ return { response, finalUrl: current.toString() };
127
+ if (redirect === MAX_REDIRECTS)
128
+ throw new Error(`more than ${MAX_REDIRECTS} redirects`);
129
+ current = new URL(location, current);
130
+ }
131
+ throw new Error('redirect limit exceeded');
132
+ }
133
+ async function snapshotOne(sourceUrl, index, options) {
134
+ const id = `U${index + 1}`;
135
+ const accessedAt = (options.now?.() ?? new Date()).toISOString();
136
+ const fetchImpl = options.fetchImpl ?? fetch;
137
+ try {
138
+ const original = new URL(sourceUrl);
139
+ await assertPublicUrl(original, options.fetchImpl === undefined);
140
+ const adapterUrl = npmRegistryUrl(original);
141
+ const { response, finalUrl } = await fetchWithRedirects(adapterUrl ?? sourceUrl, fetchImpl, options.fetchImpl === undefined);
142
+ const declaredLength = Number(response.headers.get('content-length') ?? 0);
143
+ if (declaredLength > MAX_RESPONSE_BYTES) {
144
+ return failure(id, sourceUrl, accessedAt, 'FAILED', `response exceeds ${MAX_RESPONSE_BYTES} bytes`, finalUrl);
145
+ }
146
+ const body = await readLimited(response);
147
+ const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() || 'text/plain';
148
+ const blocked = response.status === 403
149
+ || response.status === 429
150
+ || response.status === 503
151
+ || /cdn-cgi\/challenge|just a moment|captcha|access denied/i.test(body);
152
+ if (blocked) {
153
+ return failure(id, sourceUrl, accessedAt, 'BLOCKED', `site blocked automated access (HTTP ${response.status})`, finalUrl);
154
+ }
155
+ if (!response.ok)
156
+ return failure(id, sourceUrl, accessedAt, 'FAILED', `HTTP ${response.status}`, finalUrl);
157
+ let title;
158
+ let content;
159
+ if (adapterUrl) {
160
+ const metadata = JSON.parse(body);
161
+ const name = typeof metadata.name === 'string' ? metadata.name : original.pathname.split('/').at(-1) ?? 'npm package';
162
+ const version = typeof metadata.version === 'string' ? metadata.version : 'unknown version';
163
+ title = `${name} ${version}`;
164
+ content = [
165
+ `Package: ${name}`,
166
+ `Version: ${version}`,
167
+ typeof metadata.description === 'string' ? `Description: ${metadata.description}` : '',
168
+ typeof metadata.homepage === 'string' ? `Homepage: ${metadata.homepage}` : '',
169
+ typeof metadata.readme === 'string' ? `README:\n${metadata.readme}` : '',
170
+ ].filter(Boolean).join('\n');
171
+ }
172
+ else if (contentType === 'text/html' || /<html\b|<body\b/i.test(body)) {
173
+ ({ title, content } = readableHtml(body));
174
+ }
175
+ else {
176
+ content = body.replace(/\s+/g, ' ').trim();
177
+ }
178
+ content = content.slice(0, MAX_CONTENT_CHARS).trim();
179
+ if (!content)
180
+ return failure(id, sourceUrl, accessedAt, 'FAILED', 'source contained no readable text', finalUrl);
181
+ return {
182
+ id,
183
+ url: sourceUrl,
184
+ final_url: finalUrl,
185
+ status: 'FETCHED',
186
+ title,
187
+ content_type: contentType,
188
+ accessed_at: accessedAt,
189
+ sha256: sha256(content),
190
+ content,
191
+ };
192
+ }
193
+ catch (error) {
194
+ return failure(id, sourceUrl, accessedAt, 'FAILED', error instanceof Error ? error.message : String(error));
195
+ }
196
+ }
197
+ export async function snapshotUrlSources(input, options = {}) {
198
+ const sources = await Promise.all(extractPublicUrls(input).map((url, index) => snapshotOne(url, index, options)));
199
+ return UrlSourceSet.parse({ sources });
200
+ }
@@ -17,7 +17,8 @@ import { runAdapter } from './adapter-core.js';
17
17
  const codexSpec = {
18
18
  id: 'codex',
19
19
  buildArgs(req, flags) {
20
- const args = ['exec', '--skip-git-repo-check'];
20
+ // `--search` is a verified root option: it must precede `exec` (Codex 0.144.1).
21
+ const args = req.research ? ['--search', 'exec', '--skip-git-repo-check'] : ['exec', '--skip-git-repo-check'];
21
22
  if (req.readOnly !== false && flags.readOnlyFlag === 'sandbox')
22
23
  args.push('-s', 'read-only');
23
24
  if (flags.model)
@@ -81,6 +81,7 @@ export const spawnCapture = (bin, args, { cwd, timeoutMs, env, signal }) => {
81
81
  let notFound = false;
82
82
  let settled = false;
83
83
  const child = spawn(bin, args, { cwd, env, detached: true, stdio: ['ignore', fd, 'pipe'] });
84
+ child.unref(); // a child that survives the group-kill must never keep our process alive post-resolve
84
85
  // SIGKILL the whole detached process group. Shared by the timeout and the Ctrl+C abort (T8).
85
86
  const killGroup = () => {
86
87
  try {
@@ -99,6 +100,10 @@ export const spawnCapture = (bin, args, { cwd, timeoutMs, env, signal }) => {
99
100
  const timer = setTimeout(() => {
100
101
  timedOut = true;
101
102
  killGroup();
103
+ // Bound the call at timeoutMs even if the group-kill missed a detached child holding our pipe/fd
104
+ // (observed with the claude CLI). Waiting on 'close' alone lets one hung call overrun the wall-clock
105
+ // deadline by an unbounded amount; force-resolve instead. A late 'close' is ignored (`settled`).
106
+ finish(null, 'SIGKILL');
102
107
  }, timeoutMs);
103
108
  // Ctrl+C: kill the in-flight child immediately so no orphaned metered call survives (§472, T8).
104
109
  const onAbort = () => killGroup();