yam-flow 2.7.0 → 2.8.0

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 (40) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +29 -0
  3. package/COMMANDS.md +42 -5
  4. package/README.md +40 -8
  5. package/ROADMAP.md +17 -6
  6. package/dist/bin/yam.js +441 -70
  7. package/dist/lib/external-updates.d.ts +9 -0
  8. package/dist/lib/external-updates.js +232 -4
  9. package/dist/lib/next-step.d.ts +61 -0
  10. package/dist/lib/next-step.js +340 -0
  11. package/dist/lib/release-auth-readiness.d.ts +81 -0
  12. package/dist/lib/release-auth-readiness.js +106 -0
  13. package/dist/lib/release-registry-status.d.ts +18 -0
  14. package/dist/lib/release-registry-status.js +37 -0
  15. package/dist/lib/scout-receipt.d.ts +89 -0
  16. package/dist/lib/scout-receipt.js +542 -0
  17. package/dist/lib/skill-installation.d.ts +30 -0
  18. package/dist/lib/skill-installation.js +470 -82
  19. package/dist/lib/trust-kernel.d.ts +2 -0
  20. package/dist/lib/trust-kernel.js +21 -2
  21. package/package.json +22 -3
  22. package/references/final-report.md +15 -1
  23. package/references/next-step.md +67 -0
  24. package/references/scout-wide-scan.md +16 -0
  25. package/references/study-note.md +4 -2
  26. package/references/tool-trust-layer.md +2 -0
  27. package/skills/deep/SKILL.md +2 -0
  28. package/skills/mission/SKILL.md +2 -0
  29. package/skills/quick/SKILL.md +1 -0
  30. package/skills/scout/SKILL.md +19 -9
  31. package/skills/ueye/SKILL.md +1 -0
  32. package/src/bin/yam.ts +445 -68
  33. package/src/lib/external-updates.ts +262 -4
  34. package/src/lib/next-step.ts +397 -0
  35. package/src/lib/release-auth-readiness.ts +148 -0
  36. package/src/lib/release-registry-status.ts +55 -0
  37. package/src/lib/scout-receipt.ts +593 -0
  38. package/src/lib/skill-installation.ts +542 -87
  39. package/src/lib/trust-kernel.ts +25 -2
  40. package/yam.manifest.json +4 -3
package/src/bin/yam.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  import {
51
51
  inspectSkillInstallation,
52
52
  installSkillSetTransactional,
53
+ planSkillSetInstallation,
53
54
  uninstallSkillSetSafely
54
55
  } from '../lib/skill-installation.js';
