@azure-id/orc 1.8.2 → 1.9.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 (65) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README-id.md +68 -51
  3. package/README.md +60 -45
  4. package/bin/cli.js +301 -39
  5. package/bin/graph-audit.js +235 -0
  6. package/bin/graph-extract.js +308 -3
  7. package/bin/graph-gain.js +23 -3
  8. package/bin/graph-map.js +9 -1
  9. package/bin/graph-query.js +152 -10
  10. package/bin/graph-shard.js +6 -1
  11. package/bin/graph-signals.js +287 -4
  12. package/bin/graph.js +717 -658
  13. package/bin/verify-contracts.js +277 -61
  14. package/bin/verify-package.js +19 -1
  15. package/bin/webui/fixtures/knowledge.js +21 -4
  16. package/bin/webui/i18n/en/knowledge.json +5 -2
  17. package/bin/webui/i18n/id/knowledge.json +5 -2
  18. package/bin/webui/js/panels/knowledge.js +21 -2
  19. package/mock-run/orc-quick.md +141 -113
  20. package/package.json +1 -1
  21. package/templates/agents/MODEL-MAPPING.md +15 -5
  22. package/templates/agents/orc-executor-haiku-4-5.md +19 -1
  23. package/templates/agents/orc-executor-opus-4-7-high.md +19 -1
  24. package/templates/agents/orc-executor-opus-4-7-med.md +19 -1
  25. package/templates/agents/orc-executor-opus-4-8-high.md +19 -1
  26. package/templates/agents/orc-executor-opus-5-high.md +19 -1
  27. package/templates/agents/orc-executor-opus-5-low.md +19 -1
  28. package/templates/agents/orc-executor-opus-5-med.md +19 -1
  29. package/templates/agents/orc-executor-sonnet-4-6-high.md +19 -1
  30. package/templates/agents/orc-executor-sonnet-4-6-med.md +19 -1
  31. package/templates/agents/orc-executor-sonnet-5-high.md +19 -1
  32. package/templates/agents/orc-planner-mini-opus-5-med.md +75 -69
  33. package/templates/agents/orc-planner-mini-sonnet-5-high.md +73 -67
  34. package/templates/agents/orc-recon-opus-5-low.md +109 -0
  35. package/templates/agents/orc-recon-sonnet-4-6-med.md +109 -0
  36. package/templates/commands/orc-mini.md +10 -12
  37. package/templates/commands/orc-quick.md +20 -33
  38. package/templates/hooks/README.md +11 -1
  39. package/templates/hooks/orc-graph-hook.js +53 -7
  40. package/templates/hooks/orc-trace.js +476 -471
  41. package/templates/skills/_shared/code-graph.md +56 -17
  42. package/templates/skills/_shared/phases/execution.md +4 -4
  43. package/templates/skills/_shared/phases/planning.md +3 -3
  44. package/templates/skills/_shared/phases/preflight.md +1 -1
  45. package/templates/skills/_shared/phases/review.md +3 -1
  46. package/templates/skills/_shared/phases/rules.md +172 -159
  47. package/templates/skills/_shared/phases/ship.md +1 -1
  48. package/templates/skills/_shared/phases/trace.md +4 -2
  49. package/templates/skills/_shared/phases/wiki-consult.md +9 -5
  50. package/templates/skills/_shared/read-ladder.md +14 -1
  51. package/templates/skills/_shared/return-validation.md +22 -0
  52. package/templates/skills/context-combiner/SKILL.md +13 -13
  53. package/templates/skills/orc/SKILL.md +1 -1
  54. package/templates/skills/orc/subskills/orc-execution/core.md +171 -159
  55. package/templates/skills/orc-fast/SKILL.md +3 -3
  56. package/templates/skills/orc-mini/SKILL.md +145 -137
  57. package/templates/skills/orc-mini/examples/mini-run-mock.md +64 -50
  58. package/templates/skills/orc-mini/references/complexity.md +119 -0
  59. package/templates/skills/orc-quick/README.md +495 -423
  60. package/templates/skills/orc-quick/SKILL.md +157 -212
  61. package/templates/skills/orc-quick/references/context-doc.md +145 -114
  62. package/templates/skills/orc-quick/references/defect.md +101 -0
  63. package/templates/skills/orc-quick/references/dispatch-gate.md +55 -24
  64. package/templates/skills/orc-quick/references/gh-mode.md +148 -127
  65. package/templates/skills/orc-quick/references/look.md +107 -0
