@open-agent-toolkit/cli 0.2.66 → 0.2.67

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 (107) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +14 -1
  3. package/assets/docs/cli-utilities/project-log.md +63 -4
  4. package/assets/docs/contributing/code.md +8 -4
  5. package/assets/docs/contributing/skills.md +29 -17
  6. package/assets/docs/docs-tooling/commands.md +3 -1
  7. package/assets/docs/provider-sync/manifest-and-drift.md +2 -18
  8. package/assets/docs/workflows/projects/lifecycle.md +17 -0
  9. package/assets/docs/workflows/projects/picking-up-projects.md +18 -0
  10. package/assets/docs/workflows/skills/repo-improve.md +7 -3
  11. package/assets/public-package-versions.json +4 -4
  12. package/assets/skills/create-agnostic-skill/SKILL.md +19 -37
  13. package/assets/skills/create-agnostic-skill/references/docs/skills-guide.md +58 -15
  14. package/assets/skills/create-agnostic-skill/references/skill-template.md +17 -15
  15. package/assets/skills/oat-agent-instructions-apply/SKILL.md +1 -1
  16. package/assets/skills/oat-agent-instructions-apply/references/instruction-file-templates/glob-scoped-rule.md +1 -4
  17. package/assets/skills/oat-doctor/SKILL.md +1 -4
  18. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
  19. package/assets/skills/oat-project-complete/SKILL.md +158 -30
  20. package/assets/skills/oat-project-complete/scripts/finalize-synced-archive.mjs +26 -7
  21. package/assets/skills/oat-project-complete/scripts/validate-durable-archive-receipt.mjs +183 -0
  22. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
  23. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
  24. package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
  25. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
  26. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
  27. package/assets/skills/oat-project-retro/SKILL.md +1 -1
  28. package/assets/skills/oat-project-retro/references/apply-procedure.md +11 -0
  29. package/assets/skills/oat-project-review-provide/SKILL.md +4 -4
  30. package/assets/skills/oat-project-summary/SKILL.md +24 -2
  31. package/assets/skills/oat-repo-improve/SKILL.md +9 -2
  32. package/assets/skills/oat-repo-improve/references/plan-template.md +4 -6
  33. package/assets/skills/oat-repo-knowledge-index/SKILL.md +11 -9
  34. package/assets/skills/oat-wave-execute/SKILL.md +20 -6
  35. package/assets/skills/oat-wave-program/SKILL.md +9 -4
  36. package/dist/commands/config/index.d.ts +2 -1
  37. package/dist/commands/config/index.d.ts.map +1 -1
  38. package/dist/commands/config/index.js +133 -38
  39. package/dist/commands/docs/index-generate/index.d.ts.map +1 -1
  40. package/dist/commands/docs/index-generate/index.js +68 -12
  41. package/dist/commands/docs/init/index.d.ts +11 -0
  42. package/dist/commands/docs/init/index.d.ts.map +1 -1
  43. package/dist/commands/docs/init/index.js +19 -1
  44. package/dist/commands/gate/index.d.ts.map +1 -1
  45. package/dist/commands/gate/index.js +10 -2
  46. package/dist/commands/instructions/instructions.utils.d.ts.map +1 -1
  47. package/dist/commands/instructions/instructions.utils.js +42 -9
  48. package/dist/commands/internal/validate-skill-version-bumps.d.ts.map +1 -1
  49. package/dist/commands/internal/validate-skill-version-bumps.js +5 -5
  50. package/dist/commands/project/dispatch-ceiling/index.d.ts.map +1 -1
  51. package/dist/commands/project/dispatch-ceiling/index.js +32 -18
  52. package/dist/commands/project/log/append.d.ts +48 -0
  53. package/dist/commands/project/log/append.d.ts.map +1 -1
  54. package/dist/commands/project/log/append.js +207 -30
  55. package/dist/commands/project/log/check.d.ts +87 -1
  56. package/dist/commands/project/log/check.d.ts.map +1 -1
  57. package/dist/commands/project/log/check.js +139 -5
  58. package/dist/commands/project/log/grammar.d.ts +165 -0
  59. package/dist/commands/project/log/grammar.d.ts.map +1 -1
  60. package/dist/commands/project/log/grammar.js +219 -1
  61. package/dist/commands/project/log/rollup.d.ts +16 -1
  62. package/dist/commands/project/log/rollup.d.ts.map +1 -1
  63. package/dist/commands/project/log/rollup.js +22 -2
  64. package/dist/commands/project/log/synthesize.d.ts +16 -0
  65. package/dist/commands/project/log/synthesize.d.ts.map +1 -1
  66. package/dist/commands/project/log/synthesize.js +85 -9
  67. package/dist/commands/status/index.d.ts.map +1 -1
  68. package/dist/commands/status/index.js +24 -2
  69. package/dist/commands/sync/apply.d.ts.map +1 -1
  70. package/dist/commands/sync/apply.js +6 -4
  71. package/dist/config/dispatch-matrix.d.ts.map +1 -1
  72. package/dist/config/dispatch-matrix.js +13 -5
  73. package/dist/config/oat-config.d.ts +15 -0
  74. package/dist/config/oat-config.d.ts.map +1 -1
  75. package/dist/config/oat-config.js +61 -7
  76. package/dist/config/own-keys.d.ts +22 -0
  77. package/dist/config/own-keys.d.ts.map +1 -0
  78. package/dist/config/own-keys.js +32 -0
  79. package/dist/config/resolve.d.ts +11 -0
  80. package/dist/config/resolve.d.ts.map +1 -1
  81. package/dist/config/resolve.js +35 -11
  82. package/dist/config/sync-config.d.ts.map +1 -1
  83. package/dist/config/sync-config.js +16 -0
  84. package/dist/drift/detector.d.ts.map +1 -1
  85. package/dist/drift/detector.js +52 -1
  86. package/dist/drift/skill-view-diagnostic.d.ts.map +1 -1
  87. package/dist/drift/skill-view-diagnostic.js +2 -23
  88. package/dist/engine/compute-plan.d.ts.map +1 -1
  89. package/dist/engine/compute-plan.js +22 -72
  90. package/dist/engine/managed-copy-hash.d.ts +69 -0
  91. package/dist/engine/managed-copy-hash.d.ts.map +1 -0
  92. package/dist/engine/managed-copy-hash.js +175 -0
  93. package/dist/fs/assets.d.ts +11 -0
  94. package/dist/fs/assets.d.ts.map +1 -1
  95. package/dist/fs/assets.js +1 -1
  96. package/dist/manifest/hash.d.ts +110 -0
  97. package/dist/manifest/hash.d.ts.map +1 -1
  98. package/dist/manifest/hash.js +111 -13
  99. package/dist/providers/ceiling/registry.d.ts.map +1 -1
  100. package/dist/providers/ceiling/registry.js +6 -1
  101. package/dist/providers/identity/dispatch-report.d.ts.map +1 -1
  102. package/dist/providers/identity/dispatch-report.js +4 -1
  103. package/dist/release/public-package-contract.d.ts.map +1 -1
  104. package/dist/release/public-package-contract.js +21 -0
  105. package/dist/validation/skills.d.ts.map +1 -1
  106. package/dist/validation/skills.js +305 -40
  107. package/package.json +2 -2