55
56
  import {
@@ -74,6 +75,19 @@ import {
74
75
  createMediaProviderReceipt,
75
76
  verifyMediaProviderReceipt
76
77
  } from '../lib/media-provider-receipt.js';
78
+ import {
79
+ createScoutReceipt,
80
+ verifyScoutReceiptFile
81
+ } from '../lib/scout-receipt.js';
82
+ import {
83
+ buildNextStep,
84
+ verifyNextStep
85
+ } from '../lib/next-step.js';
86
+ import { releaseRegistryStatusFromChecks } from '../lib/release-registry-status.js';
87
+ import {
88
+ buildReleaseAuthReadiness,
89
+ inspectTrustedPublisherWorkflowText
90
+ } from '../lib/release-auth-readiness.js';
77
91
  import {
78
92
  BoundedInputError,
79
93
  GENERAL_STDIN_MAX_BYTES,
@@ -210,7 +224,9 @@ Usage:
210
224
  yam proof [dir|--from file] [--route route] [--truth status] [--command text] [--evidence text]
211
225
  yam proof write [dir] [--format json|md] [--out file] [--route route] [--truth status] [--command text]
212
226
  yam study-note check [dir] [--report file|--text text] [--json]
227
+ yam next-step <report|verify> [--spec file|--receipt file] [--json]
213
228
  yam loop report [--route route] [--intent text] [--stage id:status:note] [--evidence text] [--json]
229
+ yam scout receipt <create|verify> [options]
214
230
  yam ueye capture --url URL --out screenshot.png [--viewport 1440x900] [--full-page] [--json]
215
231
  yam ueye compare --reference ref.png --actual screenshot.png [--json]
216
232
  yam ueye preflight [dir] [--json]
@@ -229,13 +245,13 @@ Usage:
229
245
  yam update apply --all [--json]
230
246
  yam benchmark report [--baseline n] [--current n] [--unit ms] [--target lower|higher] [--json]
231
247
  yam benchmark report --candidate-digest sha256 --baseline-digest sha256 --sample seed:candidate:baseline --unit score --evidence-source text [--min-samples n] [--min-mean-delta n] [--min-win-rate n] [--json]
232
- yam release report [--json]
248
+ yam release report [--auth-mode auto|oidc|token] [--json]
233
249
  yam safety [text...]
234
250
  yam memory <init|add|list|summary|resolve> [dir] [options]
235
251
  yam hook <status|enable|disable|run> [lite|study-note] [--global|--project dir]
236
252
  yam template <project|ueye|mission|proof|tuning>
237
253
  yam tune-log [dir]
238
- yam install [--replace-user-skill name]
254
+ yam install [--dry-run] [--json] [--replace-user-skill name]
239
255
  yam uninstall
240
256
  yam doctor [--json]
241
257
  yam examples
@@ -261,10 +277,20 @@ async function rmrf(target) {
261
277
  await fsp.rm(target, { recursive: true, force: true });
262
278
  }
263
279
 
264
- function installReplacementSkills(args: string[] = []) {
280
+ function installOptions(args: string[] = []) {
265
281
  const skills: string[] = [];
282
+ let dryRun = false;
283
+ let json = false;
266
284
  for (let index = 0; index < args.length; index += 1) {
267
285
  const arg = String(args[index] || '');
286
+ if (arg === '--dry-run') {
287
+ dryRun = true;
288
+ continue;
289
+ }
290
+ if (arg === '--json') {
291
+ json = true;
292
+ continue;
293
+ }
268
294
  if (arg === '--replace-user-skill') {
269
295
  const skill = String(args[index + 1] || '');
270
296
  if (!skill || skill.startsWith('--')) {
@@ -282,21 +308,31 @@ function installReplacementSkills(args: string[] = []) {
282
308
  }
283
309
  throw new Error(`unknown yam install argument: ${arg}`);
284
310
  }
285
- return [...new Set(skills)];
311
+ if (json && !dryRun) throw new Error('yam install --json currently requires --dry-run');
312
+ return { replaceSkills: [...new Set(skills)], dryRun, json };
286
313
  }
287
314
 
288
315
  async function install(args: string[] = []) {
289
- const replaceSkills = installReplacementSkills(args);
290
- const result = await installSkillSetTransactional({
316
+ const options = installOptions(args);
317
+ const installInput = {
291
318
  sourceRoot: ROOT,
292
319
  destination: DEST,
293
320
  codexMirror: CODEX_MIRROR,
294
321
  packageName: String(PACKAGE_JSON.name || 'yam-flow'),
295
322
  version: VERSION,
296
323
  skills: SKILLS,
297
- replaceSkills,
324
+ replaceSkills: options.replaceSkills,
298
325
  legacySkills: LEGACY_SKILLS,
299
326
  retiredSkills: RETIRED_SKILLS
327
+ };
328
+ if (options.dryRun) {
329
+ const plan = await planSkillSetInstallation(installInput);
330
+ printJsonOrHuman(plan, options.json, 'yam install dry run');
331
+ if (!plan.ready) process.exitCode = 1;
332
+ return;
333
+ }
334
+ const result = await installSkillSetTransactional({
335
+ ...installInput
300
336
  });
301
337
  console.log(`yam installed to ${DEST}`);
302
338
  console.log(`install receipt: ${result.receiptPath}`);
@@ -374,6 +410,92 @@ async function readJson(file): Promise<AnyRecord> {
374
410
  return JSON.parse(await fsp.readFile(file, 'utf8'));
375
411
  }
376
412
 
413
+ async function readBoundedRegularJson(file, label = 'JSON input', maxBytes = GENERAL_STDIN_MAX_BYTES): Promise<AnyRecord> {
414
+ const requested = path.resolve(expandHome(String(file || '')));
415
+ const physicalParent = await fsp.realpath(path.dirname(requested));
416
+ const target = path.join(physicalParent, path.basename(requested));
417
+ const parents = await captureAbsoluteRegularDirectoryPath(physicalParent, label);
418
+ const noFollow = process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number'
419
+ ? fs.constants.O_NOFOLLOW
420
+ : 0;
421
+ const before = await fsp.lstat(target);
422
+ if (!before.isFile() || before.isSymbolicLink()) {
423
+ throw new Error(`${label} must be a regular JSON file no larger than ${maxBytes} bytes`);
424
+ }
425
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | noFollow);
426
+ try {
427
+ const opened = await handle.stat();
428
+ await revalidateAbsoluteRegularDirectoryPath(parents, label);
429
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > maxBytes) {
430
+ throw new Error(`${label} must be a stable regular JSON file no larger than ${maxBytes} bytes`);
431
+ }
432
+ const bytes = await readBoundedFileHandle(handle, maxBytes);
433
+ const afterOpened = await handle.stat();
434
+ const after = await fsp.lstat(target);
435
+ await revalidateAbsoluteRegularDirectoryPath(parents, label);
436
+ if (
437
+ bytes.length > maxBytes
438
+ || after.isSymbolicLink()
439
+ || !after.isFile()
440
+ || after.dev !== opened.dev
441
+ || after.ino !== opened.ino
442
+ || afterOpened.dev !== opened.dev
443
+ || afterOpened.ino !== opened.ino
444
+ || afterOpened.size !== bytes.length
445
+ ) {
446
+ throw new Error(`${label} changed identity or size while being read`);
447
+ }
448
+ return JSON.parse(bytes.toString('utf8'));
449
+ } finally {
450
+ await handle.close();
451
+ }
452
+ }
453
+
454
+ type CliPathIdentity = { path: string; dev: number; ino: number };
455
+
456
+ async function captureAbsoluteRegularDirectoryPath(target: string, label: string) {
457
+ const absolute = path.resolve(target);
458
+ const filesystemRoot = path.parse(absolute).root;
459
+ const relative = path.relative(filesystemRoot, absolute);
460
+ const identities: CliPathIdentity[] = [];
461
+ let current = filesystemRoot;
462
+ for (const segment of relative ? ['', ...relative.split(path.sep)] : ['']) {
463
+ if (segment) current = path.join(current, segment);
464
+ const stat = await fsp.lstat(current);
465
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
466
+ throw new Error(`${label} parent must be a regular physical directory: ${current}`);
467
+ }
468
+ identities.push({ path: current, dev: stat.dev, ino: stat.ino });
469
+ }
470
+ return identities;
471
+ }
472
+
473
+ async function revalidateAbsoluteRegularDirectoryPath(identities: CliPathIdentity[], label: string) {
474
+ for (const identity of identities) {
475
+ const stat = await fsp.lstat(identity.path);
476
+ if (
477
+ stat.isSymbolicLink()
478
+ || !stat.isDirectory()
479
+ || stat.dev !== identity.dev
480
+ || stat.ino !== identity.ino
481
+ ) {
482
+ throw new Error(`${label} parent changed identity while being read: ${identity.path}`);
483
+ }
484
+ }
485
+ }
486
+
487
+ async function readBoundedFileHandle(handle: fsp.FileHandle, maxBytes: number) {
488
+ const buffer = Buffer.alloc(maxBytes + 1);
489
+ let total = 0;
490
+ while (total < buffer.length) {
491
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
492
+ if (bytesRead === 0) break;
493
+ total += bytesRead;
494
+ }
495
+ if (total > maxBytes) throw new Error(`JSON input exceeds ${maxBytes} bytes while being read`);
496
+ return buffer.subarray(0, total);
497
+ }
498
+
377
499
  async function readJsonOrDefault(file, fallback: AnyRecord = {}): Promise<AnyRecord> {
378
500
  try {
379
501
  return JSON.parse(await fsp.readFile(file, 'utf8'));
@@ -845,8 +967,9 @@ Usage:
845
967
  yam study-note check [dir] [--report file|--text text] [--json]
846
968
 
847
969
  Notes:
848
- Read-only guard. It checks whether changed work has a Study Note and whether relevant UI/CSS/DB hygiene was reported.
849
- It does not generate, edit, or infer the Study Note for you.
970
+ Read-only guard. It checks whether changed work has a complete Study Note followed immediately by Next step,
971
+ including relevant UI/CSS/DB hygiene and the ordered fix-first/planned handoff contract.
972
+ It does not generate, edit, or infer either section for you.
850
973
  `);
851
974
  }
852
975
 
@@ -867,6 +990,7 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
867
990
  const needsStudyNote = changeScopeAvailable && changedFiles.length > 0;
868
991
  const scopeAwareStatus = (passes) => changeScopeAvailable ? (!needsStudyNote || passes ? 'pass' : 'fail') : 'partial';
869
992
  const hygieneRequirements = studyNoteHygieneRequirements(changedFiles);
993
+ const studyNoteText = studyNoteSectionText(reportText);
870
994
  const checks = [
871
995
  studyNoteGuardCheck(
872
996
  'changed_files',
@@ -876,16 +1000,24 @@ async function buildStudyNoteGuardResult(dir, reportText = '', reportSource = 'n
876
1000
  : 'Git changed-file scope is unavailable; do not treat this project as clean and determine Study Note need manually'
877
1001
  ),
878
1002
  studyNoteGuardCheck('study_note_present', scopeAwareStatus(hasStudyNoteMarker(reportText)), 'final report should include a Study Note when project artifacts changed'),
879
- studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(reportText)), 'Study Note should explain what the touched code/artifact does'),
880
- studyNoteGuardCheck('execution_point', scopeAwareStatus(/(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(reportText)), 'Study Note should explain where or when the touched code/artifact runs or is read'),
881
- studyNoteGuardCheck('before_after_or_change', scopeAwareStatus(/(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(reportText)), 'Study Note should explain what changed from before to after'),
882
- studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(reportText)), 'Study Note should describe the expected behavior or result'),
883
- studyNoteGuardCheck('syntax_or_structure', scopeAwareStatus(/(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(reportText)), 'Study Note should include one useful syntax or structure insight'),
884
- studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(reportText)), 'Study Note should say what was checked'),
885
- studyNoteGuardCheck('limits_or_uncertainty', scopeAwareStatus(/(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(reportText)), 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains')
1003
+ studyNoteGuardCheck('role_or_responsibility', scopeAwareStatus(/(\brole\b|\bresponsib|\bdoes\b|역할|기능)/i.test(studyNoteText)), 'Study Note should explain what the touched code/artifact does'),
1004
+ studyNoteGuardCheck('execution_point', scopeAwareStatus(/(execution|\bruns?\b|\bloads?\b|\brenders?\b|validates?|builds?|publishes?|read by|실행|로드|렌더|검사|검증|빌드|게시|배포|읽)/i.test(studyNoteText)), 'Study Note should explain where or when the touched code/artifact runs or is read'),
1005
+ studyNoteGuardCheck('before_after_or_change', scopeAwareStatus(/(\bbefore\b|\bafter\b|before\/after|changed|change meaning|바뀌|변경|수정)/i.test(studyNoteText)), 'Study Note should explain what changed from before to after'),
1006
+ studyNoteGuardCheck('expected_behavior', scopeAwareStatus(/(expected|should|will|result|behavior|예상|기대|결과|동작|되어야|하게 됨)/i.test(studyNoteText)), 'Study Note should describe the expected behavior or result'),
1007
+ studyNoteGuardCheck('syntax_or_structure', scopeAwareStatus(/(syntax|structure|schema|\bapi\b|function|array|field|type|condition|문법|구조|스키마|함수|배열|필드|타입|조건)/i.test(studyNoteText)), 'Study Note should include one useful syntax or structure insight'),
1008
+ studyNoteGuardCheck('verification', scopeAwareStatus(/(verification|verified|checked|tested|검증|확인|테스트)/i.test(studyNoteText)), 'Study Note should say what was checked'),
1009
+ studyNoteGuardCheck('limits_or_uncertainty', scopeAwareStatus(/(limits?|uncertain|unknown|not checked|remaining|한계|불확실|모르는|미확인|남은)/i.test(studyNoteText)), 'Study Note should say what remains uncertain or explicitly state that no meaningful uncertainty remains'),
1010
+ studyNoteGuardCheck('next_step_present', scopeAwareStatus(hasNextStepMarker(reportText)), 'changed-artifact reports should include a Next step section'),
1011
+ studyNoteGuardCheck('single_study_note_next_step_pair', scopeAwareStatus(hasSingleStudyNoteNextStepPair(reportText)), 'changed-artifact reports should contain one unambiguous Study Note followed by one Next step section'),
1012
+ studyNoteGuardCheck('study_note_then_next_step', scopeAwareStatus(hasStudyNoteThenNextStepOrder(reportText)), 'Next step should be the next report section immediately after Study Note'),
1013
+ studyNoteGuardCheck('next_step_whole_process_scan', scopeAwareStatus(hasNextStepWholeProcessScan(reportText)), 'Next step should include current situation, forward outlook, a critical opinion, and an improvement recommendation'),
1014
+ studyNoteGuardCheck('next_step_ordered_actions', scopeAwareStatus(hasNextStepOrderedActions(reportText)), 'Next step should include an ordered action sequence with fix-first work before planned work'),
1015
+ studyNoteGuardCheck('next_step_evidence_and_ownership', scopeAwareStatus(hasNextStepEvidenceAndOwnership(reportText)), 'Next step should name evidence level or stamp plus the owner route or scope'),
1016
+ studyNoteGuardCheck('next_step_safety_context', scopeAwareStatus(hasNextStepSafetyContext(reportText)), 'Next step should state blocker status, safe retry guidance, and side effects'),
1017
+ studyNoteGuardCheck('next_step_truth_status', scopeAwareStatus(hasNextStepTruthStatus(reportText)), 'Next step should state its evidence-bounded truth status')
886
1018
  ];
887
1019
  for (const requirement of hygieneRequirements) {
888
- checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(reportText, requirement), requirement.note));
1020
+ checks.push(studyNoteGuardCheck(requirement.id, requirementSatisfied(studyNoteText, requirement), requirement.note));
889
1021
  }
890
1022
  const blockers = checks.filter((check) => check.status === 'fail').map((check) => `${check.id}: ${check.next_action}`);
891
1023
  const result = {
@@ -967,6 +1099,121 @@ function hasStudyNoteMarker(text = '') {
967
1099
  return /(study note|study-note|학습\s*노트|스터디\s*노트)/i.test(text);
968
1100
  }
969
1101
 
1102
+ function hasNextStepMarker(text = '') {
1103
+ return /(next step|next-step|다음\s*(?:단계|할\s*일|작업))/i.test(text);
1104
+ }
1105
+
1106
+ function reportSectionMarkers(text = '') {
1107
+ return String(text || '').split(/\r?\n/).map((line, index) => {
1108
+ const trimmed = line.trim();
1109
+ const heading = trimmed.match(/^#{1,6}\s+(.+?)\s*#*\s*$/);
1110
+ const named = trimmed.match(/^(study note|study-note|학습\s*노트|스터디\s*노트|next step|next-step|다음\s*(?:단계|할\s*일|작업))\s*:?[ \t]*$/i);
1111
+ const rawLabel = heading?.[1] || named?.[1] || '';
1112
+ if (!rawLabel) return null;
1113
+ const label = rawLabel.toLowerCase().replace(/[-\s]+/g, ' ').trim();
1114
+ const kind = /(study note|학습 노트|스터디 노트)/i.test(label)
1115
+ ? 'study_note'
1116
+ : /(next step|다음 단계|다음 할 일|다음 작업)/i.test(label)
1117
+ ? 'next_step'
1118
+ : 'other';
1119
+ return { index, kind, level: heading?.[0].match(/^#+/)?.[0].length || 0 };
1120
+ }).filter((item): item is { index: number; kind: string; level: number } => Boolean(item));
1121
+ }
1122
+
1123
+ function hasStudyNoteThenNextStepOrder(text = '') {
1124
+ const markers = reportSectionMarkers(text);
1125
+ const studyIndex = markers.findIndex((item) => item.kind === 'study_note');
1126
+ if (studyIndex < 0) return false;
1127
+ return nextPeerSection(markers, studyIndex)?.kind === 'next_step';
1128
+ }
1129
+
1130
+ function hasSingleStudyNoteNextStepPair(text = '') {
1131
+ const markers = reportSectionMarkers(text);
1132
+ return markers.filter((item) => item.kind === 'study_note').length === 1
1133
+ && markers.filter((item) => item.kind === 'next_step').length === 1;
1134
+ }
1135
+
1136
+ function hasNextStepWholeProcessScan(text = '') {
1137
+ const value = nextStepSectionText(text);
1138
+ const situation = /(situation|current state|현재\s*(?:상황|상태))/i.test(value);
1139
+ const outlook = /(outlook|forward|앞으로|향후|전망)/i.test(value);
1140
+ const critical = /(critical opinion|critique|비판적|비판\s*의견|tradeoff|약점)/i.test(value);
1141
+ const recommendation = /(recommendation|recommend|개선\s*추천|추천\s*의견|권장)/i.test(value);
1142
+ return situation && outlook && critical && recommendation;
1143
+ }
1144
+
1145
+ function hasNextStepOrderedActions(text = '') {
1146
+ const value = nextStepSectionText(text);
1147
+ const lines = value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
1148
+ const actionLines = lines.filter((line) => /^\d+[.)]\s+/.test(line));
1149
+ const labeledLines = lines.filter((line) => /\[(fix[- ]first|우선\s*(?:수정|해결|처리)|planned|계획(?:된|한)?\s*(?:작업|단계)?)\]/i.test(line));
1150
+ if (!actionLines.length || !/^1[.)]\s+/.test(actionLines[0])) return false;
1151
+ if (labeledLines.length !== actionLines.length || labeledLines.some((line) => !/^\d+[.)]\s+/.test(line))) return false;
1152
+ let plannedSeen = false;
1153
+ for (const [index, line] of actionLines.entries()) {
1154
+ const number = Number(line.match(/^(\d+)[.)]/)?.[1] || 0);
1155
+ if (number !== index + 1) return false;
1156
+ const kind = line.match(/\[(fix[- ]first|우선\s*(?:수정|해결|처리)|planned|계획(?:된|한)?\s*(?:작업|단계)?)\]/i)?.[1] || '';
1157
+ if (!kind) return false;
1158
+ const planned = /planned|계획/i.test(kind);
1159
+ if (planned) plannedSeen = true;
1160
+ else if (plannedSeen) return false;
1161
+ }
1162
+ return true;
1163
+ }
1164
+
1165
+ function hasNextStepEvidenceAndOwnership(text = '') {
1166
+ const value = nextStepSectionText(text);
1167
+ const evidence = /(evidence(?:[_ -](?:level|stamp))?|\bL[0-5]\b|근거|증거|검증\s*(?:수준|스탬프))/i.test(value);
1168
+ const ownership = /(owner(?:[_ -](?:route|scope))?|담당|소유|책임\s*(?:경로|범위|주체))/i.test(value);
1169
+ return evidence && ownership;
1170
+ }
1171
+
1172
+ function hasNextStepSafetyContext(text = '') {
1173
+ const value = nextStepSectionText(text);
1174
+ const blocker = /(blocker|blocked(?:[_ -]by)?|차단|막힘|방해)/i.test(value);
1175
+ const safeRetry = /(safe[_ -]retry|안전[^\n]{0,20}재시도|재시도[^\n]{0,20}안전)/i.test(value);
1176
+ const sideEffects = /(side[_ -]effects?|부작용|영향)/i.test(value);
1177
+ return blocker && safeRetry && sideEffects;
1178
+ }
1179
+
1180
+ function hasNextStepTruthStatus(text = '') {
1181
+ return /(truth[_ -]status|진실\s*상태|검증\s*상태)/i.test(nextStepSectionText(text));
1182
+ }
1183
+
1184
+ function nextStepSectionText(text = '') {
1185
+ const value = String(text || '');
1186
+ const lines = value.split(/\r?\n/);
1187
+ const markers = reportSectionMarkers(value);
1188
+ const study = markers.findIndex((item) => item.kind === 'study_note');
1189
+ if (study < 0) return '';
1190
+ const adjacent = nextPeerSectionIndex(markers, study);
1191
+ if (adjacent < 0 || markers[adjacent].kind !== 'next_step') return '';
1192
+ const end = nextPeerSection(markers, adjacent);
1193
+ return lines.slice(markers[adjacent].index + 1, end?.index ?? lines.length).join('\n');
1194
+ }
1195
+
1196
+ function studyNoteSectionText(text = '') {
1197
+ const value = String(text || '');
1198
+ const lines = value.split(/\r?\n/);
1199
+ const markers = reportSectionMarkers(value);
1200
+ const study = markers.findIndex((item) => item.kind === 'study_note');
1201
+ if (study < 0) return '';
1202
+ const end = nextPeerSection(markers, study);
1203
+ return lines.slice(markers[study].index + 1, end?.index ?? lines.length).join('\n');
1204
+ }
1205
+
1206
+ function nextPeerSection(markers: Array<{ index: number; kind: string; level: number }>, start: number) {
1207
+ const nextIndex = nextPeerSectionIndex(markers, start);
1208
+ return nextIndex < 0 ? undefined : markers[nextIndex];
1209
+ }
1210
+
1211
+ function nextPeerSectionIndex(markers: Array<{ index: number; kind: string; level: number }>, start: number) {
1212
+ const current = markers[start];
1213
+ const relative = markers.slice(start + 1).findIndex((item) => current.level === 0 || item.level === 0 || item.level <= current.level);
1214
+ return relative < 0 ? -1 : start + 1 + relative;
1215
+ }
1216
+
970
1217
  function studyNoteGuardCheck(id, status, nextAction) {
971
1218
  return {
972
1219
  id,
@@ -2182,26 +2429,27 @@ async function buildStudyNoteHookContext({ cwd }) {
2182
2429
  const changedFileDetection = gitChangedFileSnapshot(dir);
2183
2430
  const changedFiles = changedFileDetection.files;
2184
2431
  const lines = [
2185
- 'yam Study Note guard active: if this turn changes code, config, release metadata, docs, or project artifacts, include a Study Note in the final response.'
2432
+ 'yam Study Note guard active: if this turn changes code, config, release metadata, docs, or project artifacts, include a Study Note followed immediately by Next step in the final response.'
2186
2433
  ];
2187
2434
  if (!changedFileDetection.available) {
2188
2435
  lines.push('Git changed-file scope is unavailable at prompt time; do not treat this as a clean project.');
2189
- lines.push('Determine manually whether artifacts changed and include the Study Note when they did; the Stop hook will keep this uncertainty visible.');
2436
+ lines.push('Determine manually whether artifacts changed and include Study Note followed by Next step when they did; the Stop hook will keep this uncertainty visible.');
2190
2437
  return lines.join('\n');
2191
2438
  }
2192
2439
  if (!changedFiles.length) {
2193
- lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add the Study Note before final.');
2440
+ lines.push('No changed files were detected at prompt time; if you change artifacts during this turn, add Study Note followed immediately by Next step before final.');
2194
2441
  lines.push('The Study Note Stop hook will check the final response if artifacts are changed later in the turn.');
2195
2442
  return lines.join('\n');
2196
2443
  }
2197
2444
  lines.push(`Changed files detected (${Math.min(changedFiles.length, 8)} shown): ${changedFiles.slice(0, 8).join(', ')}`);
2198
2445
  lines.push('Study Note minimum: touched code/artifact, role, execution point, before/after change, expected behavior, one syntax/structure insight, verification, and limits.');
2446
+ lines.push('Next step minimum: quick whole-process scan, current situation, forward outlook, critical opinion, improvement recommendation, ordered fix-first then planned actions, evidence, ownership, blocker status, safe retry, side effects, and truth status.');
2199
2447
  const hygiene = studyNoteHygieneRequirements(changedFiles);
2200
2448
  if (hygiene.length) {
2201
2449
  lines.push(`Architecture hygiene required: ${hygiene.map((item) => item.id).join(', ')}.`);
2202
2450
  lines.push('Report whether the change avoided dumping unrelated logic into page.tsx, one-off CSS into global.css, or structured product data into broad DB jsonb.');
2203
2451
  }
2204
- lines.push('This prompt reminder does not generate or edit the Study Note; the paired Stop hook requests one correction pass if the final response is incomplete.');
2452
+ lines.push('This prompt reminder does not generate or edit either section; the paired Stop hook requests one correction pass if the final response is incomplete or ordered incorrectly.');
2205
2453
  return lines.join('\n');
2206
2454
  }
2207
2455
 
@@ -2279,12 +2527,15 @@ async function safety(args = []) {
2279
2527
  async function release(args = []) {
2280
2528
  const subcommand = args[0] || 'help';
2281
2529
  if (subcommand !== 'report') {
2282
- console.error('usage: yam release report [--json]');
2530
+ console.error('usage: yam release report [--auth-mode auto|oidc|token] [--json]');
2283
2531
  process.exitCode = 1;
2284
2532
  return;
2285
2533
  }
2286
- const asJson = args.includes('--json');
2287
- const report = runReleaseReport();
2534
+ const flags = parseSimpleFlags(args.slice(1), new Set(['auth-mode', 'json']));
2535
+ const authMode = String(flags.auth_mode || 'auto');
2536
+ if (!['auto', 'oidc', 'token'].includes(authMode)) throw new Error('--auth-mode must be auto, oidc, or token');
2537
+ const asJson = Boolean(flags.json);
2538
+ const report = runReleaseReport(authMode);
2288
2539
  if (asJson) {
2289
2540
  console.log(JSON.stringify(report, null, 2));
2290
2541
  if (!report.ok) process.exitCode = 1;
@@ -2309,7 +2560,7 @@ async function release(args = []) {
2309
2560
  if (report.publish_readiness) {
2310
2561
  console.log(`- Publish readiness: ${report.publish_readiness.status}`);
2311
2562
  console.log(`- Registry: ${report.publish_readiness.registry.url || 'not-verified'}`);
2312
- console.log(`- npm auth: ${report.publish_readiness.auth.status}`);
2563
+ console.log(`- Publish auth: ${report.publish_readiness.auth.mode} (${report.publish_readiness.auth.status})`);
2313
2564
  }
2314
2565
  if (report.failed.length) {
2315
2566
  console.log('- Failed:');
@@ -2332,7 +2583,7 @@ async function release(args = []) {
2332
2583
  if (!report.ok) process.exitCode = 1;
2333
2584
  }
2334
2585
 
2335
- function runReleaseReport() {
2586
+ function runReleaseReport(authMode = 'auto') {
2336
2587
  const startedAt = new Date().toISOString();
2337
2588
  const checks = [
2338
2589
  ['typecheck', ['npm', ['run', 'typecheck']]],
@@ -2347,7 +2598,7 @@ function runReleaseReport() {
2347
2598
  const tarball = releaseTarballProvenance();
2348
2599
  const freshness = releaseFreshness(checks, provenance, tarball);
2349
2600
  const publishBlockerEvidence = publishBlockerEvidenceFromRelease(checks, tarball);
2350
- const publishReadiness = releasePublishReadiness(checks, provenance, tarball, publishBlockerEvidence);
2601
+ const publishReadiness = releasePublishReadiness(checks, provenance, tarball, publishBlockerEvidence, authMode);
2351
2602
  const nextActions = releaseNextActions(checks, provenance, tarball, publishBlockerEvidence, publishReadiness);
2352
2603
  const preliminaryOk = failed.length === 0 && publishReadiness.status === 'ready';
2353
2604
  const readinessReceipt = releaseReadinessReceipt(provenance, freshness, tarball, publishReadiness, preliminaryOk);
@@ -2413,9 +2664,14 @@ function releaseReadinessReceipt(provenance: AnyRecord = {}, freshness: AnyRecor
2413
2664
  truth_status: publishReadiness.registry?.truth_status || 'partial'
2414
2665
  },
2415
2666
  auth: {
2416
- command: 'npm whoami',
2667
+ requested_mode: String(publishReadiness.auth?.requested_mode || 'auto'),
2668
+ mode: String(publishReadiness.auth?.mode || 'manual_token'),
2669
+ command: String(publishReadiness.auth?.command || 'npm whoami'),
2670
+ required_for_selected_mode: Boolean(publishReadiness.auth?.required_for_selected_mode),
2417
2671
  status: String(publishReadiness.auth?.status || 'not_verified'),
2418
- account: publishReadiness.auth?.status === 'authenticated' ? 'observed_redacted' : '',
2672
+ account: String(publishReadiness.auth?.account || ''),
2673
+ local_token: publishReadiness.auth?.local_token || null,
2674
+ trusted_publisher: publishReadiness.auth?.trusted_publisher || null,
2419
2675
  truth_status: publishReadiness.auth?.truth_status || 'partial'
2420
2676
  },
2421
2677
  git: {
@@ -2517,14 +2773,36 @@ function releaseFreshness(checks = [], provenance: AnyRecord = {}, tarball: AnyR
2517
2773
  };
2518
2774
  }
2519
2775
 
2520
- function releasePublishReadiness(checks = [], provenance: AnyRecord = {}, tarball: AnyRecord = {}, publishBlockers = []) {
2776
+ function releasePublishReadiness(checks = [], provenance: AnyRecord = {}, tarball: AnyRecord = {}, publishBlockers = [], authMode = 'auto') {
2521
2777
  const failedChecks = checks.filter((check) => check.status !== 'passed');
2522
2778
  const registryProbe = runReadOnlyCommandResult('npm', ['config', 'get', 'registry']);
2523
2779
  const registryUrl = registryProbe.stdout.trim() || String(PACKAGE_JSON.publishConfig?.registry || 'https://registry.npmjs.org/');
2524
2780
  const whoamiProbe = runReadOnlyCommandResult('npm', ['whoami', '--registry', registryUrl]);
2525
- const registryStatus = releaseRegistryStatusFromChecks(checks);
2781
+ const trustedPublisherWorkflowPath = '.github/workflows/release.yml';
2782
+ const trustedPublisherWorkflowFile = path.join(ROOT, trustedPublisherWorkflowPath);
2783
+ const trustedPublisherWorkflowExists = fs.existsSync(trustedPublisherWorkflowFile);
2784
+ const trustedPublisherWorkflow = inspectTrustedPublisherWorkflowText(
2785
+ trustedPublisherWorkflowExists ? fs.readFileSync(trustedPublisherWorkflowFile, 'utf8') : '',
2786
+ trustedPublisherWorkflowPath,
2787
+ trustedPublisherWorkflowExists
2788
+ );
2789
+ const authReadiness = buildReleaseAuthReadiness({
2790
+ workflow: trustedPublisherWorkflow,
2791
+ local_token: {
2792
+ command: 'npm whoami',
2793
+ ok: whoamiProbe.ok,
2794
+ note: whoamiProbe.note
2795
+ },
2796
+ requested_mode: authMode as 'auto' | 'oidc' | 'token'
2797
+ });
2798
+ const registryStatus = releaseRegistryStatusFromChecks(checks, {
2799
+ package_name: PACKAGE_JSON.name,
2800
+ version: VERSION
2801
+ });
2526
2802
  const latestProbe = registryStatus.checked
2527
2803
  ? { ok: true, stdout: registryStatus.latest_version, note: registryStatus.note }
2804
+ : registryStatus.parse_failed
2805
+ ? { ok: false, stdout: '', note: `registry check output did not prove ${PACKAGE_JSON.name}@${VERSION} is unpublished: ${registryStatus.note}` }
2528
2806
  : runReadOnlyCommandResult('npm', ['view', PACKAGE_JSON.name, 'version', '--registry', registryUrl]);
2529
2807
  const latestVersion = latestProbe.ok ? String(latestProbe.stdout || '').trim() : '';
2530
2808
  const blockers = [];
@@ -2535,8 +2813,8 @@ function releasePublishReadiness(checks = [], provenance: AnyRecord = {}, tarbal
2535
2813
  if (!registryProbe.ok) {
2536
2814
  blockers.push(readinessBlocker('registry_not_verified', 'npm registry could not be read from this shell', 'run `npm config get registry` and confirm it points to the intended public registry', 'npm config get registry', 'error'));
2537
2815
  }
2538
- if (!whoamiProbe.ok) {
2539
- blockers.push(readinessBlocker('auth_not_verified', whoamiProbe.note || 'npm whoami did not confirm an authenticated publisher in this shell', 'refresh npm login/token, then rerun `npm whoami` before publishing', 'npm whoami', 'error'));
2816
+ for (const blocker of authReadiness.blockers) {
2817
+ blockers.push(readinessBlocker(blocker.kind, blocker.reason, blocker.safe_next_action, blocker.command, blocker.severity));
2540
2818
  }
2541
2819
  if (!latestProbe.ok) {
2542
2820
  const blocker = classifyPublishBlockerText(latestProbe.note);
@@ -2576,11 +2854,16 @@ function releasePublishReadiness(checks = [], provenance: AnyRecord = {}, tarbal
2576
2854
  truth_status: latestProbe.ok ? 'verified' : 'blocked'
2577
2855
  },
2578
2856
  auth: {
2579
- command: 'npm whoami',
2580
- status: whoamiProbe.ok ? 'authenticated' : 'not_authenticated',
2581
- account: whoamiProbe.ok ? 'observed_redacted' : '',
2582
- note: whoamiProbe.ok ? 'npm account observed; username intentionally redacted' : whoamiProbe.note,
2583
- truth_status: whoamiProbe.ok ? 'verified' : 'blocked'
2857
+ requested_mode: authReadiness.requested_mode,
2858
+ mode: authReadiness.mode,
2859
+ command: authReadiness.local_token.command,
2860
+ required_for_selected_mode: authReadiness.local_token.required_for_selected_mode,
2861
+ status: authReadiness.status,
2862
+ account: authReadiness.local_token.account,
2863
+ note: authReadiness.next_action,
2864
+ local_token: authReadiness.local_token,
2865
+ trusted_publisher: authReadiness.trusted_publisher,
2866
+ truth_status: authReadiness.truth_status
2584
2867
  },
2585
2868
  blockers: uniqueBlockers,
2586
2869
  next_action: uniqueBlockers[0]?.safe_next_action || 'publish readiness is complete; run publish only when the user explicitly intends it',
@@ -2588,19 +2871,6 @@ function releasePublishReadiness(checks = [], provenance: AnyRecord = {}, tarbal
2588
2871
  };
2589
2872
  }
2590
2873
 
2591
- function releaseRegistryStatusFromChecks(checks = []) {
2592
- const registryCheck = checks.find((check) => check.id === 'registry_status');
2593
- const note = String(registryCheck?.note || '');
2594
- if (registryCheck?.status !== 'passed') return { checked: false, latest_version: '', not_published: false, note };
2595
- const latest = note.match(/\blatest\s+([^,\s)]+)/i)?.[1] || '';
2596
- return {
2597
- checked: true,
2598
- latest_version: latest,
2599
- not_published: /not published yet/i.test(note),
2600
- note
2601
- };
2602
- }
2603
-
2604
2874
  function readinessBlocker(kind, reason, safeNextAction, command = '', severity = 'warning') {
2605
2875
  return {
2606
2876
  kind,
@@ -2636,13 +2906,13 @@ function releaseStudyNote(publishReadiness: AnyRecord = {}, publishBlockers = []
2636
2906
  }
2637
2907
  return buildStudyNote({
2638
2908
  issue_code: 'npm publish readiness',
2639
- issue_role: 'This part checks registry, version, auth, tarball, and local release checks before any public publish.',
2909
+ issue_role: 'This part checks registry, version, the selected OIDC or manual-token authentication path, tarball, and local release checks before any public publish.',
2640
2910
  issue_symptom: firstBlocker.reason || firstBlocker.likely_cause || 'Publish readiness is blocked.',
2641
2911
  changed_code: 'yam release report',
2642
2912
  changed_role: 'It now explains the likely publish blocker instead of only showing a raw npm failure.',
2643
- change_summary: 'The report adds auth-safe readiness, safe next action, and beginner-readable blocker evidence.',
2644
- why_important: 'A bad token or wrong npm account can make publish fail or target the wrong package context.',
2645
- learning_note: 'When release readiness is blocked, fix the first blocker before trying a publish command.'
2913
+ change_summary: 'The report separates Trusted Publisher OIDC evidence from optional local-token evidence and gives a path-specific safe next action.',
2914
+ why_important: 'npm whoami cannot prove OIDC readiness; treating its E401 as an OIDC blocker would recommend the wrong credential model.',
2915
+ learning_note: 'For Trusted Publisher releases, verify the hosted workflow and npmjs package binding; use npm whoami only for an explicitly selected manual-token path.'
2646
2916
  });
2647
2917
  }
2648
2918
 
@@ -2817,20 +3087,117 @@ async function loop(args = []) {
2817
3087
  return loopUsage();
2818
3088
  }
2819
3089
 
3090
+ async function scout(args = []) {
3091
+ const subcommand = args[0] || 'help';
3092
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return scoutUsage();
3093
+ if (subcommand === 'receipt') return scoutReceipt(args.slice(1));
3094
+ console.error(`unknown scout command: ${subcommand}`);
3095
+ process.exitCode = 1;
3096
+ return scoutUsage();
3097
+ }
3098
+
3099
+ function scoutUsage() {
3100
+ console.log(`yam scout
3101
+
3102
+ Immutable, operator-supplied research evidence helpers. These commands do not browse, fetch, or trust external content as instructions.
3103
+
3104
+ Usage:
3105
+ yam scout receipt create [--root dir] --receipt-path .yam/scout/run.json --spec scout-spec.json [--json]
3106
+ yam scout receipt verify [--root dir] --receipt-path .yam/scout/run.json [--json]
3107
+ `);
3108
+ }
3109
+
3110
+ async function scoutReceipt(args = []) {
3111
+ const action = args[0] || 'help';
3112
+ if (action === 'help' || action === '--help' || action === '-h') return scoutUsage();
3113
+ const flags = parseSimpleFlags(args.slice(1), new Set(['root', 'receipt-path', 'spec', 'json']));
3114
+ const root = path.resolve(expandHome(String(flags.root || process.cwd())));
3115
+ try {
3116
+ let result;
3117
+ if (action === 'create') {
3118
+ if (!flags.receipt_path || !flags.spec) throw new Error('create requires --receipt-path and --spec');
3119
+ result = await createScoutReceipt({
3120
+ root,
3121
+ receipt_path: String(flags.receipt_path),
3122
+ spec: await readBoundedRegularJson(flags.spec, 'Scout spec') as any
3123
+ });
3124
+ } else if (action === 'verify') {
3125
+ if (!flags.receipt_path) throw new Error('verify requires --receipt-path');
3126
+ result = await verifyScoutReceiptFile({ root, receipt_path: String(flags.receipt_path) });
3127
+ } else {
3128
+ throw new Error(`unknown Scout receipt action: ${action}`);
3129
+ }
3130
+ printJsonOrHuman(result, Boolean(flags.json), 'Scout receipt');
3131
+ if (result.truth_status === 'blocked') process.exitCode = 1;
3132
+ } catch (error) {
3133
+ const result = {
3134
+ schema: 'yam.scout-receipt-error.v1',
3135
+ truth_status: 'blocked',
3136
+ error: errorMessage(error),
3137
+ next_action: 'repair the confined receipt path, canonical entity, version clocks, source digests, claim links, or failure taxonomy before retrying'
3138
+ };
3139
+ printJsonOrHuman(result, Boolean(flags.json), 'Scout receipt');
3140
+ process.exitCode = 1;
3141
+ }
3142
+ }
3143
+
3144
+ async function nextStepCommand(args = []) {
3145
+ const subcommand = args[0] || 'help';
3146
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') return nextStepUsage();
3147
+ const flags = parseSimpleFlags(args.slice(1), new Set(['spec', 'receipt', 'json']));
3148
+ try {
3149
+ let result;
3150
+ if (subcommand === 'report') {
3151
+ if (!flags.spec) throw new Error('report requires --spec');
3152
+ result = buildNextStep(await readBoundedRegularJson(flags.spec, 'Next step spec') as any);
3153
+ } else if (subcommand === 'verify') {
3154
+ if (!flags.receipt) throw new Error('verify requires --receipt');
3155
+ result = verifyNextStep(await readBoundedRegularJson(flags.receipt, 'Next step receipt') as any);
3156
+ } else {
3157
+ throw new Error(`unknown Next step command: ${subcommand}`);
3158
+ }
3159
+ printJsonOrHuman(result, Boolean(flags.json), 'Next step');
3160
+ if (result.truth_status === 'blocked') process.exitCode = 1;
3161
+ } catch (error) {
3162
+ const result = {
3163
+ schema: 'yam.next-step-error.v1',
3164
+ truth_status: 'blocked',
3165
+ error: errorMessage(error),
3166
+ next_action: 'repair the whole-process scan, ordered steps, evidence level/stamp, owner scope, blockers, or safe retry before retrying'
3167
+ };
3168
+ printJsonOrHuman(result, Boolean(flags.json), 'Next step');
3169
+ process.exitCode = 1;
3170
+ }
3171
+ }
3172
+
3173
+ function nextStepUsage() {
3174
+ console.log(`yam next-step
3175
+
3176
+ Build or verify an evidence-bound ordered handoff after a quick whole-process scan.
3177
+
3178
+ Usage:
3179
+ yam next-step report --spec next-step-spec.json [--json]
3180
+ yam next-step verify --receipt next-step-receipt.json [--json]
3181
+ `);
3182
+ }
3183
+
2820
3184
  function loopUsage() {
2821
3185
  console.log(`yam loop
2822
3186
 
2823
- Read-only loop artifact helpers. A loop report records intent, stages, evidence, blockers, next action, handoff fields, and a short study note. It does not run agents, start processes, publish packages, or write files.
3187
+ Read-only loop artifact helpers. A loop report records intent, stages, evidence, blockers, a short Study Note, and an evidence-bound Next step receipt. It does not run agents, start processes, publish packages, or write files.
2824
3188
 
2825
3189
  Usage:
2826
- yam loop report [--route route] [--intent text] [--loop-kind harness|release|ueye|scout|deep|mission] [--stage id:status:note] [--evidence text] [--evidence-level none|fixture|smoke|local|real] [--evidence-stamp text] [--source-digest text] [--touched-file file] [--read-file file] [--verified-file file] [--skipped-check text] [--stop-condition text] [--resume-hint text] [--readiness-state usable|degraded|blocked|unknown] [--covered-requirement text] [--uncovered-requirement text] [--blocked text] [--blocked-kind text] [--failure-cause text] [--safe-retry text] [--recovery-hint text] [--fix-first-item text] [--remaining-task text] [--recommended-direction text] [--implementation-note text] [--why-this-next text] [--blocked-by text] [--owner-route route] [--owner-scope text] [--scope-owner text] [--side-effect text] [--avoidance-note text] [--truth status] [--intent-label read_only|write|destructive|runtime|visual|publish] [--issue-code text] [--issue-role text] [--issue-symptom text] [--changed-code text] [--changed-role text] [--change-summary text] [--why-important text] [--learning-note text] [--json]
3190
+ yam loop report [--route route] [--intent text] [--loop-kind harness|release|ueye|scout|deep|mission] [--stage id:status:note] [--evidence text] [--evidence-level none|fixture|smoke|local|real] [--evidence-stamp text] [--source-digest text] [--touched-file file] [--read-file file] [--verified-file file] [--skipped-check text] [--stop-condition text] [--resume-hint text] [--readiness-state usable|degraded|blocked|unknown] [--covered-requirement text] [--uncovered-requirement text] [--blocked text] [--blocked-kind text] [--failure-cause text] [--safe-retry text] [--recovery-hint text] [--fix-first-item text] [--remaining-task text] [--recommended-direction text] [--implementation-note text] [--why-this-next text] [--blocked-by text] [--owner-route route] [--owner-scope text] [--scope-owner text] [--side-effect text] [--avoidance-note text] [--next-step-spec file] [--truth status] [--intent-label read_only|write|destructive|runtime|visual|publish] [--issue-code text] [--issue-role text] [--issue-symptom text] [--changed-code text] [--changed-role text] [--change-summary text] [--why-important text] [--learning-note text] [--json]
2827
3191
  `);
2828
3192
  }
2829
3193
 
2830
3194
  async function loopReport(args = []) {
2831
3195
  if (args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return loopUsage();
2832
- const flags = parseSimpleFlags(args, new Set(['route', 'intent', 'loop-kind', 'stage', 'stage-convention', 'evidence', 'evidence-level', 'evidence-stamp', 'source-digest', 'touched-file', 'read-file', 'verified-file', 'skipped-check', 'stop-condition', 'resume-hint', 'readiness-state', 'covered-requirement', 'uncovered-requirement', 'blocked', 'blocker', 'blocked-kind', 'failure-cause', 'next-action', 'safe-retry', 'recovery-hint', 'fix-first-item', 'remaining-task', 'recommended-direction', 'direction', 'implementation-note', 'why-this-next', 'blocked-by', 'owner-route', 'owner-scope', 'scope-owner', 'owner', 'scope', 'side-effect', 'avoidance-note', 'truth', 'intent-label', 'tool-intent', 'issue-code', 'issue-role', 'issue-symptom', 'changed-code', 'changed-role', 'change-summary', 'why-important', 'learning-note', 'json']));
3196
+ const flags = parseSimpleFlags(args, new Set(['route', 'intent', 'loop-kind', 'stage', 'stage-convention', 'evidence', 'evidence-level', 'evidence-stamp', 'source-digest', 'touched-file', 'read-file', 'verified-file', 'skipped-check', 'stop-condition', 'resume-hint', 'readiness-state', 'covered-requirement', 'uncovered-requirement', 'blocked', 'blocker', 'blocked-kind', 'failure-cause', 'next-action', 'safe-retry', 'recovery-hint', 'fix-first-item', 'remaining-task', 'recommended-direction', 'direction', 'implementation-note', 'why-this-next', 'blocked-by', 'owner-route', 'owner-scope', 'scope-owner', 'owner', 'scope', 'side-effect', 'avoidance-note', 'next-step-spec', 'truth', 'intent-label', 'tool-intent', 'issue-code', 'issue-role', 'issue-symptom', 'changed-code', 'changed-role', 'change-summary', 'why-important', 'learning-note', 'json']));
2833
3197
  const blockers = [...arrayFlag(flags.blocked), ...arrayFlag(flags.blocker)];
3198
+ const nextStepSpec = flags.next_step_spec
3199
+ ? await readBoundedRegularJson(flags.next_step_spec, 'Next step spec')
3200
+ : undefined;
2834
3201
  const report = buildLoopReport({
2835
3202
  route: normalizeRoute(flags.route) || String(flags.route || ''),
2836
3203
  intent: String(flags.intent || ''),
@@ -2869,6 +3236,7 @@ async function loopReport(args = []) {
2869
3236
  scope_owner: String(flags.scope_owner || flags.owner || ''),
2870
3237
  side_effects: arrayFlag(flags.side_effect),
2871
3238
  avoidance_note: String(flags.avoidance_note || ''),
3239
+ next_step: nextStepSpec as any,
2872
3240
  issue_code: String(flags.issue_code || ''),
2873
3241
  issue_role: String(flags.issue_role || ''),
2874
3242
  issue_symptom: String(flags.issue_symptom || ''),
@@ -3177,37 +3545,42 @@ async function readOptionalSpecs(value: unknown, label: string) {
3177
3545
  }
3178
3546
 
3179
3547
  async function readBoundedJsonSpec(file: string, label: string) {
3180
- const before = await fsp.lstat(file);
3181
- if (before.isSymbolicLink() || !before.isFile()) throw new Error(`${label} must be a regular non-symlink file: ${file}`);
3182
- if (before.size > 256 * 1024) throw new Error(`${label} exceeds the 256 KiB limit: ${file}`);
3548
+ const physicalParent = await fsp.realpath(path.dirname(file));
3549
+ const target = path.join(physicalParent, path.basename(file));
3550
+ const parents = await captureAbsoluteRegularDirectoryPath(physicalParent, label);
3551
+ const before = await fsp.lstat(target);
3552
+ if (before.isSymbolicLink() || !before.isFile()) throw new Error(`${label} must be a regular non-symlink file: ${target}`);
3553
+ if (before.size > 256 * 1024) throw new Error(`${label} exceeds the 256 KiB limit: ${target}`);
3183
3554
  const noFollow = process.platform !== 'win32' && typeof fs.constants.O_NOFOLLOW === 'number'
3184
3555
  ? fs.constants.O_NOFOLLOW
3185
3556
  : 0;
3186
- const handle = await fsp.open(file, fs.constants.O_RDONLY | noFollow);
3557
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | noFollow);
3187
3558
  let text = '';
3188
3559
  try {
3189
3560
  const opened = await handle.stat();
3190
- if (!opened.isFile() || !sameSpecFileIdentity(before, opened)) throw new Error(`${label} identity changed before open: ${file}`);
3191
- if (opened.size > 256 * 1024) throw new Error(`${label} exceeds the 256 KiB limit: ${file}`);
3561
+ await revalidateAbsoluteRegularDirectoryPath(parents, label);
3562
+ if (!opened.isFile() || !sameSpecFileIdentity(before, opened)) throw new Error(`${label} identity changed before open: ${target}`);
3563
+ if (opened.size > 256 * 1024) throw new Error(`${label} exceeds the 256 KiB limit: ${target}`);
3192
3564
  const bytes = Buffer.alloc(opened.size);
3193
3565
  let offset = 0;
3194
3566
  while (offset < bytes.length) {
3195
3567
  const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
3196
- if (!bytesRead) throw new Error(`${label} became shorter while reading: ${file}`);
3568
+ if (!bytesRead) throw new Error(`${label} became shorter while reading: ${target}`);
3197
3569
  offset += bytesRead;
3198
3570
  }
3199
3571
  const openedAfter = await handle.stat();
3200
- const after = await fsp.lstat(file);
3572
+ const after = await fsp.lstat(target);
3573
+ await revalidateAbsoluteRegularDirectoryPath(parents, label);
3201
3574
  if (!sameSpecFileIdentity(opened, openedAfter) || openedAfter.size !== opened.size
3202
3575
  || after.isSymbolicLink() || !after.isFile() || !sameSpecFileIdentity(opened, after)) {
3203
- throw new Error(`${label} changed size or identity while reading: ${file}`);
3576
+ throw new Error(`${label} changed size or identity while reading: ${target}`);
3204
3577
  }
3205
3578
  text = bytes.toString('utf8');
3206
3579
  } finally {
3207
3580
  await handle.close();
3208
3581
  }
3209
3582
  const value = JSON.parse(text);
3210
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must contain one JSON object: ${file}`);
3583
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must contain one JSON object: ${target}`);
3211
3584
  return value;
3212
3585
  }
3213
3586
 
@@ -5979,6 +6352,8 @@ function showRequestedHelp(args: string[]) {
5979
6352
  context: contextUsage,
5980
6353
  cleanup: cleanupUsage,
5981
6354
  'study-note': studyNoteUsage,
6355
+ 'next-step': nextStepUsage,
6356
+ scout: scoutUsage,
5982
6357
  tools: toolsUsage,
5983
6358
  hook: hookUsage,
5984
6359
  loop: loopUsage,
@@ -6012,7 +6387,9 @@ async function main() {
6012
6387
  if (command === 'tools') return tools(process.argv.slice(3));
6013
6388
  if (command === 'proof') return proof(process.argv.slice(3));
6014
6389
  if (command === 'study-note') return studyNote(process.argv.slice(3));
6390
+ if (command === 'next-step') return nextStepCommand(process.argv.slice(3));
6015
6391
  if (command === 'loop') return loop(process.argv.slice(3));
6392
+ if (command === 'scout') return scout(process.argv.slice(3));
6016
6393
  if (command === 'ueye') return ueye(process.argv.slice(3));
6017
6394
  if (command === 'media') return media(process.argv.slice(3));
6018
6395
  if (command === 'verification') return verification(process.argv.slice(3));