@@ -35,6 +35,51 @@ const COCHANGE_MIN = 3;
35
35
  const COCHANGE_SINCE = "6.months";
36
36
  const COCHANGE_ROWS = 12;
37
37
 
38
+ // ── the complexity thresholds (v1.9.1 V2, DE-12) ────────────────────────────
39
+ // They live HERE, with their reasons, because the lane used to hold them in
40
+ // prose and count the rows itself. A threshold in two places is a threshold
41
+ // that drifts. `orc-mini/references/complexity.md` quotes these by NAME and
42
+ // VALUE, and `bin/verify-contracts.js` fails if one side is renamed alone.
43
+ //
44
+ // COMPLEXITY_CALLER_FILES callers in this many files OUTSIDE the plan mean the
45
+ // change reaches further than one executor can see.
46
+ // COMPLEXITY_CALLERS this many confident callers outside the plan mean the
47
+ // same, by weight rather than by spread.
48
+ // COMPLEXITY_COCHANGE_MIN a file that changed WITH an operand this many times
49
+ // and is not in the plan is a file the plan forgot.
50
+ // It is COCHANGE_MIN by construction — the same
51
+ // evidence, read for a different question.
52
+ const COMPLEXITY_CALLER_FILES = 4;
53
+ const COMPLEXITY_CALLERS = 8;
54
+ const COMPLEXITY_COCHANGE_MIN = COCHANGE_MIN;
55
+
56
+ // ── the THIN thresholds (v1.9.1 A1, DE-4) ───────────────────────────────────
57
+ // A graph is THIN when it holds so few named symbols that most questions asked
58
+ // of it answer "not found" — and nothing, until now, said so. All three must
59
+ // hold, because each one alone is a false alarm:
60
+ //
61
+ // THIN_MIN_FILES a small repository has few symbols because it is small.
62
+ // THIN_PER_FILE under three named symbols per file, a `ctx` on a name the
63
+ // request mentions is more likely to miss than to hit.
64
+ // THIN_ZERO_SHARE the decisive one: files the parser read as EMPTY. Measured
65
+ // at 0.97 for `.vue` on a real front-end project, where the
66
+ // graph@5 parser did not read Options API object members
67
+ // (graph@6, W5b, does — THIN stays for what it still misses).
68
+ const THIN_MIN_FILES = 30;
69
+ const THIN_PER_FILE = 3;
70
+ const THIN_ZERO_SHARE = 0.4;
71
+
72
+ function isThin(density, files) {
73
+ if (!density) return false;
74
+ return (
75
+ Number(files) >= THIN_MIN_FILES &&
76
+ Number(density.symbols_per_file) < THIN_PER_FILE &&
77
+ Number(density.zero_share) >= THIN_ZERO_SHARE
78
+ );
79
+ }
80
+
81
+ const plural = (n, word, many) => `${n} ${n === 1 ? word : many || word + "s"}`;
82
+
38
83
  function git(root, args) {
39
84
  return spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
40
85
  }
@@ -142,12 +187,27 @@ function graphChanges(claudeDir, root, opts) {
142
187
  }
143
188
  }
144
189
 
190
+ // V1 (v1.9.1) `--files=`: the lane used to take the whole answer and keep the
191
+ // rows whose file it had just written. The CLI restricts them instead, so the
192
+ // rows it never wanted never enter its context.
193
+ const only = Array.isArray(opts.files) && opts.files.length ? new Set(opts.files.map(norm)) : null;
194
+ if (only) for (const k of [...hunks.keys()]) if (!only.has(k)) hunks.delete(k);
195
+
145
196
  const rows = [];
