Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in
`research-kit/lib/core.mjs`; a release is tagged `v<version>` (ADR-0116).

## Unreleased

- Timeline details fold multiline errors into one table row before pipe escaping
(#354). Regenerated the root timeline without changing its provenance ledger.

- Fixed `AGENTS.md` corpus description to use evergreen relative references rather than stale hardcoded entry counts (#299).

- The suite's "every CLI refuses a flag it does not know" table now covers `measure`,
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ is an open decision (2026-09-30):
| `scaffold.mjs` | Project shape: `LAYOUT` (12 entries), the **four** `GATE_MARKERS` (pinned by ADR-0001 and its test), template rendering, placeholder validation, hook executability. `.gitattributes` is a LAYOUT entry but **not** a gate marker: a project without it still gates, it just breaks on a Windows builder (ADR-0020). `UNTITLED_TOPIC` names the placeholder topic, so `decompose` can tell a project without a topic from one that has one (ADR-0056). A folder created while scaffolding a brand-new project is not counted as `repaired` (2026-09-27): only a folder missing from a project that already existed is a repair. **`MERGED_FILES` (`.gitignore`, `.gitattributes`) are merged into an operator's existing copy** (2026-09-27, ADR-0064): `missingKitLines` finds the kit's rule lines it lacks (whitespace collapsed) and they are appended once under a marked comment, so `.env` stays out and the ledger stays in. `validateProject` warns `kit-rules-missing` and `agents-md-foreign` (an AGENTS.md without the kit's heading, kept but named). A created folder counts as `repaired` only when `research/` already existed (2026-09-27): the operator's own `.gitignore` had made a fresh folder read as a project. | ADR-0001, ADR-0020 |
| `installer.mjs` | Installs the git hook (records/restores prior `hooksPath`) and the edit-time gate (backs up, repairs known corruption, refuses unfamiliar states). The hook was renamed (A10.4): the installer recognises a registration left at a retired name, replaces it rather than adding a second, and reports the repair — `retiredRepairNote`. **And whether the deployment is still the kit you have** (`deployedDrift`, `driftNote`). `deploy` is a full mirror, so drift is exact rather than heuristic: every source file should be present at the destination with the same bytes, and nothing else should be there. Doctor measures the drift against the home the install state recorded (the one the hooks point at; it had measured `KIT_HOME` and named the recorded one), and run from the deployed kit itself - `node ~/.agents/research-kit/bin/doctor.mjs`, the copy an agent runs - it says so as `info`, with the command that does measure, instead of `pass ... matches this tree`: a tree compared with itself always matches, and a line appended to the deployed `lib/core.mjs` had passed that way (break-test pass 3, 2026-10-02). This exists because doctor's `deploy` check read the install STATE and stopped - "kit deployed <date>", pass, READY - and recorded is not current. Measured on this machine 2026-09-22, that passed over a deployment **missing 119 files and carrying 38 stale ones**, including a skill describing a protocol without the step shipped that morning. An agent invoking the skill runs the DEPLOYED copy, so every fix since the last install existed only in the repository while the machine reported ready. The skill is reported separately from the kit because it drifts independently and is the file that tells an agent what the protocol IS. **Reported, never repaired**: `install.mjs` acts, and a health check that quietly rewrote what it measured would erase the evidence of the problem it found - pinned by a test that asserts the missing file is still missing afterwards. `removeEditGate` drops the `PreToolUse` list and `hooks` object its entry leaves empty, and removes a settings file with nothing left (2026-09-27): `--uninstall` on a machine with no settings file left `{"hooks": {"PreToolUse": []}}` behind. `removeCommitGate` restores the recorded `hooksPath` only while the current value is still the kit's own folder (2026-09-27, ADR-0063), and otherwise leaves it and names it: an uninstall had unset a machine's `/opt/myhooks`, which the kit never set. `installEditGate` refuses, as the commit gate already did, when `hooks/edit-gate.mjs` is not deployed (2026-09-27): it had registered a command naming a missing file, so every Edit would run a hook that crashed. `deployedDrift` asks `isDirectory`, not `exists`, of the kit home and each skill location, and `listTree` guards its `readdirSync`/`statSync` (break-test, 2026-09-29): `RESEARCH_KIT_HOME` naming a regular file sent `readdirSync` at it, and `doctor` died with a raw ENOTDIR stack; a file there now reads as not deployed. `listTree` walks each real folder once (2026-09-29): a folder link back to an ancestor re-listed the tree on every lap until ELOOP, about 40 phantom "extra" files per real one. `deploy({ dryRun })` lists what the deploy would actually prune - retired files that exist, plus the mirror's extras (2026-09-29, Arena break test): it listed every retired name whether or not it was there, so a machine never installed read "would prune" three files, and it never named a stale file the mirror would remove. `readSettings` also refuses a `hooks.PreToolUse` node of a type the walkers do not handle (`knownHookShape`, 2026-09-30): `[null]`, a number, a string, or an entry whose `hooks` is not a list of objects reads as `unfamiliar`, and the file is not rewritten. It had reached the walkers and thrown a raw `TypeError` out of `install-hooks.mjs`. An entry with no `hooks` key is still the known shape. `knownHookShape` also requires `hooks` itself, each entry and each hook to be plain objects (2026-09-30): `"hooks": "none"`, `null`, a list or a number had passed as the known shape, and the install spread the string into the file. **Project binding is checked before deployment writes** (2026-10-08, ADR-0006): `deploy({ into })` refuses a missing path, a regular file, or a directory with none of `.git`, `AGENTS.md`, or `research/`; `.git` may be a file or directory so worktrees remain eligible, `AGENTS.md` must be a file, and `research/` must be a directory. The same refusal precedes the mirror, personal/project skill copies and install-state update on the real run and the preview; `install.mjs --into` reports it with exit 2. The guard had been absent, so a typo could deploy the home copy, prune its old files and bind skills into an unrelated folder. Config readers retain their separate last-good snapshot policy. `deploy` refuses, touching nothing, a `RESEARCH_KIT_HOME` that holds files and is neither a kit (`lib/core.mjs`, `bin/gate.mjs` and `bin/preflight.mjs`, all three: one alone took a project with a `lib/core.mjs` for a kit) nor the recorded deployment, `--dry-run` included; `install` exits 2 (ADR-0111, 2026-10-01). The mirror had deleted a folder's own files and reported them as retired. The refusal reads only the first few files it names (2026-10-01): counting all of them took 1.6 s for /usr. `installCommitGate` records the hooks folder it replaced as `research-kit.previousHooksPath` in git's global config, never the kit's own, and `removeCommitGate` clears it (ADR-0113). **A dry run answers what the real run answers** (2026-10-01, arena #196): `installCommitGate` checks that `githooks/pre-commit` is deployed before it previews, and `installEditGate`'s check no longer reads `!dryRun && !exists(hook)` - on a machine with nothing deployed `--dry-run` promised both gates and exited 0 while the real run refused both and exited 1. Both previews carry `ok: true`, one result shape for every caller, and `bin/install-hooks.mjs` tests `ok` before `dryRun`: the first cut reordered the CLI without that key and a healthy deployed machine printed `commit gate: undefined`, which a CLI test over a deployed kit now pins. **A repository-local `core.hooksPath` is reported at install time** (2026-10-03, ADR-0131): `installCommitGate` takes `cwd`, probes it with `localHooksPathOverride`, and returns `displaced: { cwd, local }` on the real run and the dry run alike; `bin/install-hooks.mjs` says it on stderr with the remedy and exits 0, and the install goes through - the gate is machine state. The installer had read and written only the global path and said "installed" from inside a husky repository where the gate would never run (outside review, 2026-10-02, three rounds). **A dry run writes nothing on any path** (2026-10-03, outside audit): `removeCommitGate`, `removeEditGate` and `uninstall` take `dryRun` and answer before any write, and `bin/install-hooks.mjs` reads `--dry-run` FIRST - it was read after the role, the posture and the uninstall had run, so `--dry-run --role builder --fail-closed` saved both and `--dry-run --uninstall` unset the installed commit gate. Both tree walks skip `__pycache__` (2026-10-04, break-test pass 4 F1): CPython's cache from the conformance runners was deployed with the kit and then counted as drift on every machine that had run the suite. **The skill set** (2026-10-06, ADR-0146): `SKILL_SET_DIR` (`skills/`) and `shippedSkills(from)` - every directory there holding a `SKILL.md`, sorted; `deploy` mirrors each to `<root>/<name>` in every `skillRoots()` root and, with `into`, beside the bound research-first skill (`boundSet`); `deployedDrift` measures each against every root where research-first is deployed, so a root installed before the set existed is drift, reported, never repaired. **A same-named skill that is not the kit's is left as it is** (2026-10-06, review): a set skill is written only where its folder is absent or its SKILL.md is, byte for byte, the one the kit last deployed there (`skillSetHashes` in the install state, kept across deploys) or the one it would deploy now - the content decides, never a recorded path, which outlives the kit's copy; any other is returned as `skillConflicts` (the dry run included), printed by `install.mjs`, and skipped by drift. The first cut overwrote a user's own `brainstorming/SKILL.md` and left its other files mixed with the kit's. A set skill the kit owns is **mirrored** (`copyTree` with `mirror`) and its extras count as drift (`orphaned`), unlike research-first's (PR review, 2026-10-06): the deploy had copied over the folder, so a reference a later kit dropped stayed and could still steer the skill, and doctor called it current. **`handOnState`** lives here since 2026-10-04 (ADR-0141; doctor re-exports it): what the hooks hand on to - none, missing, kit, other - and `installCommitGate` records no folder it classifies as `kit` (`handsOnTo`), on a first install or a re-install from the state file. doctor's one-line remedy for that hand-on (ADR-0134) had lasted one session on the operator's machine: the installer kept the old kit's `githooks/` in the install state and the next `install-hooks` run wrote it back into git config, and the old gate blocked commits again on its outdated checks. The state file and git config agree once an install has run; a `missing` folder stays recorded, for the uninstall restore. **Read-only deployed files remain replaceable** (2026-10-08, break-test pass 7 correction): `copyTree` restores write permission on an existing regular-file destination before copying, independently of the incoming source's mode, and explicitly applies the source's ordinary permission bits with owner-write after copying. This covers repeated read-only-source deployment and replacement of earlier read-only output from a normal writable source. Windows chmod changes write permission only; the copied file's read-only attribute is cleared, while POSIX ordinary execute bits are preserved and `githooks` keeps its final `HOOK_MODE` (E-04/E-05 in `docs/decisions/2026-10-07-break-test-external-facts`). Source-mode propagation was observed in the original Linux probe rather than promised by the copy API; directory, ownership and ACL failures remain separate limits. | ADR-0012, ADR-0131, ADR-0141 |
| `render.mjs` | Terminal tables and Markdown findings, with no policy or I/O. `renderTable` sizes each column with a **fold, not a spread** (2026-09-28, Arena break test 12): `Math.max(header, ...rows.map(...))` passes one ARGUMENT per row, and V8 exhausts the stack somewhere past 100,000 of them - measured, 50,000 rows rendered and 200,000 threw `RangeError: Maximum call stack size exceeded`, which is a raw stack trace where a report belongs. Nothing in the kit produces a report that large today; a renderer that dies on a large input is still a renderer with an undocumented ceiling. **`renderGateWarnings(verdict)`** presents an existing evaluation in Markdown for the brief, audits and package reading guides (ADR-0143). It preserves every warning reason, remedy and supplied row/unknown target, labels whole-corpus evaluation time, and distinguishes unavailable observations from an evaluated zero. Presentation only: it performs no I/O or policy judgment. | ADR-0143 |
| `timeline.mjs` | TIMELINE.md generation and the diagnostics-log appender the gate CLI records through. Its regenerate command is spelled by `documentCommand`, with the project's recorded `kitPath` first (ADR-0084). The When column writes a day as a date and a real time as `YYYY-MM-DD HH:MM UTC` (2026-09-27): it had mixed evidence dates with the ledger's day-precision `T00:00:00.000Z` stamps. A migration row gives its recorded reason or note and a prior row its file, not `url -> raw` ("undefined -> undefined", fixed 2026-09-29). Events sort by `compareText` on their timestamp (2026-10-01). | ADR-0051 |
| `timeline.mjs` | TIMELINE.md generation and the diagnostics-log appender the gate CLI records through. Its regenerate command is spelled by `documentCommand`, with the project's recorded `kitPath` first (ADR-0084). The When column writes a day as a date and a real time as `YYYY-MM-DD HH:MM UTC` (2026-09-27): it had mixed evidence dates with the ledger's day-precision `T00:00:00.000Z` stamps. A migration row gives its recorded reason or note and a prior row its file, not `url -> raw` ("undefined -> undefined", fixed 2026-09-29). Events sort by `compareText` on their timestamp (2026-10-01). Timeline details fold whitespace before escaping table pipes, keeping multiline fetch errors in a single Markdown row; the root timeline is regenerated from its intact corpus (#354, 2026-10-13). | ADR-0051 |

**Not a module, but part of the map:** `examples/release-evidence/` (added 2026-09-20) is
six synthetic release packages plus `build.mjs` (regenerates them, keeping their hashes
Expand Down
2 changes: 1 addition & 1 deletion research-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ node research-kit/bin/selftest.mjs # all of it
node research-kit/bin/selftest.mjs gate hook # just these files
```

1789 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
1790 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means
the assertions settled (ADR-0021), and each test is raced against a watchdog
(`RESEARCH_KIT_TEST_TIMEOUT`, default 60s).

Expand Down
2 changes: 1 addition & 1 deletion research-kit/lib/timeline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function renderTimeline(root, { corpus = null } = {}) {
'',
'| When | What | Detail |',
'|---|---|---|',
...events.map((e) => `| ${when(e.at)} | ${e.kind} | ${String(e.detail).replace(/\|/g, '\\|')} |`),
...events.map((e) => `| ${when(e.at)} | ${e.kind} | ${String(e.detail).replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|')} |`),
'',
];
writeText(resolve(root, PATHS.timeline), `${lines.join('\n')}\n`);
Expand Down
17 changes: 17 additions & 0 deletions research-kit/test/hardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,23 @@ test('the timeline writes each moment in one readable form', () => {
assert.ok(whens.includes('2026-09-27 14:05 UTC'), `a real time was lost: ${whens}`);
});

test('timeline errors with CR/LF or pipes stay inside one readable table row', () => {
const dir = makePassingProject();
const corpus = readCorpus(dir);
for (const error of ['Error: failed\n', 'Error:\r\nretry | later\r\n', 'Error:\nsecond\nthird']) {
corpus.ledger.entries.push({ at: '2026-10-13T01:02:03.000Z', op: 'fail', url: 'https://x.invalid/page', error });
}
renderTimeline(dir, { corpus });
const lines = readText(resolve(dir, PATHS.timeline)).split('\n');
const rows = lines.filter((line) => line.includes('fetch failed'));
assert.equal(rows.length, 3);
assert.deepEqual(rows, [
'| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: failed |',
'| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: retry \\| later |',
'| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: second third |',
]);
});

// Found 2026-09-28 (an outside break-test, F-02; confirmed): writeText opened its target
// with 'w', which EMPTIES it before a byte is written, so a write cut short - a full disk, a
// quota, EFBIG, a killed process - left a partial new file where the old one had been.
Expand Down
12 changes: 4 additions & 8 deletions research/TIMELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,8 @@ of a gate decision. Regenerate with `node research-kit/bin/timeline.mjs`.
| 2026-09-26 | scrape | https://serpapi.com/pricing -> research/raw/2026-09-26-serpapi-plans-and-pricing-serpapi-d0c89b50.md (firecrawl-cli) |
| 2026-09-26 | scrape | https://serpapi.com/account-api -> research/raw/2026-09-26-account-api-serpapi-serpapi-8f9c166b.md (firecrawl-cli) |
| 2026-09-26 | scrape | https://serpapi.com/faq -> research/raw/2026-09-26-serpapi-faq-serpapi-7dec5bc6.md (firecrawl-cli) |
| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/pricing - Error: Request failed with status code 405
|
| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/account-api - Error: Request failed with status code 405
|
| 2026-09-26 21:20 UTC | fetch failed | https://www.merriam-webster.com/dictionary/search - Error: Request failed with status code 405
|
| 2026-09-26 21:20 UTC | fetch failed | https://plan-international.org/ - Error: Request failed with status code 405
|
| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/pricing - Error: Request failed with status code 405 |
| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/account-api - Error: Request failed with status code 405 |
| 2026-09-26 21:20 UTC | fetch failed | https://www.merriam-webster.com/dictionary/search - Error: Request failed with status code 405 |
| 2026-09-26 21:20 UTC | fetch failed | https://plan-international.org/ - Error: Request failed with status code 405 |

Loading