@@ -12,7 +12,8 @@ import { resolveAssetsRoot } from '../../../fs/assets.js';
12
12
  import { dirExists, fileExists } from '../../../fs/io.js';
13
13
  import { resolveProjectRoot } from '../../../fs/paths.js';
14
14
  import { Command } from 'commander';
15
- import { composeJudgmentHeading, composeStructuralHeading, isProjectLogEntryMarker, isProjectLogSectionMarker, PROJECT_LOG_AREA_MAX_LENGTH, PROJECT_LOG_HEADING_DELIMITER, PROJECT_LOG_SCOPES, PROJECT_LOG_TYPES, } from './grammar.js';
15
+ import { findProjectLogAmbiguity, findProjectLogSeal, unreachableProjectLogSealError, } from './check.js';
16
+ import { composeJudgmentHeading, composeStructuralHeading, containsAmbiguousProjectLogMarker, isProjectLogEntryMarker, findProjectLogSealHeadingLine, isProjectLogSealHeading, isProjectLogSectionMarker, PROJECT_LOG_AREA_MAX_LENGTH, PROJECT_LOG_HEADING_DELIMITER, PROJECT_LOG_LINE_TERMINATOR_RE, PROJECT_LOG_LONE_TERMINATOR_RE, PROJECT_LOG_SCOPES, PROJECT_LOG_TYPES, normalizeProjectLogLineEndings, splitProjectLogLines, } from './grammar.js';
16
17
  export const PROJECT_LOG_FILENAME = 'project-log.md';
17
18
  const SYNTHESIS_HEADING_PREFIX = '\n## End-of-run synthesis';
18
19
  /**
@@ -331,8 +332,13 @@ function releaseProjectLogLock(handle) {
331
332
  * `best-effort` is for work that cannot lose an entry — committing does not
332
333
  * rewrite the log — where holding the lock only keeps this process from racing
333
334
  * its own sibling into `.git/index.lock`.
335
+ *
336
+ * Exported so `synthesize` enters the same critical section rather than a
337
+ * second one of its own: its whole-file rewrite races an overlapping append
338
+ * exactly as two appends race each other, and two locks would serialize
339
+ * neither.
334
340
  */