146
197
  const files = [...hunks.keys()].filter((f) => model.fileSet.has(f)).sort();
147
198
  const indexable = new Set(Object.keys(G.LANG_BY_EXT));
148
199
  const absent = [...hunks.keys()]
149
200
  .filter((f) => !model.fileSet.has(f) && indexable.has(path.posix.extname(f).toLowerCase()))
150
201
  .sort();
202
+ // Which KIND of edge reaches each test file: a route beats a call, a call
203
+ // beats a name. A file that reaches two ways is counted once, under the
204
+ // strongest — otherwise the three numbers do not add up to the total.
205
+ const TEST_RANK = { route: 3, call: 2, name: 1 };
206
+ const testKind = new Map();
207
+ const noteTest = (file, kind) => {
208
+ const had = testKind.get(file);
209
+ if (!had || TEST_RANK[kind] > TEST_RANK[had]) testKind.set(file, kind);
210
+ };
151
211
  for (const rel of files) {
152
212
  const ranges = hunks.get(rel);
153
213
  for (const sym of (model.byFile[rel] || {}).symbols || []) {
@@ -157,6 +217,8 @@ function graphChanges(claudeDir, root, opts) {
157
217
  const c = Q.callersOf(model, sym);
158
218
  const callerFiles = [...new Set(c.confident.map((x) => x.sym.file))];
159
219
  const tests = [...new Set([...callerFiles.filter((f) => Q.TEST_FILE.test(f)), ...testsByName(model, rel)])];
220
+ for (const x of c.confident) if (Q.TEST_FILE.test(x.sym.file)) noteTest(x.sym.file, x.state === "ROUTE" ? "route" : "call");
221
+ for (const f of testsByName(model, rel)) noteTest(f, "name");
160
222
  // The reason is part of the answer. "high" with nothing behind it is a
161
223
  // number nobody can check, and nobody should act on.
162
224
  const why = [];
@@ -185,6 +247,39 @@ function graphChanges(claudeDir, root, opts) {
185
247
  rows.sort((a, b) => order[a.risk] - order[b.risk] || b.fan_in - a.fan_in || a.file.localeCompare(b.file) || a.lines[0] - b.lines[0]);
186
248
  const counts = { high: 0, medium: 0, low: 0 };
187
249
  for (const r of rows) counts[r.risk]++;
250
+
251
+ // V1: the CLI computes the line. Both lines used to be assembled by the
252
+ // orchestrator from `symbols[]`, which is why `symbols[]` had to travel into
253
+ // the main session to be counted there. Now the numbers travel and the rows
254
+ // do not.
255
+ const testFiles = new Set(rows.flatMap((r) => r.tests));
256
+ const byKind = { route: 0, call: 0, name: 0 };
257
+ for (const f of testFiles) byKind[testKind.get(f) || "name"]++;
258
+ const totals = {
259
+ symbols: rows.length,
260
+ callers: rows.reduce((a, r) => a + r.fan_in, 0),
261
+ caller_files: new Set(rows.flatMap((r) => r.caller_files)).size,
262
+ maybe: rows.reduce((a, r) => a + r.maybe_callers, 0),
263
+ tests: testFiles.size,
264
+ tests_by_kind: byKind,
265
+ high: counts.high,
266
+ medium: counts.medium,
267
+ low: counts.low,
268
+ not_in_graph: absent.length,
269
+ };
270
+ // The PATHS behind `totals.tests`. A count alone cannot be run, and the lane
271
+ // that used to read them out of `symbols[]` no longer receives that array.
272
+ const tests = [...testFiles].sort();
273
+ const worst = rows[0];
274
+ const blast_line = rows.length
275
+ ? `blast radius ${plural(totals.symbols, "symbol")} · callers ${totals.callers} in ${plural(totals.caller_files, "file")}` +
276
+ (totals.maybe ? ` · maybe ${totals.maybe}` : "") +
277
+ ` · tests reach ${totals.tests} · risk ${worst.risk}: ${worst.qname} (${worst.why.join(", ")})`
278
+ : `blast radius none indexed${absent.length ? ` (${plural(absent.length, "file")} not in the graph)` : ""}`;
279
+ const tests_line = totals.tests
280
+ ? `tests reached ${plural(totals.tests, "file")} (ROUTE ${byKind.route} · call ${byKind.call}${byKind.name ? ` · name ${byKind.name}` : ""})`
281
+ : "tests reached none";
282
+
188
283
  return {
189
284
  ok: true,
190
285
  state: "found",
@@ -193,6 +288,10 @@ function graphChanges(claudeDir, root, opts) {
193
288
  not_in_graph: absent,
194
289
  symbols: rows,
195
290
  counts,
291
+ totals,
292
+ tests,
293
+ tests_line,
294
+ blast_line,
196
295
  generation: model.meta.generation || 0,
197
296
  exit: 0,
198
297
  };
@@ -231,9 +330,15 @@ function buildCochange(root) {
231
330
  function graphCochange(claudeDir, root, opts) {
232
331
  const rel = norm(opts.file || "");
233
332
  if (!rel) return { ok: false, reason: "missing-operand", exit: 1 };
234
- const head = git(root, ["rev-parse", "HEAD"]);
235
- if (head.status !== 0) return { ok: false, state: "unavailable", reason: "not-git", exit: 1 };
236
- const at = head.stdout.trim();
333
+ // `opts.at` is the HEAD a caller has already asked git for. V2 asks this
334
+ // question once per DECLARED FILE, and one `rev-parse` per file is one git
335
+ // process per file for an answer that cannot change between them.
336
+ let at = opts.at;
337
+ if (!at) {
338
+ const head = git(root, ["rev-parse", "HEAD"]);
339
+ if (head.status !== 0) return { ok: false, state: "unavailable", reason: "not-git", exit: 1 };
340
+ at = head.stdout.trim();
341
+ }
237
342
 
238
343
  let cache = null;
239
344
  try {
@@ -274,4 +379,182 @@ function graphCochange(claudeDir, root, opts) {
274
379
  };
275
380
  }
276
381
 
277
- module.exports = { importance, testsByName, parseDiff, changedPaths, graphChanges, graphCochange, cochangePath, COCHANGE_MIN, COCHANGE_MAX_FILES };
382
+ // ── complexity (v1.9.1 V2) ──────────────────────────────────────────────────
383
+ // exit codes are `impact`'s — this is an ADDITION to that answer, never a
384
+ // command of its own.
385
+ //
386
+ // The question is mini's: is this change small enough for one executor? It used
387
+ // to be answered by the orchestrator, which ran `impact` and one `cochange` per
388
+ // declared file, read `callers[]` and `rows[]` in its own context, and applied
389
+ // four thresholds written in prose. Now ONE call answers it, the four
390
+ // thresholds are the constants at the top of this file, and the lane prints the
391
+ // line it is handed.
392
+ //
393
+ // The verdict is never a decision. `recommend-orc` is a recommendation the
394
+ // lane OFFERS; the user chooses.
395
+
396
+ // `--risk=<class>[@<file:line>],…`. An unparsable item is DROPPED and named,
397
+ // never guessed at: a risk class the reader cannot check is worse than none.
398
+ function parseRisk(spec) {
399
+ const risk = [];
400
+ const ignored = [];
401
+ for (const raw of String(spec || "").split(",")) {
402
+ const item = raw.trim();
403
+ if (!item) continue;
404
+ const m = /^([a-z][a-z0-9-]*)(?:@(.+))?$/.exec(item);
405
+ if (!m) {
406
+ ignored.push(item);
407
+ continue;
408
+ }
409
+ risk.push({ class: m[1], ...(m[2] ? { at: m[2] } : {}) });
410
+ }
411
+ return { risk, ignored };
412
+ }
413
+
414
+ const CONFIDENT_STATES = new Set(["LOCAL", "IMPORT", "UNIQUE", "ROUTE"]);
415
+
416
+ function complexityOf(claudeDir, root, model, rels, opts) {
417
+ const Q = require("./graph-query.js");
418
+ const o = opts || {};
419
+ const operands = new Set(rels.map(norm));
420
+ const targets = rels.flatMap((r) => ((model.byFile[r] || {}).symbols || []).filter((s) => s.kind !== "module"));
421
+
422
+ const callerFiles = new Set();
423
+ let callersOutside = 0;
424
+ let maybe = 0;
425
+ const testFiles = new Set();
426
+ for (const t of targets) {
427
+ const c = Q.callersOf(model, t);
428
+ for (const x of c.confident) {
429
+ const f = x.sym.file;
430
+ if (Q.TEST_FILE.test(f)) testFiles.add(f);
431
+ if (operands.has(f) || !CONFIDENT_STATES.has(x.state)) continue;
432
+ callersOutside++;
433
+ callerFiles.add(f);
434
+ }
435
+ // The stored AMBIGUOUS map knows WHICH file each unresolved call came from,
436
+ // so a caller inside the plan is not counted against it. Without the cache
437
+ // only the total exists, and the total is what is used.
438
+ const m = model.maybeCache;
439
+ if (m && Object.prototype.hasOwnProperty.call(m, t.id)) {
440
+ for (const [file, n] of m[t.id]) if (!operands.has(norm(file))) maybe += n;
441
+ } else maybe += c.maybe;
442
+ }
443
+ // Tests are counted the two ways `changes` counts them: by CALL (an edge into
444
+ // an operand's symbol) and by NAME (`x.test.js` beside `x.js`). When the
445
+ // `impact` answer is handed in, ITS list wins: it followed the call chain to
446
+ // the configured depth and through routes, and a second, shallower count of
447
+ // the same thing beside it would be two answers to one question.
448
+ for (const rel of rels) for (const f of testsByName(model, rel)) testFiles.add(f);
449
+ const testsReaching = Array.isArray(o.tests) ? new Set(o.tests).size : testFiles.size;
450
+
451
+ // The partners history says change WITH an operand and the plan does not
452
+ // name. One process: `graphCochange` reads the per-HEAD cache, which is built
453
+ // at most once for the whole call.
454
+ const cochange = [];
455
+ const seenPartner = new Set();
456
+ const perFile = [];
457
+ const head = git(root, ["rev-parse", "HEAD"]);
458
+ const at = head.status === 0 ? head.stdout.trim() : null;
459
+ for (const rel of rels) {
460
+ let r = null;
461
+ try {
462
+ r = at ? graphCochange(claudeDir, root, { file: rel, at }) : null;
463
+ } catch (_) {
464
+ r = null;
465
+ }
466
+ const partners = r && r.ok ? r.rows.filter((x) => x.together >= COMPLEXITY_COCHANGE_MIN) : [];
467
+ perFile.push({ file: rel, partners: partners.map((x) => ({ path: x.file, count: x.together })) });
468
+ for (const x of partners) {
469
+ const p = norm(x.file);
470
+ if (operands.has(p) || seenPartner.has(p)) continue;
471
+ seenPartner.add(p);
472
+ cochange.push({ path: p, count: x.together, of: x.of });
473
+ }
474
+ }
475
+ cochange.sort((a, b) => b.count - a.count || a.path.localeCompare(b.path));
476
+
477
+ const { risk, ignored } = parseRisk(o.risk);
478
+ const riskGiven = typeof o.risk === "string";
479
+
480
+ const why = [];
481
+ if (callerFiles.size >= COMPLEXITY_CALLER_FILES) why.push(`callers in ${callerFiles.size} files (≥ ${COMPLEXITY_CALLER_FILES})`);
482
+ if (callersOutside >= COMPLEXITY_CALLERS) why.push(`callers ${callersOutside} (≥ ${COMPLEXITY_CALLERS})`);
483
+ for (const x of risk) why.push(`risk ${x.class}`);
484
+ for (const x of cochange) why.push(`cochange ${x.path} x${x.count} not in plan`);
485
+ for (const x of ignored) why.push(`risk item ignored: ${x}`);
486
+ if (!riskGiven) why.push("risk: not given");
487
+
488
+ const verdict =
489
+ callerFiles.size >= COMPLEXITY_CALLER_FILES || callersOutside >= COMPLEXITY_CALLERS || risk.length > 0 || cochange.length > 0 ? "recommend-orc" : "mini-ok";
490
+
491
+ const riskText = risk.length ? risk.map((x) => `${x.class}${x.at ? ` (${x.at})` : ""}`).join(", ") : riskGiven ? "none" : "not given";
492
+ const cochangeText = cochange.length ? cochange.map((x) => `${x.path} x${x.count} not in plan`).join(", ") : "none";
493
+ const maybeText = maybe ? ` · maybe ${maybe}` : "";
494
+ const line =
495
+ verdict === "mini-ok"
496
+ ? `complexity: mini-ok — ${rels.length} files · confident callers ${callersOutside} in ${callerFiles.size} files${maybeText} · tests reach ${testsReaching} · risk ${riskText} · cochange ${cochangeText}`
497
+ : `complexity: recommend /orc — ${rels.length} files · callers ${callersOutside} in ${callerFiles.size} files${maybeText} · tests reach ${testsReaching} · risk ${riskText} · cochange ${cochangeText}`;
498
+
499
+ return {
500
+ verdict,
501
+ why,
502
+ numbers: {
503
+ files: rels.length,
504
+ callers_outside: callersOutside,
505
+ caller_files_outside: callerFiles.size,
506
+ maybe,
507
+ tests_reaching: testsReaching,
508
+ cochange_missing: cochange,
509
+ risk,
510
+ risk_given: riskGiven,
511
+ },
512
+ line,
513
+ thresholds: { caller_files: COMPLEXITY_CALLER_FILES, callers: COMPLEXITY_CALLERS, cochange_min: COMPLEXITY_COCHANGE_MIN },
514
+ // V3: `graph_facts` in the shape the lane pastes. `map` is null — the lane
515
+ // already holds the `map --focus` card and fills it there, so the same card
516
+ // is never computed twice.
517
+ facts: {
518
+ map: null,
519
+ impact: rels.map((rel) => {
520
+ const syms = ((model.byFile[rel] || {}).symbols || []).filter((s) => s.kind !== "module");
521
+ const cf = new Set();
522
+ let n = 0;
523
+ const tf = new Set();
524
+ for (const t of syms) {
525
+ for (const x of Q.callersOf(model, t).confident) {
526
+ if (Q.TEST_FILE.test(x.sym.file)) tf.add(x.sym.file);
527
+ if (!CONFIDENT_STATES.has(x.state)) continue;
528
+ n++;
529
+ cf.add(x.sym.file);
530
+ }
531
+ }
532
+ for (const f of testsByName(model, rel)) tf.add(f);
533
+ return { file: rel, confident_callers: n, caller_files: cf.size, tests_reaching: tf.size };
534
+ }),
535
+ cochange: perFile,
536
+ generation: model.meta.generation || 0,
537
+ },
538
+ };
539
+ }
540
+
541
+ module.exports = {
542
+ importance,
543
+ testsByName,
544
+ parseDiff,
545
+ changedPaths,
546
+ graphChanges,
547
+ graphCochange,
548
+ cochangePath,
549
+ complexityOf,
550
+ parseRisk,
551
+ COCHANGE_MIN,
552
+ COCHANGE_MAX_FILES,
553
+ isThin,
554
+ THIN_MIN_FILES,
555
+ THIN_PER_FILE,
556
+ THIN_ZERO_SHARE,
557
+ COMPLEXITY_CALLER_FILES,
558
+ COMPLEXITY_CALLERS,
559
+ COMPLEXITY_COCHANGE_MIN,
560
+ };