335
- async function withProjectLogLock(logPath, overrides, policy, mutate) {
341
+ export async function withProjectLogLock(logPath, overrides, policy, mutate) {
336
342
  const handle = await acquireProjectLogLock(logPath, overrides);
337
343
  if (!handle.held && policy === 'required') {
338
344
  throw new Error(`Timed out waiting for the project log lock at ${handle.path}. Another writer is holding it; retry once it finishes.`);
@@ -794,6 +800,33 @@ export function findProjectLogEntryByIdempotencyKey(content, key, body) {
794
800
  }
795
801
  return undefined;
796
802
  }
803
+ /**
804
+ * Raised when an append that is not the completion seal targets a sealed log.
805
+ *
806
+ * This is a throw rather than a fourth `ProjectLogAppendResult` variant because
807
+ * every in-tree caller of `appendProjectLog` runs *before* the seal: the gate's
808
+ * partial-finalization receipt narrows the result's `status` into its own
809
+ * `'appended' | 'already-appended'` field, so widening the union would force a
810
+ * change on a caller that can never legitimately reach this state. Throwing
811
+ * refuses that caller too — the command layer maps this to
812
+ * `status: 'sealed'` with a non-zero exit, and any other caller fails loudly
813
+ * instead of silently appending past the seal.
814
+ */
815
+ export class ProjectLogSealedError extends Error {
816
+ logPath;
817
+ seal;
818
+ /**
819
+ * `action` names the refused mutation so `synthesize`, which rewrites rather
820
+ * than appends, reports what it actually declined. It defaults to `append`,
821
+ * which is the wording every existing caller and its pinned control expect.
822
+ */
823
+ constructor(logPath, seal, action = 'append') {
824
+ super(`No project-log ${action} may follow the completion seal (${seal.heading}) in ${logPath}.`);
825
+ this.name = 'ProjectLogSealedError';
826
+ this.logPath = logPath;
827
+ this.seal = seal;
828
+ }
829
+ }
797
830
  const DEFAULT_APPEND_DEPENDENCIES = {
798
831
  resolveActiveProject,
799
832
  resolveEffectiveConfig,
@@ -825,7 +858,7 @@ function validateSingleLine(value, option, maxLength = PROJECT_LOG_AREA_MAX_LENG
825
858
  if (!normalized) {
826
859
  throw new Error(`${option} is required and must be a non-empty single line.`);
827
860
  }
828
- if (/[\r\n]/.test(value ?? '')) {
861
+ if (PROJECT_LOG_LINE_TERMINATOR_RE.test(value ?? '')) {
829
862
  throw new Error(`${option} must be a single line without newline characters.`);
830
863
  }
831
864
  if (normalized.length > maxLength) {
@@ -846,16 +879,43 @@ function validateVersionNote(value) {
846
879
  }
847
880
  return validateSingleLine(value, '--version-note', Number.MAX_SAFE_INTEGER);
848
881
  }
882
+ /**
883
+ * Whether any line of `body` is a `## ` or `### ` marker the command owns.
884
+ *
885
+ * The split is `splitProjectLogLines` rather than `/\r?\n/` so validation
886
+ * enumerates exactly the LineTerminators a regular expression's multiline `^`
887
+ * anchors after. Splitting on fewer of them was the injection: a body reading
888
+ * `carrier<CR>## Injected` was one unbroken line here and so passed, while the
889
+ * section parser saw a real `## ` boundary and truncated the entries region,
890
+ * voiding an already-written completion seal.
891
+ */
849
892
  function containsCommandOwnedMarker(body) {
850
- return body
851
- .split(/\r?\n/)
852
- .some((line) => isProjectLogSectionMarker(line) || isProjectLogEntryMarker(line));
893
+ return splitProjectLogLines(body).some((line) => isProjectLogSectionMarker(line) || isProjectLogEntryMarker(line));
853
894
  }
854
895
  function validateEntry(input, date) {
855
- const body = input.body?.trim();
856
- if (!body) {
896
+ // The lone-terminator rule is tested against the *raw* body, before trimming.
897
+ // `String.prototype.trim` strips CR, LF, U+2028 and U+2029 as whitespace, so a
898
+ // body edge-terminated by one of them — `'ab\r'`, `'\rabc'` — never reached
899
+ // the test and appended with the terminator silently removed, while the error
900
+ // text said such a body is not accepted. Refusing it narrows what is accepted,
901
+ // which is the safe direction, and makes the message true.
902
+ const raw = input.body ?? '';
903
+ const supplied = raw.trim();
904
+ if (!supplied) {
857
905
  throw new Error('--body is required and must contain non-whitespace text.');
858
906
  }
907
+ // Parse leniently, write strictly: CRLF is accepted from the caller and stored
908
+ // as LF, so no log this command writes ever carries a terminator other than
909
+ // LF.
910
+ //
911
+ // The lone-terminator rule below is deliberately applied to `raw`: before
912
+ // trimming, and before normalization. Normalizing first would launder `\r\r\n` — a lone CR
913
+ // followed by a CRLF — into `\r\n`, which passes the rule and leaves a CR in
914
+ // the stored bytes, because one `replaceAll` pass cannot re-examine the CR it
915
+ // just exposed. Refusing at the door means every CR that reaches
916
+ // normalization is already part of a CRLF, and one pass is then exact.
917
+ const normalized = supplied.includes('\r\n');
918
+ const body = normalizeProjectLogLineEndings(supplied);
859
919
  const versionNote = validateVersionNote(input.versionNote);
860
920
  if (input.structural) {
861
921
  if (input.type !== undefined ||
@@ -863,7 +923,7 @@ function validateEntry(input, date) {
863
923
  input.area !== undefined) {
864
924
  throw new Error('--structural cannot be combined with judgment flags --type, --scope, or --area.');
865
925
  }
866
- if (/[\r\n]/.test(body)) {
926
+ if (PROJECT_LOG_LINE_TERMINATOR_RE.test(body)) {
867
927
  throw new Error('--body for structural entries must be one line without newline characters.');
868
928
  }
869
929
  if (containsCommandOwnedMarker(body)) {
@@ -875,6 +935,7 @@ function validateEntry(input, date) {
875
935
  heading: composeStructuralHeading({ date, producer, ref }),
876
936
  body,
877
937
  versionNote,
938
+ normalized,
878
939
  };
879
940
  }
880
941
  if (input.producer !== undefined || input.ref !== undefined) {
@@ -892,6 +953,12 @@ function validateEntry(input, date) {
892
953
  if (!PROJECT_LOG_SCOPES.includes(input.scope)) {
893
954
  throw new Error(`Invalid --scope '${input.scope}'; accepted values: ${PROJECT_LOG_SCOPES.join(' | ')}.`);
894
955
  }
956
+ // Tested against `raw`: before trimming, and before normalization. Trimming
957
+ // hides an edge terminator; normalizing first launders `\r\r\n` into
958
+ // `\r\n`, which passes this rule and leaves a carriage return in the bytes.
959
+ if (PROJECT_LOG_LONE_TERMINATOR_RE.test(raw)) {
960
+ throw new Error('--body for judgment entries must break lines with line feeds or CRLF; a lone carriage return and the U+2028 and U+2029 separators are not accepted.');
961
+ }
895
962
  if (containsCommandOwnedMarker(body)) {
896
963
  throw new Error('--body for judgment entries must not contain command-owned level-two or level-three Markdown headings.');
897
964
  }
@@ -905,6 +972,7 @@ function validateEntry(input, date) {
905
972
  }),
906
973
  body,
907
974
  versionNote,
975
+ normalized,
908
976
  };
909
977
  }
910
978
  /**
@@ -1000,30 +1068,114 @@ async function appendLockedProjectLog(input, dependencies, logPath) {
1000
1068
  }
1001
1069
  }
1002
1070
  const date = dependencies.now().toISOString().slice(0, 10);
1003
- const { heading, body, versionNote } = validateEntry(input, date);
1071
+ const { heading, body, versionNote, normalized } = validateEntry(input, date);
1004
1072
  const idempotencyKey = validateIdempotencyKey(input.idempotencyKey, body);
1005
1073
  let created = false;
1006
- if (logExists && idempotencyKey !== undefined) {
1007
- const existing = findProjectLogEntryByIdempotencyKey(await dependencies.readLog(logPath), idempotencyKey, body);
1008
- if (existing !== undefined) {
1009
- // Append-only order is preserved (DR-260714): a replay observes the
1010
- // entry it already wrote instead of writing a second one.
1011
- return {
1012
- status: 'already-appended',
1013
- logPath,
1014
- heading: existing,
1015
- created: false,
1016
- };
1074
+ if (logExists) {
1075
+ const content = await dependencies.readLog(logPath);
1076
+ // A log whose markers two readers resolve differently cannot answer
1077
+ // "is this sealed?", so nothing may be written onto it until a human
1078
+ // resolves it. This refusal is what keeps the LF-only section parser from
1079
+ // widening such a log: without it, a pre-existing `preamble<U+2028>##
1080
+ // Entries` stops parsing, reports unsealed, and accepts a second seal.
1081
+ const ambiguity = findProjectLogAmbiguity(content, logPath);
1082
+ if (ambiguity !== undefined) {
1083
+ throw new Error(ambiguity);
1084
+ }
1085
+ const seal = findProjectLogSeal(content);
1086
+ // The parser found no seal, but the raw text may still hold a seal heading
1087
+ // it cannot reach — a log with no `## Entries` section, or a seal below
1088
+ // another `## ` section. Writing onto such a log is the outcome that must
1089
+ // never happen, and reporting it unsealed would contradict what is plainly
1090
+ // in the file, so both branches below fail closed on it.
1091
+ //
1092
+ // Refusing is never weaker than the reading it replaces: the pre-fix code
1093
+ // answered these logs `already-appended` and wrote nothing when a key
1094
+ // happened to match, and appended past the seal when it did not.
1095
+ const unreachableSeal = seal === null ? findProjectLogSealHeadingLine(content) : undefined;
1096
+ // Read the requested identity back out of the composed heading, so the
1097
+ // comparison runs on the same normalized producer/ref the parser reads out
1098
+ // of the log.
1099
+ const requestsSeal = isProjectLogSealHeading(heading);
1100
+ // The seal routes on structure before the generic keyed short-circuit, and
1101
+ // is never suppressed by it. `idempotencyToken` matches any whole word
1102
+ // equal to the key *anywhere* in the log, so with the keyed scan first a
1103
+ // single ordinary entry whose prose happened to mention `oat-seal:<project>`
1104
+ // answered the completion seal with `already-appended` naming that entry —
1105
+ // no seal was written, the log stayed open to every later append, and the
1106
+ // completing agent saw success. Structural recognition cannot collide that
1107
+ // way, and the keyed fallback below is admitted only when it names a seal,
1108
+ // so an `already-appended` seal always reports a seal heading.
1109
+ if (requestsSeal) {
1110
+ if (seal !== null) {
1111
+ // The seal is idempotent by structure, not only by key: a log sealed
1112
+ // before the keyed convention carries no token to match, and a resumed
1113
+ // completion must still observe the existing seal rather than write a
1114
+ // second one.
1115
+ return {
1116
+ status: 'already-appended',
1117
+ logPath,
1118
+ heading: seal.heading,
1119
+ created: false,
1120
+ };
1121
+ }
1122
+ if (unreachableSeal !== undefined) {
1123
+ throw unreachableProjectLogSealError(logPath, content, unreachableSeal);
1124
+ }
1125
+ }
1126
+ else {
1127
+ // Recognizing an entry this key already wrote comes before the sealed
1128
+ // guard, and deliberately so: it appends nothing, and the entry it finds
1129
+ // necessarily predates any later seal. Refusing it would turn the gate's
1130
+ // idempotent recovery replay into a hard failure on every project that
1131
+ // has since been completed.
1132
+ if (idempotencyKey !== undefined) {
1133
+ const existing = findProjectLogEntryByIdempotencyKey(content, idempotencyKey, body);
1134
+ if (existing !== undefined) {
1135
+ // Append-only order is preserved (DR-260714): a replay observes the
1136
+ // entry it already wrote instead of writing a second one.
1137
+ return {
1138
+ status: 'already-appended',
1139
+ logPath,
1140
+ heading: existing,
1141
+ created: false,
1142
+ };
1143
+ }
1144
+ }
1145
+ if (seal !== null) {
1146
+ throw new ProjectLogSealedError(logPath, seal);
1147
+ }
1148
+ // The same refusal the seal path makes, on the same file. Leaving it out
1149
+ // of this branch is what let a log whose seal the parser could not reach
1150
+ // hard-fail the completion seal while still accepting ordinary content
1151
+ // past that seal — the two writers disagreeing about one file.
1152
+ if (unreachableSeal !== undefined) {
1153
+ throw unreachableProjectLogSealError(logPath, content, unreachableSeal);
1154
+ }
1017
1155
  }
1018
1156
  }
1019
1157
  if (!logExists) {
1020
1158
  const assetsRoot = await dependencies.resolveAssetsRoot();
1021
1159
  const template = await readFile(join(assetsRoot, 'templates', PROJECT_LOG_FILENAME), 'utf8');
1022
- await dependencies.writeLog(logPath, instantiateProjectLogTemplate(template, basename(dirname(logPath)), date));
1160
+ const instantiated = instantiateProjectLogTemplate(template, basename(dirname(logPath)), date);
1161
+ // The project directory's basename lands in the template's title line, so a
1162
+ // directory named `demo<U+2028>## Notes` would create a log this command
1163
+ // then refuses on its next append. A mutator must never write content it
1164
+ // would decline to read.
1165
+ if (containsAmbiguousProjectLogMarker(instantiated)) {
1166
+ throw new Error(`Project name ${basename(dirname(logPath))} would put a '## ' heading after a carriage return, U+2028, or U+2029 in the new log's title, leaving a file with two readings. Rename the project directory using line feeds only.`);
1167
+ }
1168
+ await dependencies.writeLog(logPath, instantiated);
1023
1169
  created = true;
1024
1170
  }
1025
1171
  await appendEntry(logPath, heading, body, versionNote, dependencies);
1026
- return { status: 'appended', logPath, heading, created };
1172
+ return {
1173
+ status: 'appended',
1174
+ logPath,
1175
+ heading,
1176
+ created,
1177
+ ...(normalized ? { normalizedLineEndings: true } : {}),
1178
+ };
1027
1179
  }
1028
1180
  async function samePath(left, right) {
1029
1181
  if (resolve(left) === resolve(right)) {
@@ -1169,12 +1321,35 @@ async function runAppendCommand(options, context, dependencies) {
1169
1321
  receiptPath = candidate;
1170
1322
  }
1171
1323
  }
1172
- const result = await appendProjectLog({
1173
- repoRoot,
1174
- home: context.home,
1175
- ...options,
1176
- body,
1177
- }, dependencies);
1324
+ let result;
1325
+ try {
1326
+ result = await appendProjectLog({
1327
+ repoRoot,
1328
+ home: context.home,
1329
+ ...options,
1330
+ body,
1331
+ }, dependencies);
1332
+ }
1333
+ catch (error) {
1334
+ if (!(error instanceof ProjectLogSealedError)) {
1335
+ throw error;
1336
+ }
1337
+ // A terminal refusal, reported in the same shape as the error path below
1338
+ // so a caller reading JSON can tell a sealed log from a malformed append.
1339
+ if (context.json) {
1340
+ context.logger.json({
1341
+ status: 'sealed',
1342
+ logPath: error.logPath,
1343
+ heading: error.seal.heading,
1344
+ message: error.message,
1345
+ });
1346
+ }
1347
+ else {
1348
+ context.logger.error(error.message);
1349
+ }
1350
+ process.exitCode = 1;
1351
+ return;
1352
+ }
1178
1353
  let commit;
1179
1354
  let receiptRemoved = false;
1180
1355
  if (options.commit && result.status !== 'skipped') {
@@ -1249,7 +1424,9 @@ Entry contract:
1249
1424
  High-value judgments may use Observation:, Impact:, and Recommendation: fields.
1250
1425
  Structural bodies are one line and reference artifacts by path instead of inlining them.
1251
1426
  Add --version-note for tool-related observations.
1252
- Pass --idempotency-key with --commit to finalize a gate partial-finalization receipt; a replay reports already-appended instead of duplicating the entry.
1427
+ Pass --idempotency-key so a replay reports already-appended instead of duplicating the entry; the key must appear in --body as its own word. Adding --commit also finalizes a gate partial-finalization receipt.
1428
+ The completion seal (--structural --producer oat-project-complete --ref seal) dedupes on its own, and every append carrying new content onto a sealed log is refused.
1429
+ A replay recognized by its own --idempotency-key still reports already-appended on a sealed log and writes nothing.
1253
1430
  Never record secret values (tokens, keys, signed URLs, or credentials); reference secrets by name or source.
1254
1431
  Prior entries are never edited or struck through. Append a new judgment entry that references and explains a correction.
1255
1432
 
@@ -5,8 +5,39 @@ import { type ProjectLogSection, type ProjectLogScope, type ProjectLogType } fro
5
5
  export declare const PROJECT_LOG_FILENAME = "project-log.md";
6
6
  export declare const SYNTHESIS_PENDING_HEADING = "## End-of-run synthesis (pending \u2014 do not skip at project completion)";
7
7
  export declare const SYNTHESIS_COMPLETE_HEADING = "## End-of-run synthesis";
8
+ /**
9
+ * The completion seal a project log already carries.
10
+ *
11
+ * `count` is reported rather than collapsed so a log double-sealed before the
12
+ * seal append became idempotent stays legible instead of looking like a normal
13
+ * single-sealed log; `heading` is always the **first** seal, which is the one
14
+ * that actually closed the log.
15
+ */
16
+ export interface ProjectLogSeal {
17
+ heading: string;
18
+ date: string;
19
+ keyed: boolean;
20
+ count: number;
21
+ }
8
22
  export interface ProjectLogCheckResult {
9
- status: 'ok' | 'absent' | 'synthesis_pending';
23
+ /**
24
+ * A *sealed* log deliberately keeps its ordinary status and carries `sealed`
25
+ * alongside it, because it is a healthy log that must keep routing normally —
26
+ * a distinct value there would break every `status: "ok"` route in
27
+ * `oat-project-complete/SKILL.md` and `oat-project-summary/SKILL.md`.
28
+ *
29
+ * `ambiguous` is the opposite case and is the one value that may join them.
30
+ * The log's markers have two readings, so no verdict below can be computed
31
+ * honestly: every other field is zeroed and must be treated as unknown, not as
32
+ * a finding. Breaking the routing is the point — both mutators already refuse
33
+ * this file, and `check` answering `ok` while they refuse is what let the
34
+ * completion gate trust a reader that disagreed with every writer.
35
+ */
36
+ status: 'ok' | 'absent' | 'synthesis_pending' | 'ambiguous';
37
+ /** Why the log has two readings, or null when it has one. */
38
+ ambiguity: string | null;
39
+ sealed: boolean;
40
+ seal: ProjectLogSeal | null;
10
41
  logPath: string | null;
11
42
  entryCounts: {
12
43
  structural: number;
@@ -60,7 +91,62 @@ export interface ProjectLogCheckCommandDependencies extends CheckProjectLogDepen
60
91
  resolveProjectRoot: (cwd: string) => Promise<string>;
61
92
  }
62
93
  export declare function findCanonicalProjectLogSynthesisSection(content: string): ProjectLogSynthesisSection;
94
+ /**
95
+ * Parses the entries region into typed entries.
96
+ *
97
+ * The split is `/\r?\n/`, not `'\n'`. With a bare LF split every line of a
98
+ * CRLF log keeps a trailing `\r`, which no heading pattern can match — their
99
+ * `$` sits after a `[^·\r\n]+` run — so a CRLF log parsed to zero entries and
100
+ * an existing completion seal was invisible to `check` and to the append guard.
101
+ * A *lone* CR is still not a boundary, which is what keeps a body from forging
102
+ * a heading; only the CRLF pair is folded in.
103
+ */
63
104
  export declare function parseProjectLogEntries(content: string): ParsedProjectLog;
105
+ /**
106
+ * Summarizes the completion seal carried by already-parsed entries.
107
+ *
108
+ * Seal identity is `isProjectLogSealEntry` and nothing else, so `check` and
109
+ * `append` cannot drift apart on what "sealed" means.
110
+ */
111
+ export declare function summarizeProjectLogSeal(entries: readonly ParsedProjectLogEntry[]): ProjectLogSeal | null;
112
+ /**
113
+ * Finds the completion seal in raw project-log content.
114
+ *
115
+ * `append` uses this so its refusal and `check`'s report are the same reading
116
+ * of the same file.
117
+ */
118
+ /**
119
+ * The reason this log has two readings, or undefined when it has one.
120
+ *
121
+ * The single question `check`, `rollup`, both append paths and `synthesize` all
122
+ * ask before trusting a parse. It is one function rather than one call each,
123
+ * because a surface that asked a slightly different question is exactly how
124
+ * `rollup` came to rewrite `summary.md` from a log the other three refused,
125
+ * reporting output byte-identical to an empty log.
126
+ *
127
+ * Two shapes qualify. A heading that a `^…$/m` reader starts a line at and the
128
+ * line-feed-only parser does not; and a completion seal that is physically in
129
+ * the file but outside the region the parser reads, where "is this sealed?" has
130
+ * one answer to a human and another to the parser.
131
+ */
132
+ export declare function findProjectLogAmbiguity(content: string, logPath: string): string | undefined;
133
+ /**
134
+ * Explains a refusal caused by a seal the entries parser cannot reach, naming
135
+ * the actual obstruction.
136
+ *
137
+ * The first version of this message assumed the only way to hide a seal was to
138
+ * place it outside `## Entries`, and offered that remedy for a CRLF log whose
139
+ * seal sat exactly where it belonged — a remedy that did not apply and could
140
+ * not be followed, on the one path a completion run cannot get past.
141
+ *
142
+ * Shared by both mutators so one file cannot produce two different accounts of
143
+ * why it was refused.
144
+ */
145
+ export declare function unreachableProjectLogSealError(logPath: string, content: string, seal: {
146
+ line: string;
147
+ index: number;
148
+ }): Error;
149
+ export declare function findProjectLogSeal(content: string): ProjectLogSeal | null;
64
150
  export declare function checkProjectLog(input: CheckProjectLogInput, overrides?: Partial<CheckProjectLogDependencies>): Promise<ProjectLogCheckResult>;
65
151
  export declare function createProjectLogCheckCommand(overrides?: Partial<ProjectLogCheckCommandDependencies>): Command;
66
152
  //# sourceMappingURL=check.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../../../src/commands/project/log/check.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../../../src/commands/project/log/check.ts"],"names":[],"mappings":"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"}
@@ -6,7 +6,7 @@ import { resolveActiveProject, } from '../../../config/oat-config.js';
6
6
  import { dirExists, fileExists } from '../../../fs/io.js';
7
7
  import { resolveProjectRoot } from '../../../fs/paths.js';
8
8
  import { Command } from 'commander';
9
- import { findProjectLogSections, isProjectLogEntryMarker, JUDGMENT_HEADING_RE, PROJECT_LOG_TYPES, STRUCTURAL_HEADING_RE, } from './grammar.js';
9
+ import { carriesProjectLogSealKey, containsAmbiguousProjectLogMarker, findProjectLogSealHeadingLine, findProjectLogSections, isProjectLogEntryMarker, isProjectLogSealEntry, JUDGMENT_HEADING_RE, PROJECT_LOG_TYPES, STRUCTURAL_HEADING_RE, } from './grammar.js';
10
10
  export const PROJECT_LOG_FILENAME = 'project-log.md';
11
11
  export const SYNTHESIS_PENDING_HEADING = '## End-of-run synthesis (pending — do not skip at project completion)';
12
12
  export const SYNTHESIS_COMPLETE_HEADING = '## End-of-run synthesis';
@@ -32,8 +32,10 @@ function emptyCounts() {
32
32
  scopeCounts: { project: 0, general: 0 },
33
33
  };
34
34
  }
35
+ /** The heading that opens the region entries are parsed from. */
36
+ const ENTRIES_HEADING = '## Entries';
35
37
  function entriesSection(content) {
36
- const section = findProjectLogSections(content).find(({ heading }) => heading === '## Entries');
38
+ const section = findProjectLogSections(content).find(({ heading }) => heading === ENTRIES_HEADING);
37
39
  if (!section) {
38
40
  return '';
39
41
  }
@@ -54,8 +56,18 @@ export function findCanonicalProjectLogSynthesisSection(content) {
54
56
  section,
55
57
  };
56
58
  }
59
+ /**
60
+ * Parses the entries region into typed entries.
61
+ *
62
+ * The split is `/\r?\n/`, not `'\n'`. With a bare LF split every line of a
63
+ * CRLF log keeps a trailing `\r`, which no heading pattern can match — their
64
+ * `$` sits after a `[^·\r\n]+` run — so a CRLF log parsed to zero entries and
65
+ * an existing completion seal was invisible to `check` and to the append guard.
66
+ * A *lone* CR is still not a boundary, which is what keeps a body from forging
67
+ * a heading; only the CRLF pair is folded in.
68
+ */
57
69
  export function parseProjectLogEntries(content) {
58
- const lines = entriesSection(content).split('\n');
70
+ const lines = entriesSection(content).split(/\r?\n/);
59
71
  const entries = [];
60
72
  const grammarViolations = [];
61
73
  for (let index = 0; index < lines.length; index += 1) {
@@ -101,6 +113,90 @@ export function parseProjectLogEntries(content) {
101
113
  }
102
114
  return { entries, grammarViolations };
103
115
  }
116
+ /**
117
+ * Summarizes the completion seal carried by already-parsed entries.
118
+ *
119
+ * Seal identity is `isProjectLogSealEntry` and nothing else, so `check` and
120
+ * `append` cannot drift apart on what "sealed" means.
121
+ */
122
+ export function summarizeProjectLogSeal(entries) {
123
+ const seals = entries.filter((entry) => entry.class === 'structural' && isProjectLogSealEntry(entry));
124
+ const first = seals[0];
125
+ if (first === undefined) {
126
+ return null;
127
+ }
128
+ return {
129
+ heading: first.heading,
130
+ date: first.date,
131
+ keyed: carriesProjectLogSealKey(first.body),
132
+ count: seals.length,
133
+ };
134
+ }
135
+ /**
136
+ * Finds the completion seal in raw project-log content.
137
+ *
138
+ * `append` uses this so its refusal and `check`'s report are the same reading
139
+ * of the same file.
140
+ */
141
+ /**
142
+ * The reason this log has two readings, or undefined when it has one.
143
+ *
144
+ * The single question `check`, `rollup`, both append paths and `synthesize` all
145
+ * ask before trusting a parse. It is one function rather than one call each,
146
+ * because a surface that asked a slightly different question is exactly how
147
+ * `rollup` came to rewrite `summary.md` from a log the other three refused,
148
+ * reporting output byte-identical to an empty log.
149
+ *
150
+ * Two shapes qualify. A heading that a `^…$/m` reader starts a line at and the
151
+ * line-feed-only parser does not; and a completion seal that is physically in
152
+ * the file but outside the region the parser reads, where "is this sealed?" has
153
+ * one answer to a human and another to the parser.
154
+ */
155
+ export function findProjectLogAmbiguity(content, logPath) {
156
+ if (containsAmbiguousProjectLogMarker(content)) {
157
+ return `Project log ${logPath} has a '## ' heading, or a dated '### ' entry heading, starting a line after a carriage return, U+2028, or U+2029 rather than a line feed, so its structure has two readings and no seal or count can be reported honestly. Replace those line terminators with line feeds.`;
158
+ }
159
+ if (findProjectLogSeal(content) === null) {
160
+ const unreachableSeal = findProjectLogSealHeadingLine(content);
161
+ if (unreachableSeal !== undefined) {
162
+ // Delegated so the precise diagnosis — which of the two obstructions this
163
+ // is — survives being reached through the shared predicate. Collapsing
164
+ // both causes into one sentence here would undo the round that made the
165
+ // remedy actionable.
166
+ return unreachableProjectLogSealError(logPath, content, unreachableSeal)
167
+ .message;
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+ /**
173
+ * Explains a refusal caused by a seal the entries parser cannot reach, naming
174
+ * the actual obstruction.
175
+ *
176
+ * The first version of this message assumed the only way to hide a seal was to
177
+ * place it outside `## Entries`, and offered that remedy for a CRLF log whose
178
+ * seal sat exactly where it belonged — a remedy that did not apply and could
179
+ * not be followed, on the one path a completion run cannot get past.
180
+ *
181
+ * Shared by both mutators so one file cannot produce two different accounts of
182
+ * why it was refused.
183
+ */
184
+ export function unreachableProjectLogSealError(logPath, content, seal) {
185
+ // Compared by offset, not by substring membership: prose inside `## Entries`
186
+ // can quote a seal heading verbatim, and a membership test would then report a
187
+ // seal that really sits under a later section as correctly placed, sending the
188
+ // operator to rewrite line terminators that were never the obstruction.
189
+ const entries = findProjectLogSections(content).find(({ heading }) => heading === ENTRIES_HEADING);
190
+ const outsideEntries = entries === undefined ||
191
+ seal.index < entries.start ||
192
+ seal.index >= entries.end;
193
+ return new Error(`Project log ${logPath} already carries the completion-seal heading '${seal.line.trim()}', but the entries parser cannot reach it, so whether the log is sealed has two answers and nothing may be read from it or written to it with confidence. ${outsideEntries
194
+ ? `The seal sits outside the '${ENTRIES_HEADING}' section; move it under that heading, then retry.`
195
+ : 'The seal is under the correct heading, so the obstruction is its line terminators; rewrite the log with line feeds, then retry.'}`);
196
+ }
197
+ export function findProjectLogSeal(content) {
198
+ return summarizeProjectLogSeal(parseProjectLogEntries(content).entries);
199
+ }
104
200
  async function resolveTargetProject(input, dependencies) {
105
201
  if (input.project) {
106
202
  const projectPath = isAbsolute(input.project)
@@ -125,6 +221,9 @@ export async function checkProjectLog(input, overrides = {}) {
125
221
  if (!(await fileExists(logPath))) {
126
222
  return {
127
223
  status: 'absent',
224
+ ambiguity: null,
225
+ sealed: false,
226
+ seal: null,
128
227
  logPath: null,
129
228
  ...emptyCounts(),
130
229
  lastEntryDate: null,
@@ -133,6 +232,24 @@ export async function checkProjectLog(input, overrides = {}) {
133
232
  };
134
233
  }
135
234
  const content = await readFile(logPath, 'utf8');
235
+ // Fail closed, exactly as every other surface does on this same file. `check`
236
+ // used to be the one reader that always produced a confident verdict: on a
237
+ // `preamble<U+2028>## Entries` log it answered `sealed` and count fields the
238
+ // writers refused to act on, and the completion gate keys on those fields.
239
+ const ambiguity = findProjectLogAmbiguity(content, logPath);
240
+ if (ambiguity !== undefined) {
241
+ return {
242
+ status: 'ambiguous',
243
+ ambiguity,
244
+ sealed: false,
245
+ seal: null,
246
+ logPath,
247
+ ...emptyCounts(),
248
+ lastEntryDate: null,
249
+ synthesisPending: false,
250
+ grammarViolations: [],
251
+ };
252
+ }
136
253
  const parsed = parseProjectLogEntries(content);
137
254
  const counts = emptyCounts();
138
255
  for (const entry of parsed.entries) {
@@ -145,8 +262,12 @@ export async function checkProjectLog(input, overrides = {}) {
145
262
  }
146
263
  }
147
264
  const synthesisPending = findProjectLogSections(content).some(({ heading }) => heading === SYNTHESIS_PENDING_HEADING);
265
+ const seal = summarizeProjectLogSeal(parsed.entries);
148
266
  return {
149
267
  status: synthesisPending ? 'synthesis_pending' : 'ok',
268
+ ambiguity: null,
269
+ sealed: seal !== null,
270
+ seal,
150
271
  logPath,
151
272
  ...counts,
152
273
  lastEntryDate: parsed.entries.at(-1)?.date ?? null,
@@ -161,14 +282,27 @@ async function runCheckCommand(options, context, dependencies) {
161
282
  if (context.json) {
162
283
  context.logger.json(result);
163
284
  }
285
+ else if (result.status === 'ambiguous') {
286
+ context.logger.error(result.ambiguity ?? 'Project log has two readings.');
287
+ }
164
288
  else {
165
289
  context.logger.info(result.status === 'absent'
166
290
  ? 'Project log: absent'
167
291
  : `Project log: ${result.status}; entries: ${result.entryCounts.structural +
168
- PROJECT_LOG_TYPES.reduce((total, type) => total + result.entryCounts.judgment[type], 0)}`);
292
+ PROJECT_LOG_TYPES.reduce((total, type) => total + result.entryCounts.judgment[type], 0)}${result.seal
293
+ ? `; sealed${result.seal.count > 1
294
+ ? ` (${result.seal.count} seal entries)`
295
+ : ''}`
296
+ : ''}`);
169
297
  }
298
+ // An ambiguous log exits non-zero whether or not synthesis was required: a
299
+ // caller that only checks the exit status must not read this as a clean log,
300
+ // and both mutators refuse the same file.
170
301
  process.exitCode =
171
- options.requireSynthesis && result.synthesisPending ? 1 : 0;
302
+ result.status === 'ambiguous' ||
303
+ (options.requireSynthesis === true && result.synthesisPending)
304
+ ? 1
305
+ : 0;
172
306
  }
173
307
  catch (error) {
174
308
  const message = error instanceof Error ? error.message : String(error);